Lambdaを使わずStep FunctionsでNature Remo Cloud APIのデータを収集する

2021.10.28

IoT事業部のやまたつです!

今日はAWS Lambdaを使わずにAWS Step FunctionsからNature RemoのCloud APIを叩いて、お部屋の温度、湿度、照度をAmazon CloudWatch metricsに収集したいと思います!

概要

Nature Remoとは

Nature株式会社からリリースされている製品です。アプリやスマートスピーカーから家電を操作できるようになります。

Nature Remo Cloud APIとは

Nature Remoを操作したり、Nature Remoに内蔵されているセンサーから得られる情報を取得したりできるWeb APIです。
今回はこれを使って温度、湿度、照度の情報を取得してみようと思います。

アウトプット

こんな感じでお部屋の温度と湿度の時系列データが確認できるようになります!

Temperatureをタイポしています!😭

AWS Step Functionsに何をさせるのか

  1. Parameter StoreからAPIのtokenを取ってくる
  2. Amazon API Gatewayを介してNature Remo Cloud APIからデータを取得する
  3. Amazon CloudWatch metricsにPutする。

AWS Lambdaでやれば良いんじゃないの?

AWS Step Functionsでやってみたかった。それ以上の回答を、僕は持ち合わせていないですね。

やっていく!

aws-cdkで書いていきます!v2を使います!  

Parameter StoreからAPIのtokenを取ってくる

予め、Nature Remo Cloud APIのtokenをParameter Storeに入れておく必要があります。
僕は /plantor/nature-remo-token という名前で入れました!

aws ssm put-parameter --name '/plantor/nature-remo-token' --value 'YOUR-TOKEN' --type 'String' --data-type 'text'

cdkはこんな感じ。

import {
  App,
  Stack,
  StackProps,
  aws_stepfunctions as sfn,
  aws_stepfunctions_tasks as tasks,
} from "aws-cdk-lib";

type Props = StackProps & {};

export class NatureRemo extends Stack {
  constructor(parent: App, id: string, props: Props) {
    super(parent, id, props);

    const taskToGetSecret = new tasks.CallAwsService(this, "GetSecretTask", {
      service: "ssm",
      action: "getParameter",
      parameters: { Name: "/plantor/nature-remo-token" },
      iamResources: ["*"],
      iamAction: "ssm:GetParameter",
      resultSelector: {
        "Token.$": "$.Parameter.Value",
      },
      resultPath: "$.SecretOutput",
    });

    new sfn.StateMachine(this, "MyStateMachine", {
      definition: taskToGetSecret,
    });
  }
}

new tasks.CallAwsService() でParameter Storeのデータを取得しています。これは今年の9月に発表されたAWS Step FunctionsのAWS SDK統合の機能を使うものです。

発表から一週間程度でaws-cdkに機能が追加されてます。活きが良い!

これによりSDKさえ対応していればAWS Step Functionsから扱うことができます。すごい!

注意⚠️

今回のようにAWS Step Functions内で秘匿情報を扱うのは、個人プロダクトだけにするのが良いと思われます。なぜならAWS Step FunctionsのStateに格納される情報はWebコンソールなどから確認することができてしまうからです。大人しくLambdaを書きましょう。

Amazon API Gatewayを介してNature Remo Cloud APIからデータを取得する

次にNature Remo Cloud APIを叩けるようにしていきます!
まずはAmazon API Gatewayを用意します。

/**
 * AWS Step Functions の Amazon API Gateway Integration は Authorization header が使えない。
 * そのため、カスタムヘッダーに入れて、Amazon API Gateway側で request parameter mappingしてあげるワークアラウンドを実装している。
 * @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html#connect-api-gateway-requests
 */
const xAuthorization = "method.request.header.x-Authorization";

const remoEndpoint = new aws_apigateway.RestApi(
  this,
  "NatureRemoEndpoint",
  {
    defaultIntegration: new aws_apigateway.HttpIntegration(
      "https://api.nature.global/1/devices",
      {
        options: {
          requestParameters: {
            "integration.request.header.Authorization": xAuthorization,
          },
        },
      },
    ),
  },
);
remoEndpoint.root.addMethod("GET", undefined, {
  requestParameters: { [xAuthorization]: true },
});

そしてAWS Step Functionsにて使います!

const taskToCallApi = new tasks.CallApiGatewayRestApiEndpoint(
  this,
  "CallNatureRemoTask",
  {
    api: remoEndpoint,
    stageName: remoEndpoint.deploymentStage.stageName,
    method: tasks.HttpMethod.GET,
    headers: sfn.TaskInput.fromObject({
      "x-Authorization": sfn.JsonPath.stringAt(
        /**
         * ドキュメントでは「Listでもいいよ」みたいに書いてあるけど、実際には配列じゃないと実行時エラーになる。
         * なので `States.Array()` が必要。
         * @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html
         */
        "States.Array(States.Format('Bearer {}', $.SecretOutput.Token))",
      ),
    }),
    resultSelector: {
      "Events.$": "$.ResponseBody[1].newest_events",
    },
    resultPath: "$.NatureRemoOutput",
  },
);

new sfn.StateMachine(this, "MyStateMachine", {
  definition: taskToGetSecret.next(taskToCallApi).next(taskToPutMetric),
});

注意⚠️

コード中のコメントにもある通り、AWS Step FunctionsのAmazon API Gateway統合ではAuthorization headerを使うことは許可されていません。そのため上記コードではAuthorization headerを使わないワークアラウンドを実装しています。ご利用は自己責任でお願いします🙇🏻‍♂️

Amazon CloudWatch metricsにPutする。

