CLOVER🍀

That was when it all began.

LocalStack × AWS SAMで、AWS Step FunctionsのJSONata対応を試す

これは、なにをしたくて書いたもの?

JSONata?

JSONataは、JSON用のクエリーおよび変換言語です。

JSONata Documentation · JSONata

AWS Step FunctionsのJSONata対応

AWS Step Functionsではデータ操作を行うのにJSONPathを使っていましたが、2024年11月からJSONataも使えるように なりました。

変数と JSONata を使った AWS Step Functions での開発者エクスペリエンスの簡素化 | Amazon Web Services ブログ

Step Functions での JSONata を使用したデータの変換 - AWS Step Functions

AWS Step FunctionsでJSONataを使うと、JSONPathを利用していた時よりも簡単にデータ操作を記述できるようになります。

AWS Step FunctionsでJSONataを使うには、ステートマシン全体もしくはステートのQueryLanguageJSONataを指定します。

{
  "QueryLanguage": "JSONata", // Set explicitly; could be set and inherited from top-level
  "Type": "Task",
  ...
  "Arguments": { // JSONata states do not have Parameters
    "static": "Hello",
    "title": "{% $states.input.title %}", 
    "name": "{% $customerName %}",   // With $customerName declared as a variable
    "not-evaluated": "$customerName"
  }
}

Step Functions ワークフローの Amazon States Language でのステートマシン構造 - AWS Step Functions

これで対象のステートでJSONataが使えるようになり、JSONPathは利用できなくなります。ワークフロー全体で指定した
場合は、個々のステートでQueryLanguageを再度指定しない限りはJSONataで記述することになります。

JSONPathからJSONataに切り替えると、ステートの記述方法に以下のような変化があります。

  • InputPath、Parameters → Arguments
  • ResultSelector、ResultPath、OutputPath → Output

ASL上でJSONataを記述するには、"{% <JSONata expression> %}"と書く必要があります。そしてJSONPathを使っている時は
フィールド名の最後に.$を付けていましたが、これは必要なくなります。

また$statesという予約変数も追加されます。

# Reserved $states variable in JSONata states
$states = {
  "input":       // Original input to the state
  "result":      // API or sub-workflow's result (if successful)
  "errorOutput": // Error Output (only available in a Catch)
  "context":     // Context object
}

Step Functions での JSONata を使用したデータの変換 / 予約変数: $states

この予約変数がなかなか便利で、JSONPathを使う時は入出力の一部しか参照できないことが多かったのですが、JSONataに
すると扱える幅が大きく広がります。

JSONataの演算子や関数も利用できるようになります。

参考)

[アップデート] AWS Step Functions で変数が使えるようになりました | DevelopersIO

これでLambdaが不要に?!Step FunctionsのJSONata対応について - Speaker Deck

AWS Step FunctionsのPassステートで入力内容を保ったまま出力を追加・編集する(JSONata形式) – Kiwi blog

LocalStackでのAWS Step Functions

LocalStackでは3.0でAWS Step Functionsの実装が新しくなったようです。

New StepFunctions implementation in LocalStack 3.0

Remove legacy StepFunctions v1 provider by joe4dev · Pull Request #11734 · localstack/localstack · GitHub

JSONataへの対応も入ったみたいです。

Add Step Functions support for Variables and JSONata features by dominikschubert · Pull Request #11906 · localstack/localstack · GitHub

しかもAWSと提携して実現しているとか。

AWS Step Functions が Variables と JSONata の変換で開発者のエクスペリエンスを簡素化 - AWS

情報を見るのはこれくらいにして、AWS Step FunctionsとJSONataを使ってみましょう。

環境

今回の環境はこちら。

$ python3 --version
Python 3.12.3


$ pip3 --version
pip 24.0 from /usr/lib/python3/dist-packages/pip (python 3.12)


$ awslocal --version
aws-cli/2.25.0 Python/3.12.9 Linux/6.8.0-55-generic exe/x86_64.ubuntu.24


$ samlocal --version
SAM CLI, version 1.135.0


$ localstack --version
LocalStack CLI 4.2.0

LocalStackを起動。

$ localstack start

お題

