パターン 3. WebUIからOpenSearchに検索を行いたい

本パターンでは、WebアプリからREST APIへアクセスし、My-IoTデータストアから情報を取得することができます。

WebアプリとREST APIへのアクセスは、それぞれCognitoによる認証が行われます。
Webアプリへアクセスする際に、IoTストアのアカウントを使用して認証を行います。

クラウドアプリケーション内の構成は以下の通りです。

パターン03

No.

リソース名

概要

1

CloudFront

WebクライアントからWebアプリへのアクセスする際のエンドポイントとなります

2

ユーザー認証用Lambda

Cognitoと連携してWebクライアントからWebアプリへのアクセス、およびAPI Gatewayへアクセスする際のユーザ認証処理を行います

3

Cognito

WebクライアントからWebアプリへのアクセス、およびAPI Gatewayへアクセスする際のユーザ認証を行います

4

S3(html+js)

WebクライアントがダウンロードしてアクセスするためのUIを格納します。

5

API Gateway

Lambdaにリクエストを送信し、結果を返却します。

6

カスタム処理

Webアプリから受け取ったリクエストをカスタムLambdaに送信し、結果を返却します。

7

OpenSearchアクセス

myiot-rel-es-access-lambda
My-IoTデータストアに検索、登録等を行います。

8

OpenSearch

My-IoTデータストアです。エッジアプリから送信されたデータが蓄積されています。

CloudFormationテンプレート例

本パターンにおけるCloudFormationテンプレートを作成します。
各項目についての設定の詳細はAWSのドキュメントを参照してください。
※yml/yamlファイルの場合に、IoTストアでは!GetAttなど、短縮形の構文で組み込み関数は使用できないため、Fn::GetAttのように完全名関数の構文で記述する必要があります。

テンプレート作成する際の注意事項として以下のコメント種別で説明をします。

コメント種別

内容

+

利用目的に応じて開発者側で適切な値の設定が必要な箇所を示しています

!

My-IoTが提供する共通リソースに関する記載のため変更禁止の箇所を示しています

*

その他の補足説明を示しています

yaml形式の場合の例

下記のファイル名で作成します。

  • cloudformation.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: An AWS function.
Resources:
  #************************************************************
  # Lambda Function
  # No.2 カスタム処理Lambdaのテンプレート例です
  #
  sipSamplePattern03lm:
    Type: 'AWS::Lambda::Function'
    Properties:
      #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
      # ソースコードの格納先はIoTストアで展開時に自動設定されるため記載しないでください
      # Code:
      #   S3Bucket: 
      #   S3Key: 
      #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
      Description: ''
      Handler: lambda_function.lambda_handler
      #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
      # ロールは変更しないでください
      #
      Role: 
        Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/sip-sample-lambda-role
      #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
      Runtime: python3.7
      MemorySize: 128
      Timeout: 30
      #************************************************************
      # Lambdaのファンクション名は導入時にIoTストアにて一意の名称に変換されます
      # テンプレート内では任意の名称で構いません
      #
      FunctionName: 'sip-sample-pattern-03-lm'
      #************************************************************
      Environment:
        Variables:
        #************************************************************
        # カスタムLambdaが参照する環境変数と値を定義します
        #
          #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
          # 下記の項目は変更しないでください
          #
          # ES_ACCESS_LAMBDA: No.7 OpenSearchアクセスLambda関数名を指定しています
          # 
          ES_ACCESS_LAMBDA: myiot-rel-es-access-lambda
          #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  #
  # Lambda Function
  #************************************************************

  #************************************************************
  # ApiGateway
  # No.3 API Gatewayのテンプレート例です
  #
  RestAPI:
    Type: AWS::ApiGateway::RestApi
    Properties:
      EndpointConfiguration:
        Types:
          - REGIONAL
      #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      # 下記の項目は作成するクラウドアプリケーションに応じて変更してください
      #
      # Description: 説明文を記載してください
      # ※空文字はエラーとなります。不要の場合は「Description」を削除してください
      # Name: API名を記載してください
      #
      Description: 'sip-sample-pattern-03 RestApi'
      Name: 'sample-apigw'
      #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

  RestAPIDeployment:
    Type: AWS::ApiGateway::Deployment
    Properties:
      Description: ''
      RestApiId:
        Ref: RestAPI
    DependsOn:
      - RestAPIMethod
      - RestAPIResource

  RestAPIStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      RestApiId:
        Ref: RestAPI
      DeploymentId:
        Ref: RestAPIDeployment
      StageName: 'dev'

  RestAPIResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      PathPart: '{proxy+}'
      ParentId:
        Fn::GetAtt: RestAPI.RootResourceId
      RestApiId:
        Ref: RestAPI

  RestAPIMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
      # 下記の項目は変更しないでください
      #
      # HttpMethod:        メソッド種別を指定しています
      # AuthorizationType: 認証種別を指定しています
      # Integration:       プロキシ統合に関する設定を指定しています
      #
      HttpMethod: ANY
      AuthorizationType: NONE
      RequestParameters:
        'method.request.path.proxy': true
      Integration:
        CacheKeyParameters:
          - 'method.request.path.proxy'
        CacheNamespace:
          Ref: RestAPIResource
        IntegrationHttpMethod: POST
        Type: AWS_PROXY
        Uri:
          Fn::Sub: 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${sipSamplePattern03lm.Arn}/invocations'
      #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
      ResourceId:
        Ref: RestAPIResource
      RestApiId:
        Ref: RestAPI
  #
  # ApiGateway
  #************************************************************

  #************************************************************
  # LambdaPermission
  #
  LambdaPermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: 'lambda:InvokeFunction'
      Principal: 'apigateway.amazonaws.com'
      FunctionName: 
        Fn::GetAtt: sipSamplePattern03lm.Arn
  #
  # LambdaPermission
  #************************************************************

