Skip to content

Commit

Permalink
feat: support jsonata expressions (#162)
Browse files Browse the repository at this point in the history
feat: support new fields for variables
chore: bump asl-path-validator version
chore: npm audit fix
  • Loading branch information
massfords authored Dec 3, 2024
1 parent 07f9fb5 commit 61f3d88
Show file tree
Hide file tree
Showing 21 changed files with 1,584 additions and 694 deletions.
1,788 changes: 1,105 additions & 683 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"homepage": "https://github.com/ChristopheBougere/asl-validator#readme",
"dependencies": {
"ajv": "^8.12.0",
"asl-path-validator": "^0.14.2",
"asl-path-validator": "^0.15.0",
"commander": "^10.0.1",
"jsonpath-plus": "^10.0.0",
"yaml": "^2.3.1"
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/definitions/invalid-jsonata-fields.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"Comment": "QueryLanguage is JSONata but has JSONPath fields",
"StartAt": "EmptyState",
"QueryLanguage": "JSONata",
"States": {
"EmptyState": {
"Type": "Pass",
"Parameters": ["abc"],
"ResultPath": "$.emptyState",
"End": true
}
}
}
106 changes: 106 additions & 0 deletions src/__tests__/definitions/invalid-jsonata-path-fields.asl.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
{
"Comment": "Incorrectly has JSONPath fields on JSONata states",
"StartAt": "Verification",
"States": {
"Verification": {
"Type": "Parallel",
"QueryLanguage": "JSONata",
"Branches": [
{
"StartAt": "Check Identity",
"States": {
"Check Identity": {
"Type": "Pass",
"QueryLanguage": "JSONata",
"End": true,
"OutputPath": "$.foo"
}
}
},
{
"StartAt": "Check Address",
"States": {
"Check Address": {
"Type": "Pass",
"QueryLanguage": "JSONata",
"End": true,
"Output": {
"isAddressValid": "{% $not(null in $each($states.input.data.address, function($v) { $length($trim($v)) > 0 ? $v : null })) %}"
}
}
}
}
],
"Assign": {
"inputPayload": "{% $states.context.Execution.Input %}",
"isCustomerValid": "{% $states.result.isIdentityValid and $states.result.isAddressValid %}"
},
"Next": "Approve or Deny?"
},
"Approve or Deny?": {
"Type": "Choice",
"QueryLanguage": "JSONata",
"Choices": [
{
"Next": "Add Account",
"Condition": "{% $isCustomerValid %}"
}
],
"Default": "Deny Message"
},
"Add Account": {
"Type": "Task",
"QueryLanguage": "JSONata",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Arguments": {
"TableName": "${AccountsTable}",
"Item": {
"PK": {
"S": "{% $uuid() %}"
},
"email": {
"S": "{% $inputPayload.data.identity.email %}"
},
"name": {
"S": "{% $inputPayload.data.firstname & ' ' & $inputPayload.data.lastname %}"
},
"address": {
"S": "{% $join($each($inputPayload.data.address, function($v) { $v }), ', ') %}"
},
"timestamp": {
"S": "{% $now() %}"
}
}
},
"Next": "Home Insurance Interests"
},
"Home Insurance Interests": {
"Type": "Task",
"QueryLanguage": "JSONata",
"Resource": "arn:aws:states:::sqs:sendMessage",
"Arguments": {
"QueueUrl": "${HomeInsuranceInterestQueueArn}",
"MessageBody": "{% ($e := $inputPayload.data.identity.email; $n := $inputPayload.data.firstname & ' ' & $inputPayload.data.lastname; $inputPayload.data.interests[category = 'home']{'customer': $n, 'email': $e, 'totalAssetValue': $sum(estimatedValue), category: {type: yearBuilt}}) %}"
},
"Next": "Approved Message"
},
"Approved Message": {
"Type": "Task",
"Resource": "arn:aws:states:::sns:publish",
"Parameters": {
"TopicArn": "arn:aws:sns:us-east-1:123456789012:CustomerNotifications",
"Message.$": "States.Format('Hello {}, your application has been approved.', $inputPayload.data.firstname)"
},
"End": true
},
"Deny Message": {
"Type": "Task",
"Resource": "arn:aws:states:::sns:publish",
"Parameters": {
"TopicArn": "${SendCustomerNotificationSNSTopicArn}",
"Message.$": "States.Format('Hello {}, your application has been denied because validation of provided data failed', $inputPayload.data.firstname)"
},
"End": true
}
}
}
19 changes: 19 additions & 0 deletions src/__tests__/definitions/invalid-variable-result-path.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"Comment": "ResultPath cannot contain a variable",
"StartAt": "StepOne",
"States": {
"StepOne": {
"Type": "Pass",
"Assign": {
"foo": "bar"
},
"Next": "Fin"
},
"Fin": {
"Type": "Task",
"Resource": "arn:aws:lambda:region-1:1234567890:function:InvalidResultPath",
"ResultPath": "$foo",
"End": true
}
}
}
108 changes: 108 additions & 0 deletions src/__tests__/definitions/valid-jsonata.asl.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
{
"Comment": "This is a state machine for account application service",
"StartAt": "Verification",
"States": {
"Verification": {
"Type": "Parallel",
"QueryLanguage": "JSONata",
"Branches": [
{
"StartAt": "Check Identity",
"States": {
"Check Identity": {
"Type": "Pass",
"QueryLanguage": "JSONata",
"End": true,
"Output": {
"isIdentityValid": "{% $match($states.input.data.identity.email, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/) and $match($states.input.data.identity.ssn, /^(\\d{3}-?\\d{2}-?\\d{4}|XXX-XX-XXXX)$/) %}"
}
}
}
},
{
"StartAt": "Check Address",
"States": {
"Check Address": {
"Type": "Pass",
"QueryLanguage": "JSONata",
"End": true,
"Output": {
"isAddressValid": "{% $not(null in $each($states.input.data.address, function($v) { $length($trim($v)) > 0 ? $v : null })) %}"
}
}
}
}
],
"Assign": {
"inputPayload": "{% $states.context.Execution.Input %}",
"isCustomerValid": "{% $states.result.isIdentityValid and $states.result.isAddressValid %}"
},
"Next": "Approve or Deny?"
},
"Approve or Deny?": {
"Type": "Choice",
"QueryLanguage": "JSONata",
"Choices": [
{
"Next": "Add Account",
"Condition": "{% $isCustomerValid %}"
}
],
"Default": "Deny Message"
},
"Add Account": {
"Type": "Task",
"QueryLanguage": "JSONata",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Arguments": {
"TableName": "${AccountsTable}",
"Item": {
"PK": {
"S": "{% $uuid() %}"
},
"email": {
"S": "{% $inputPayload.data.identity.email %}"
},
"name": {
"S": "{% $inputPayload.data.firstname & ' ' & $inputPayload.data.lastname %}"
},
"address": {
"S": "{% $join($each($inputPayload.data.address, function($v) { $v }), ', ') %}"
},
"timestamp": {
"S": "{% $now() %}"
}
}
},
"Next": "Home Insurance Interests"
},
"Home Insurance Interests": {
"Type": "Task",
"QueryLanguage": "JSONata",
"Resource": "arn:aws:states:::sqs:sendMessage",
"Arguments": {
"QueueUrl": "${HomeInsuranceInterestQueueArn}",
"MessageBody": "{% ($e := $inputPayload.data.identity.email; $n := $inputPayload.data.firstname & ' ' & $inputPayload.data.lastname; $inputPayload.data.interests[category = 'home']{'customer': $n, 'email': $e, 'totalAssetValue': $sum(estimatedValue), category: {type: yearBuilt}}) %}"
},
"Next": "Approved Message"
},
"Approved Message": {
"Type": "Task",
"Resource": "arn:aws:states:::sns:publish",
"Parameters": {
"TopicArn": "arn:aws:sns:us-east-1:123456789012:CustomerNotifications",
"Message.$": "States.Format('Hello {}, your application has been approved.', $inputPayload.data.firstname)"
},
"End": true
},
"Deny Message": {
"Type": "Task",
"Resource": "arn:aws:states:::sns:publish",
"Parameters": {
"TopicArn": "${SendCustomerNotificationSNSTopicArn}",
"Message.$": "States.Format('Hello {}, your application has been denied because validation of provided data failed', $inputPayload.data.firstname)"
},
"End": true
}
}
}
4 changes: 4 additions & 0 deletions src/checks/json-schema-errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Ajv, { ErrorObject } from "ajv";
import paths from "../schemas/paths.json";
import jsonata from "../schemas/jsonata.json";
import choice from "../schemas/choice.json";
import fail from "../schemas/fail.json";
import parallel from "../schemas/parallel.json";
Expand All @@ -20,6 +21,7 @@ export const jsonSchemaErrors: AslChecker = (definition, options) => {
const ajv = new Ajv({
schemas: [
paths,
jsonata,
choice,
fail,
parallel,
Expand All @@ -41,6 +43,8 @@ export const jsonSchemaErrors: AslChecker = (definition, options) => {
ajv.addFormat("asl_path", () => true);
ajv.addFormat("asl_ref_path", () => true);
ajv.addFormat("asl_payload_template", () => true);
// An ASL ResultPath is a ReferencePath that cannot have variables.
ajv.addFormat("asl_result_path", () => true);
}
if (options.checkArn) {
ajv.addFormat("asl_arn", isArnFormatValid);
Expand Down
58 changes: 55 additions & 3 deletions src/checks/state-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,17 @@ const getPropertyCount = ({
.reduce((prev, curr) => prev + curr, 0);
};

export const AtMostOne = ({
const enforceMaxCount = ({
props,
errorCode,
path,
maxCount,
errorMessage,
}: {
props: string[];
errorCode: StateMachineErrorCode;
maxCount: number;
errorMessage: string;
// path to a sub-property within the state to use as the
// context for the property checks. This is intended to
// support enforcement of constraints on nested properties
Expand All @@ -106,12 +110,12 @@ export const AtMostOne = ({
return null;
}
const count = getPropertyCount({ object, props });
if (count > 1) {
if (count > maxCount) {
return {
"Error code": errorCode,
// Use of JSONPath within the error message is unnecessary
// since the state names are unique.
Message: `State "${stateName}" MUST contain at most one of ${props
Message: `State "${stateName}" ${errorMessage} ${props
.map((p) => {
return `"${p}"`;
})
Expand All @@ -122,6 +126,54 @@ export const AtMostOne = ({
};
};

export const AtMostOne = ({
props,
errorCode,
path,
}: {
props: string[];
errorCode: StateMachineErrorCode;
// path to a sub-property within the state to use as the
// context for the property checks. This is intended to
// support enforcement of constraints on nested properties
// within a State.
// See the Map's ItemReader.ReaderConfiguration at most one
// rule for MaxItems and MaxItemsPath.
path?: string;
}): StateChecker => {
return enforceMaxCount({
maxCount: 1,
props,
errorCode,
path,
errorMessage: "MUST contain at most one of",
});
};

export const None = ({
props,
errorCode,
path,
}: {
props: string[];
errorCode: StateMachineErrorCode;
// path to a sub-property within the state to use as the
// context for the property checks. This is intended to
// support enforcement of constraints on nested properties
// within a State.
// See the Map's ItemReader.ReaderConfiguration at most one
// rule for MaxItems and MaxItemsPath.
path?: string;
}): StateChecker => {
return enforceMaxCount({
maxCount: 0,
props,
errorCode,
path,
errorMessage: "MUST NOT contain any of",
});
};

export const ExactlyOne = ({
props,
errorCode,
Expand Down
Loading

0 comments on commit 61f3d88

Please sign in to comment.