今回のお題は、前にやったこちらのエントリーをJSONPathからJSONataで書き直すものにします。

LocalStack × AWS SAMで、AWS Lambda関数を使った入出力を扱うAWS Step Functionsのワークフローを書いてみる - CLOVER🍀

つまり、こういう入力を与えると

{
  "message": "Hello World"
}

こうなって

{
  "result": "★★★Hello World★★★",
  "message": "★★★Hello World★★★",
  "inputOriginalMessage": "Hello World",
  "withStar": {
    "result": "★★★Hello World★★★"
  }
}

最後にこうなるワークフローを組みます。

{
  "result": "***★★★Hello World★★★***",
  "inputOriginalMessage": "Hello World",
  "withStar": {
    "result": "★★★Hello World★★★"
  },
  "wishAsterisk": {
    "result": "***★★★Hello World★★★***"
  }
}

ちなみに、この時のASLの定義はこんな感じでした。

{
  "Comment": "My Input Output State Machine",
  "StartAt": "WithStar",
  "States": {
    "WithStar": {
      "Type": "Task",
      "Resource": "${StarFunctionArn}",
      "ResultPath": "$.withStar",
      "Next": "FormatStarOutput"
    },
    "FormatStarOutput": {
      "Type": "Pass",
      "Parameters": {
        "message.$": "$.withStar.result",
        "inputOriginalMessage.$": "$.message",
        "result.$": "$.withStar.result",
        "withStar.$": "$.withStar"
      },
      "Next": "WithAsterisk"
    },
    "WithAsterisk": {
      "Type": "Task",
      "Resource": "${AsteriskFunctionArn}",
      "ResultPath": "$.withAsterisk",
      "Next": "FormatAsteriskOutput"
    },
    "FormatAsteriskOutput": {
      "Type": "Pass",
      "Parameters": {
        "result.$": "$.withAsterisk.result",
        "inputOriginalMessage.$": "$.inputOriginalMessage",
        "withStar.$": "$.withStar",
        "withAsterisk.$": "$.withAsterisk"
      },
      "End": true
    }
  }
}

これがJSONataでどれくらい書きやすくなったかを比べたいと思います。

AWS Lambda関数を作成する

まずはAWS Lambda関数を作成していきます。この内容だと、もはやAWS Lambda関数は要らない気もしますが、まずは前回の
置き換えということで。

$ samlocal init --name sfn-localstack-input-output-jsonata --runtime python3.12 --app-template hello-world --package-type Zip --no-tracing --no-application-insights --structured-logging
$ cd sfn-localstack-input-output-jsonata

使わないものを削除。

$ rm -rf events hello_world tests

AWS Lambda関数を作成します。

渡された文字列に「★」を加えるAWS Lambda関数。

star/app.py

def lambda_handler(event, context) -> dict:
    print(f"star function input = {event}")

    message = event["message"]

    return {
        "result": ("★" * 3) + message + ("★" * 3)
    }

渡された文字列に「*」を加えるAWS Lambda関数。

asterisk/app.py

def lambda_handler(event, context) -> dict:
    print(f"asterisk function input = {event}")

    message = event["message"]

    return {
        "result": ("*" * 3) + message + ("*" * 3),
    }

どちらも入力を標準出力に書き出すようにしています。

AWS Step Functionsのステートマシンの定義。今回は2つのステートで構成していて、AWS Lambda関数のARNは
パラメーター化しています。

statemachine/input-output-state-machine.asl.json