カスタム処理ソースコード例(Python)

下記のファイル名で作成します。

  • lambda_function.py

API Gateway経由で呼び出され、共通リソースのIoTデータへのアクセス(OpenSearch)Lambda経由でMy-IoTデータストアからデータを取得し、APIに返却するカスタム処理です。

import boto3
import copy
import json
import logging
import os
from datetime import datetime
from dateutil import tz

# Loggerオブジェクトを取得し、表示するレベルを設定します。
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# ESアクセスLambda関数名
ES_ACCESS_LAMBDA = os.environ.get('ES_ACCESS_LAMBDA')
# テナントID
TENANT_ID = os.environ.get('TENANT_ID')
# コネクタID
CONNECTOR_IDS = os.environ.get('CONNECTOR_INFO')

# エラーコード&メッセージ
ERR_INVALID_ARGUMENT = 400
ERR_INTERNAL_ERROR = 500

ERR_MSG_INVALID_ARGUMENT = {'message': 'Invalid Argument.'}
ERR_MSG_INTERNAL_ERROR = {'message': 'Internal error.'}


def is_empty_str(val):
    """値が空文字列か、又はNoneかを調べます。

    Args:
        val (str/NonType): 調べる値

    Returns:
        bool: 値が空文字列か、又はNoneなら True を返す
    """

    return not val if isinstance(val, str) else val is None


def make_response(code, body):
    """実行結果をJSON形式で返します。

    Args:
        code (int): ステータスコード
        body (str): 実行結果

    Returns:
        [dict]: 実行結果
    """

    # ==============================================================================
    # APIで返却するレスポンスには、オリジン間リソース共有(CORS)を有効にするために
    # 「Access-Control-Allow-Origin」をヘッダーに設定しています。
    # ここでは、すべてのドメインからのアクセスを許可するように設定しています。
    # ==============================================================================
    response = {
        'statusCode': code,
        'headers': {
            'Access-Control-Allow-Origin': '*',
            'Content-Type': 'application/json'
        },
        'body': json.dumps(body),
        'isBase64Encoded': False
    }

    return response


def es_query_access():
    """OpenSearchアクセスAPIを呼び出し、データの検索を行います。

    Returns:
        dict: 処理結果を返します。
    """

    # テナントIDと日付からインデックス名を生成する(テナントID_YYYY.MM.DD)
    # 例: エッジアプリからMy-IoTデータストアに今日送信されたデータを取得する場合
    JST = tz.gettz('Asia/Tokyo')
    today = datetime.now(JST).strftime('%Y.%m.%d')
    index_name = '{}_{}'.format(TENANT_ID, today)

    # ==============================================================================
    # OpenSearchにアクセスする際に必要な引数を設定しています。
    # 詳細については【PF仕様書】共通リソースの利用方法(API仕様など)
    # No.2 IoTデータへのアクセス を参照してください。
    # ここでは操作内容や、登録データなどを設定しています。
    # ==============================================================================
    # 環境変数に設定されたコネクタを検索する条件生成
    connectors = json.loads(CONNECTOR_IDS)
    connector_query = []
    connector_query_item = {
        'match': {
            'connectorID': ''
        }
    }
    for connector in connectors:
        query_item = copy.deepcopy(connector_query_item)
        query_item['match']['connectorID'] = connector
        connector_query.append(query_item)

    payload = {
        'method': 'Get',
        'index': index_name,
        'query': {
            'query': {
                'bool': {
                    'should': connector_query
                }
            },
            'size': 100,
            'sort': [{'timestamp': 'desc'}]
        }
    }

    try:
        # ==============================================================================
        # No.7 OpenSearchアクセス
        # 環境変数で定義されているmyiot-rel-es-access-lambdaに
        # リクエストを実施し、結果を取得します。
        # ==============================================================================
        res = boto3.client('lambda').invoke(
            FunctionName=ES_ACCESS_LAMBDA,
            InvocationType='RequestResponse',
            Payload=json.dumps(payload)
        )

        response = json.loads(res['Payload'].read())

    except Exception:
        import traceback
        logger.error(traceback.format_exc())
        return make_response(ERR_INTERNAL_ERROR, ERR_MSG_INTERNAL_ERROR)

    # 処理結果を返します。
    return make_response(response['statusCode'], response['body'])