もう一息!もう一度、AWS Step FunctionsのSDK統合を使ってcloudwatch putMetricDataを呼び出します!

const taskToPutMetric = new tasks.CallAwsService(this, "PutMetricTask", {
  service: "cloudwatch",
  action: "putMetricData",
  parameters: {
    Namespace: "CUSTOM-IoT/Room",
    MetricData: [
      {
        MetricName: "Temperature",
        Value: sfn.JsonPath.numberAt("$.NatureRemoOutput.Events.te.val"),
      },
      {
        MetricName: "Illuminance",
        Value: sfn.JsonPath.numberAt("$.NatureRemoOutput.Events.il.val"),
      },
      {
        MetricName: "Humidity",
        Value: sfn.JsonPath.numberAt("$.NatureRemoOutput.Events.hu.val"),
      },
    ],
  },
  iamResources: ["*"],
  iamAction: "cloudwatch:PutMetricData",
  resultPath: "$.PutMetricOutput",
});

const stateMachine = new sfn.StateMachine(this, "MyStateMachine", {
  definition: taskToGetSecret.next(taskToCallApi).next(taskToPutMetric),
});

完成!

コードの全体は以下のとおりです。

import {
  App,
  Duration,
  Stack,
  StackProps,
  aws_events,
  aws_events_targets,
  aws_stepfunctions as sfn,
  aws_stepfunctions_tasks as tasks,
  aws_apigateway,
} from "aws-cdk-lib";

type Props = StackProps & {};

export class NatureRemo extends Stack {
  constructor(parent: App, id: string, props: Props) {
    super(parent, id, props);

    const remoEndpoint = this.natureRemoEndpoint();

    const taskToGetSecret = new tasks.CallAwsService(this, "GetSecretTask", {
      service: "ssm",
      action: "getParameter",
      parameters: { Name: "/plantor/nature-remo-token" },
      iamResources: ["*"],
      iamAction: "ssm:GetParameter",
      resultSelector: {
        "Token.$": "$.Parameter.Value",
      },
      resultPath: "$.SecretOutput",
    });

    const taskToCallApi = new tasks.CallApiGatewayRestApiEndpoint(
      this,
      "CallNatureRemoTask",
      {
        api: remoEndpoint,
        stageName: remoEndpoint.deploymentStage.stageName,
        method: tasks.HttpMethod.GET,
        headers: sfn.TaskInput.fromObject({
          "x-Authorization": sfn.JsonPath.stringAt(
            /**
             * ドキュメントでは「Listでもいいよ」みたいに書いてあるけど、実際には配列じゃないと実行時エラーになる。
             * なので `States.Array()` が必要。
             * @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html
             */
            "States.Array(States.Format('Bearer {}', $.SecretOutput.Token))",
          ),
        }),
        resultSelector: {
          "Events.$": "$.ResponseBody[1].newest_events",
        },
        resultPath: "$.NatureRemoOutput",
      },
    );

    const taskToPutMetric = new tasks.CallAwsService(this, "PutMetricTask", {
      service: "cloudwatch",
      action: "putMetricData",
      parameters: {
        Namespace: "CUSTOM-IoT/Room",
        MetricData: [
          {
            MetricName: "Temperature",
            Value: sfn.JsonPath.numberAt("$.NatureRemoOutput.Events.te.val"),
          },
          {
            MetricName: "Illuminance",
            Value: sfn.JsonPath.numberAt("$.NatureRemoOutput.Events.il.val"),
          },
          {
            MetricName: "Humidity",
            Value: sfn.JsonPath.numberAt("$.NatureRemoOutput.Events.hu.val"),
          },
        ],
      },
      iamResources: ["*"],
      iamAction: "cloudwatch:PutMetricData",
      resultPath: "$.PutMetricOutput",
    });

    const stateMachine = new sfn.StateMachine(this, "MyStateMachine", {
      definition: taskToGetSecret.next(taskToCallApi).next(taskToPutMetric),
    });

    new aws_events.Rule(this, "ScheduleRule", {
      schedule: aws_events.Schedule.rate(Duration.minutes(60)),
      targets: [new aws_events_targets.SfnStateMachine(stateMachine)],
    });
  }

  /**
   * 後ろ側にnatureRemoのAPIを設定したAmazon API Gateway
   */
  private natureRemoEndpoint() {
    /**
     * AWS Step Functions の Amazon API Gateway Integration は Authorization header が使えない。
     * そのため、カスタムヘッダーに入れて、Amazon API Gateway側で request parameter mappingしてあげるワークアラウンドを実装している。
     * @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html#connect-api-gateway-requests
     */
    const xAuthorization = "method.request.header.x-Authorization";

    const remoEndpoint = new aws_apigateway.RestApi(
      this,
      "NatureRemoEndpoint",
      {
        defaultIntegration: new aws_apigateway.HttpIntegration(
          "https://api.nature.global/1/devices",
          {
            options: {
              requestParameters: {
                "integration.request.header.Authorization": xAuthorization,
              },
            },
          },
        ),
      },
    );
    remoEndpoint.root.addMethod("GET", undefined, {
      requestParameters: { [xAuthorization]: true },
    });

    return remoEndpoint;
  }
}

今回作成したコードはGitHubにもコミットしてあります。

まとめ

AWS Lambdaのコードを書かずに外部APIの結果をメトリクスデータとして可視化できました!
「結局cdkのためにtypescript書いてるやないかい」というツッコミは甘んじて受けます!

AWS Lambdaのコードを書くかどうかは置いておいても、SDK統合によってAWS Step Functionsが格段にパワーアップしたのを感じました!
もし機会があればぜひ使ってみてください!

以上、やまたつでした!