{{
  "Comment": "My Input Output State Machine, Using JSONata",
  "QueryLanguage": "JSONata", 
  "StartAt": "WithStar",
  "States": {
    "WithStar": {
      "Type": "Task",
      "Resource": "${StarFunctionArn}",
      "Output": "{% {'message': $states.result.result, 'inputOriginalMessage': $states.input.message, 'result': $states.result.result, 'withStar': $states.result} %}",
      "Next": "WithAsterisk"
    },
    "WithAsterisk": {
      "Type": "Task",
      "Resource": "${AsteriskFunctionArn}",
      "Output": "{% {'result': $states.result.result, 'inputOriginalMessage': $states.input.inputOriginalMessage, 'withStar': $states.input.withStar, 'withAsterisk': $states.result} %}",
      "End": true
    }
  }
}

ポイントは、まずは"QueryLanguage": "JSONata"の指定ですね。今回はステートマシン全体で指定しました。

  "QueryLanguage": "JSONata", 

そして、今回はOutputで変数操作が全部できてしまったので、JSONPathの時に使っていた変数操作のためだけのステートが
なくなりました。

JSONataを使う時は、{% <JSONata expression> %}という表記にする必要があります。またJSONPathの時は動的に評価する
項目は末尾につけていた.$(下の例でいえばOutput.$としていた)が不要になります。

      "Output": "{% {'message': $states.result.result, 'inputOriginalMessage': $states.input.message, 'result': $states.result.result, 'withStar': $states.result} %}",

AWS SAMテンプレート。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sfn-localstack-input-output-jsonata

  Sample SAM Template for sfn-localstack-input-output-jsonata

Globals:
  Function:
    Timeout: 3
    LoggingConfig:
      LogFormat: JSON

Resources:
Resources:
  StarFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: star-function
      CodeUri: star/
      Handler: app.lambda_handler
      Runtime: python3.10
      Architectures:
      - x86_64
  AsteriskFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: asterisk-function
      CodeUri: asterisk/
      Handler: app.lambda_handler
      Runtime: python3.10
      Architectures:
      - x86_64
  InputOutputStateMachine:
    Type: AWS::Serverless::StateMachine
    Properties:
      Name: input-output-state-machine
      DefinitionUri: statemachine/input-output-state-machine.asl.json
      DefinitionSubstitutions:
        StarFunctionArn: !GetAtt StarFunction.Arn
        AsteriskFunctionArn: !GetAtt AsteriskFunction.Arn

Outputs:
  StarFunction:
    Description: Star Lambda Function ARN
    Value: !GetAtt StarFunction.Arn
  StarFunctionIamRole:
    Description: Implicit IAM Role created for Star function
    Value: !GetAtt StarFunctionRole.Arn
  AsteriskFunction:
    Description: Asterisk Lambda Function ARN
    Value: !GetAtt AsteriskFunction.Arn
  AsteriskFunctionIamRole:
    Description: Implicit IAM Role created for Asterisk function
    Value: !GetAtt AsteriskFunctionRole.Arn
  InputOutputStateMachineArn:
    Description: "Input Output State machine ARN"
    Value: !Ref InputOutputStateMachine
  InputOutputStateMachineRoleArn:
    Description: "IAM Role created for Input Output State machine based on the specified SAM Policy Templates"
    Value: !GetAtt InputOutputStateMachineRole.Arn

デプロイ。

$ samlocal deploy --region us-east-1

完了しました。

CloudFormation events from stack operations (refresh every 5.0 seconds)
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ResourceStatus                             ResourceType                               LogicalResourceId                          ResourceStatusReason
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------
CREATE_IN_PROGRESS                         AWS::CloudFormation::Stack                 sfn-localstack-input-output-jsonata        -
CREATE_IN_PROGRESS                         AWS::IAM::Role                             StarFunctionRole                           -
CREATE_COMPLETE                            AWS::IAM::Role                             StarFunctionRole                           -
CREATE_IN_PROGRESS                         AWS::IAM::Role                             AsteriskFunctionRole                       -
CREATE_COMPLETE                            AWS::IAM::Role                             AsteriskFunctionRole                       -
CREATE_IN_PROGRESS                         AWS::IAM::Role                             InputOutputStateMachineRole                -
CREATE_COMPLETE                            AWS::IAM::Role                             InputOutputStateMachineRole                -
CREATE_IN_PROGRESS                         AWS::Lambda::Function                      StarFunction                               -
CREATE_COMPLETE                            AWS::Lambda::Function                      StarFunction                               -
CREATE_IN_PROGRESS                         AWS::Lambda::Function                      AsteriskFunction                           -
CREATE_COMPLETE                            AWS::Lambda::Function                      AsteriskFunction                           -
CREATE_IN_PROGRESS                         AWS::StepFunctions::StateMachine           InputOutputStateMachine                    -
CREATE_COMPLETE                            AWS::StepFunctions::StateMachine           InputOutputStateMachine                    -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 sfn-localstack-input-output-jsonata        -
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------

CloudFormation outputs from deployed stack
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 StarFunction
Description         Star Lambda Function ARN
Value               arn:aws:lambda:us-east-1:000000000000:function:star-function

Key                 StarFunctionIamRole
Description         Implicit IAM Role created for Star function
Value               arn:aws:iam::000000000000:role/sfn-localstack-input-output-jsonata-StarFunctionRole-c8227ab2

Key                 AsteriskFunction
Description         Asterisk Lambda Function ARN
Value               arn:aws:lambda:us-east-1:000000000000:function:asterisk-function

Key                 AsteriskFunctionIamRole
Description         Implicit IAM Role created for Asterisk function
Value               arn:aws:iam::000000000000:role/sfn-localstack-input-output-jsona-AsteriskFunctionRole-c6d4eabe

Key                 InputOutputStateMachineArn
Description         Input Output State machine ARN
Value               arn:aws:states:us-east-1:000000000000:stateMachine:input-output-state-machine

Key                 InputOutputStateMachineRoleArn
Description         IAM Role created for Input Output State machine based on the specified SAM Policy Templates
Value               arn:aws:iam::000000000000:role/sfn-localstack-input-output-j-InputOutputStateMachineR-9e5607a0
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Successfully created/updated stack - sfn-localstack-input-output-jsonata in us-east-1

では、実行してみます。

$ awslocal stepfunctions start-execution --state-machine arn:aws:states:us-east-1:000000000000:stateMachine:input-output-state-machine --name input-output --input '{"message": "Hello World"}'
{
    "executionArn": "arn:aws:states:us-east-1:000000000000:execution:input-output-state-machine:input-output",
    "startDate": "2025-03-21T16:44:26.881388+09:00"
}

結果はこちら。

$ awslocal stepfunctions describe-execution --execution-arn arn:aws:states:us-east-1:000000000000:execution:input-output-state-machine:input-output
{
    "executionArn": "arn:aws:states:us-east-1:000000000000:execution:input-output-state-machine:input-output",
    "stateMachineArn": "arn:aws:states:us-east-1:000000000000:stateMachine:input-output-state-machine",
    "name": "input-output",
    "status": "SUCCEEDED",
    "startDate": "2025-03-21T16:44:26.881388+09:00",
    "stopDate": "2025-03-21T16:44:29.008236+09:00",
    "input": "{\"message\":\"Hello World\"}",
    "inputDetails": {
        "included": true
    },
    "output": "{\"result\":\"***\\u2605\\u2605\\u2605Hello World\\u2605\\u2605\\u2605***\",\"inputOriginalMessage\":\"Hello World\",\"withStar\":{\"result\":\"\\u2605\\u2605\\u2605Hello World\\u2605\\u2605\\u2605\"},\"withAsterisk\":{\"result\":\"***\\u2605\\u2605\\u2605Hello World\\u2605\\u2605\\u2605***\"}}",
    "outputDetails": {
        "included": true
    }
}

わかりにくいですが、狙った結果が得られています。JSONPathで作った時と同じ結果です。

    "output": "{\"result\":\"***\\u2605\\u2605\\u2605Hello World\\u2605\\u2605\\u2605***\",\"inputOriginalMessage\":\"Hello World\",\"withStar\":{\"result\":\"\\u2605\\u2605\\u2605Hello World\\u2605\\u2605\\u2605\"},\"withAsterisk\":{\"result\":\"***\\u2605\\u2605\\u2605Hello World\\u2605\\u2605\\u2605***\"}}",

ここからは、前回同様にステートマシンの定義とAWS Lambda関数のログを突き合わせながら見ていきます。

最初に、入力はこれでしたね。

{
  "message": "Hello World"
}

最初のステートは、「★」を追加するAWS Lambda関数の呼び出しです。

    "WithStar": {
      "Type": "Task",
      "Resource": "${StarFunctionArn}",
      "Output": "{% {'message': $states.result.result, 'inputOriginalMessage': $states.input.message, 'result': $states.result.result, 'withStar': $states.result} %}",
      "Next": "WithAsterisk"
    },

入力データのログを確認してみます。

$ awslocal logs tail /aws/lambda/star-function

messageが渡ってきています。

2025-03-21T07:44:27.635000+00:00 2025/03/21/[$LATEST]8bb44bd9f2a6dd1c304357a2f50b02fb star function input = {'message': 'Hello World'}

AWS Lambda関数は入力値の前後に「★」を3つつけるだけでした。

    message = event["message"]

    return {
        "result": ("★" * 3) + message + ("★" * 3)
    }

つまり、この関数の戻り値はこうなります。

{
  "result": "★★★Hello World★★★"
}

ここで、Outputでこのステートの入力と出力を組み合わせます。

      "Output": "{% {'message': $states.result.result, 'inputOriginalMessage': $states.input.message, 'result': $states.result.result, 'withStar': $states.result} %}",

$statesAWS Step FunctionsのASLでJSONataを使った時の予約語です。

$states.inputでステートの入力を、$states.resultでステートの出力を扱えます。今回は使っていませんが、他には
$states.errorOutput$states.contextもあります。

Step Functions での JSONata を使用したデータの変換 / 予約変数: $states

Step Functions の Context オブジェクトからの実行データへのアクセス - AWS Step Functions

結果はこちら。

{
  "message": "★★★Hello World★★★",
  "inputOriginalMessage": "Hello World",
  "result": "★★★Hello World★★★",
  "withStar": {
    "result": "★★★Hello World★★★"
  }
}

JSONPathではAWS Lambda関数の呼び出しの後にPassステートとParametersで変数操作をしていたのですが、それが
不要になりました。

そしてこれが次のステートの入力になります。

    "WithAsterisk": {
      "Type": "Task",
      "Resource": "${AsteriskFunctionArn}",
      "Output": "{% {'result': $states.result.result, 'inputOriginalMessage': $states.input.inputOriginalMessage, 'withStar': $states.input.withStar, 'withAsterisk': $states.result} %}",
      "End": true
    }

ログを確認してみます。

$ awslocal logs tail /aws/lambda/asterisk-function

WithStarステートの結果がそのまま渡されていますね。

2025-03-21T07:44:28.981000+00:00 2025/03/21/[$LATEST]b49aea79c9b924026e956d45a74001fe asterisk function input = {'message': '★★★Hello World★★★', 'inputOriginalMessage': 'Hello World', 'result': '★★★Hello World★★★', 'withStar': {'result': '★★★Hello World★★★'}}

AWS Lambda関数は、「*」を3つつけるようになっていました。

    message = event["message"]

    return {
        "result": ("*" * 3) + message + ("*" * 3),
    }

よって、AWS Lambda関数の戻り値としてはこうなります。

{
  "result": "***★★★Hello World★★★***"
}

ステートの出力は、Outputで決定します。

      "Output": "{% {'result': $states.result.result, 'inputOriginalMessage': $states.input.inputOriginalMessage, 'withStar': $states.input.withStar, 'withAsterisk': $states.result} %}",

つまり、この結果になるわけです。

{
  "result": "***★★★Hello World★★★***",
  "inputOriginalMessage": "Hello World",
  "withStar": {
    "result": "★★★Hello World★★★"
  },
  "withAsterisk": {
    "result": "***★★★Hello World★★★***"
  }
}

1度JSONPathでベースを組んでいるからというのもありますが、JSONPathを使っている時はResultSelectorResultPath
タスクの出力結果をいろいろと使いたい場合などにだいぶ苦戦しましたが、JSONataだと入出力全体で扱うのでかなり
簡単に済ませることができました。

今回は登場しませんでしたが、(JSONPathの時はParametersを使ってPassステートで変数を加工していたのがそもそも
不要になったので)Argumentsを使うとタスクの入力も柔軟に扱えます。

入出力がArgumentsOutputとシンプルに使えるようになったのでかなりわかりやすくなったと思います。

おわりに

LocalStackとAWS SAMで、AWS Lambda関数を使った入出力を扱うAWS Step FunctionsのワークフローをJSONataを
使って書き直してみました。

JSONPathの時にとてもとても苦労したので、理解度が上がったのもあるはずですがかなりあっさり書けるようになって
ちょっと嬉しいです(笑)。

今後は積極的に使ってよい気がしますね。