# Lambda関数の処理の入口
def lambda_handler(event, context):

    # OpenSearchアクセスAPIを呼び出し、データの検索を行います。
    return es_query_access()

WebUI作成例

本パターンにおけるWebUIを作成します。
サンプルでは以下のファイル、フォルダ構成となります。

index.html
js(フォルダ)
└─ index.js

WebUI画面例(index.html)

パターン03_WebUi

<!DOCTYPE html>
<html lang="ja">
 <head>
  <meta charset="utf-8">
   <title>TOP画面</title>
   <meta name="viewport" content="width=device-width, initial-scale=1">  
   <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.0/jquery.min.js"></script>
  </head>
<body>
    <header>
      <h2 class="bg-secondary text-white text-center">検索画面</h2>
    </header>
    <div class="Search area">
      <table>
        <tr>
          <td><button type="button" id="searchBtn" style="width: 140px">検索</button></td>
        </tr>
      </table>
    </div>
    <div class="Response area">
      <table>
        <tr>
          <td><b> 実行結果:</b></td>
          <td><textarea id="result" style="width: 810px" rows="10"></textarea></td>
      </table>
    </div>
    <script type="text/javascript" src="js/index.js"></script>
</body>
</html>

WebUIソースコード例(index.js)

API Gatewayへのアクセスは、HTTPヘッダーにIDトークンを設定する必要があるため、window.onload()内でCookieからIDトークンの取得を行っています。

また、クラウドアプリのインストール時に生成されるconfig.jsonファイルからAPI Gatewayの情報を読み込み、API GatewayのURLを設定しています。

API Gatewayからステータスコード401が返却された場合は、IDトークンの再取得処理が必要となります。

IDトークンの再取得は、{WebUIアプリのURL}/refreshにajax等でアクセスすることで自動でCookieが更新されます。
Cookieの更新後、セッションストレージのtokenをCookieのidTokenで上書きする必要があります。

IDトークン再取得後も401が返却されてしまう場合は再度サインインが必要です。
HTMLの再読み込みなどを行い、サインイン画面に遷移させる処理が必要となります。

let configure = {};
let apiGatewayUrl = '';

window.onload = async function () {
    /**
     * API Gatewayへのアクセスに必要な情報を取得します
     */

    // IDトークンの取得を行います
    const idToken = document.cookie.split('; ').map((data) => {
        const _ = data.split('=');
        return { key: _[0], value: _[1] };
    }).find((data) => /\.idToken$/.test(data.key));

    // セッションストレージへ保存します
    sessionStorage.setItem('token', idToken.value);

    // config.jsonを読み込みます
    configure = await _getConfigure();

    // APIGatewayのURLを設定します
    apiGatewayUrl = configure.ApiUrl;
};

// 検索ボタンを設定します
const searchBtn = document.getElementById('searchBtn');

// 検索イベント(クリック)登録します
searchBtn.addEventListener('click', onSearchBtn);

/**
 * 検索リクエストを実行します
 */
async function onSearchBtn() {
    // リクエストを送信します
    await sendRequest();
}

/**
 * APIGatewayへリクエスト送信します
 */
function sendRequest() {
    // レスポンス内容をクリアします
    $('#result').val('');

    // URL(APIGatewayエンドポイント + 任意のパス)を設定します
    const url = apiGatewayUrl + 'test';

    $.ajax({
        url: url,
        type: 'GET',
        headers: {
            'Authorization': sessionStorage['token'],
        },
        dataType: 'json',
        contentType: 'application/json',
        cache: false,
        timeout: 60000,
    }).then((_data) => {
        // 文字列型に変換します
        const text = JSON.stringify(_data);
        // レスポンス内容を表示します
        $('#result').val(text);
    }, (XMLHttpRequest, textStatus, errorThrown) => {
        // エラー内容を表示します
        const text = XMLHttpRequest.status + ' ' + textStatus + ' ' + errorThrown.message;
        $('#result').val(text);
    });
}

// config.json読み込み
function _getConfigure() {
    return new Promise((resolve, reject) => {
        $.ajax({
            url: `./config.json`,
            type: 'GET',
            dataType: 'json',
            contentType: 'application/json',
            cache: false,
            timeout: 60000,
        }).then((data) => {
            resolve(data);
        }, (XMLHttpRequest, textStatus, errorThrown) => {
            reject();
        });
    });
}