Skip to content

Commit 9548de9

Browse files
tstephen-nhsCopilotdependabot[bot]
authored
New: [AEA-6256] - StateMachine construct (#604)
## Summary - ✨ New Feature ### Details Reusable state machine construct Note this is stacked on PR 547 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent 52ed973 commit 9548de9

File tree

9 files changed

+775
-13
lines changed

9 files changed

+775
-13
lines changed

package-lock.json

Lines changed: 29 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {IResource, PassthroughBehavior, StepFunctionsIntegration} from "aws-cdk-lib/aws-apigateway"
2+
import {IRole} from "aws-cdk-lib/aws-iam"
3+
import {HttpMethod} from "aws-cdk-lib/aws-lambda"
4+
import {Construct} from "constructs"
5+
import {stateMachineRequestTemplate} from "./templates/stateMachineRequest.js"
6+
import {stateMachine200ResponseTemplate, stateMachineErrorResponseTemplate} from "./templates/stateMachineResponses.js"
7+
import {ExpressStateMachine} from "../StateMachine.js"
8+
9+
/** Parameters used to create an API endpoint backed by a Step Functions Express workflow. */
10+
export interface StateMachineEndpointProps {
11+
/** Parent API resource under which the state machine endpoint is added. */
12+
parentResource: IResource
13+
/** Path segment used to create the child API resource. */
14+
readonly resourceName: string
15+
/** HTTP verb bound to the Step Functions integration. */
16+
readonly method: HttpMethod
17+
/** Invocation role used by API Gateway when starting workflow executions. */
18+
restApiGatewayRole: IRole
19+
/** State machine wrapper construct providing the target workflow ARN and integration target. */
20+
stateMachine: ExpressStateMachine
21+
}
22+
23+
/** Adds an API Gateway resource/method that starts an Express Step Functions execution. */
24+
export class StateMachineEndpoint extends Construct {
25+
/** API resource created by this construct. */
26+
resource: IResource
27+
28+
/** Wires request and response mapping templates for JSON and FHIR payload flows. */
29+
public constructor(scope: Construct, id: string, props: StateMachineEndpointProps) {
30+
super(scope, id)
31+
32+
const requestTemplate = stateMachineRequestTemplate(props.stateMachine.stateMachine.stateMachineArn)
33+
34+
const resource = props.parentResource.addResource(props.resourceName)
35+
resource.addMethod(props.method, StepFunctionsIntegration.startExecution(props.stateMachine.stateMachine, {
36+
credentialsRole: props.restApiGatewayRole,
37+
passthroughBehavior: PassthroughBehavior.WHEN_NO_MATCH,
38+
requestTemplates: {
39+
"application/json": requestTemplate,
40+
"application/fhir+json": requestTemplate
41+
},
42+
integrationResponses: [
43+
{
44+
statusCode: "200",
45+
responseTemplates: {
46+
"application/json": stateMachine200ResponseTemplate
47+
}
48+
},
49+
{
50+
statusCode: "400",
51+
selectionPattern: String.raw`^4\d{2}.*`,
52+
responseTemplates: {
53+
"application/json": stateMachineErrorResponseTemplate("400")
54+
}
55+
},
56+
{
57+
statusCode: "500",
58+
selectionPattern: String.raw`^5\d{2}.*`,
59+
responseTemplates: {
60+
"application/json": stateMachineErrorResponseTemplate("500")
61+
}
62+
}
63+
]
64+
}), {
65+
methodResponses: [
66+
{ statusCode: "200" },
67+
{ statusCode: "400" },
68+
{ statusCode: "500" }
69+
]
70+
})
71+
72+
this.resource = resource
73+
}
74+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* eslint-disable max-len */
2+
/**
3+
* @returns API Gateway request mapping template for StartExecution payloads.
4+
*/
5+
export const stateMachineRequestTemplate = (stateMachineArn: string) => {
6+
return `## Velocity Template used for API Gateway request mapping template
7+
## "@@" is used here as a placeholder for '"' to avoid using escape characters.
8+
9+
#set($includeHeaders = true)
10+
#set($includeQueryString = true)
11+
#set($includePath = true)
12+
#set($requestContext = '')
13+
14+
#set($inputString = '')
15+
#set($allParams = $input.params())
16+
#set($allParams.header.apigw-request-id = $context.requestId)
17+
{
18+
"stateMachineArn": "${stateMachineArn}",
19+
#set($inputString = "$inputString,@@body@@: $input.body")
20+
#if ($includeHeaders)
21+
#set($inputString = "$inputString, @@headers@@:{")
22+
#foreach($paramName in $allParams.header.keySet())
23+
#set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.header.get($paramName))@@")
24+
#if($foreach.hasNext)
25+
#set($inputString = "$inputString,")
26+
#end
27+
#end
28+
#set($inputString = "$inputString }")
29+
#end
30+
#if ($includeQueryString)
31+
#set($inputString = "$inputString, @@queryStringParameters@@:{")
32+
#foreach($paramName in $allParams.querystring.keySet())
33+
#set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.querystring.get($paramName))@@")
34+
#if($foreach.hasNext)
35+
#set($inputString = "$inputString,")
36+
#end
37+
#end
38+
#set($inputString = "$inputString }")
39+
#end
40+
#if ($includePath)
41+
#set($inputString = "$inputString, @@pathParameters@@:{")
42+
#foreach($paramName in $allParams.path.keySet())
43+
#set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.path.get($paramName))@@")
44+
#if($foreach.hasNext)
45+
#set($inputString = "$inputString,")
46+
#end
47+
#end
48+
#set($inputString = "$inputString }")
49+
#end
50+
## Check if the request context should be included as part of the execution input
51+
#if($requestContext && !$requestContext.empty)
52+
#set($inputString = "$inputString,")
53+
#set($inputString = "$inputString @@requestContext@@: $requestContext")
54+
#end
55+
#set($inputString = "$inputString}")
56+
#set($inputString = $inputString.replaceAll("@@",'"'))
57+
#set($len = $inputString.length() - 1)
58+
"input": "{$util.escapeJavaScript($inputString.substring(1,$len))}"
59+
}`
60+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/* eslint-disable max-len */
2+
/** VTL response template that unwraps successful workflow output and forwards status and headers. */
3+
export const stateMachine200ResponseTemplate = `#set($payload = $util.parseJson($input.path('$.output')))
4+
#set($context.responseOverride.status = $payload.Payload.statusCode)
5+
#set($allHeaders = $payload.Payload.headers)
6+
#foreach($headerName in $allHeaders.keySet())
7+
#set($context.responseOverride.header[$headerName] = $allHeaders.get($headerName))
8+
#end
9+
$payload.Payload.body`
10+
11+
interface ErrorMap {
12+
[key: string]: {
13+
code: string
14+
severity: string
15+
diagnostics: string
16+
codingCode: string
17+
codingDisplay: string
18+
}
19+
}
20+
21+
const getOperationOutcome = (status: string) => {
22+
const errorMap: ErrorMap = {
23+
400: {
24+
code: "value",
25+
severity: "error",
26+
diagnostics: "Invalid request.",
27+
codingCode: "BAD_REQUEST",
28+
codingDisplay: "400: The Server was unable to process the request"
29+
},
30+
500: {
31+
code: "exception",
32+
severity: "fatal",
33+
diagnostics: "Unknown Error.",
34+
codingCode: "SERVER_ERROR",
35+
codingDisplay: "500: The Server has encountered an error processing the request."
36+
}
37+
}
38+
39+
return JSON.stringify({
40+
ResourceType: "OperationOutcome",
41+
issue: [
42+
{
43+
code: errorMap[status].code,
44+
severity: errorMap[status].severity,
45+
diagnostics: errorMap[status].diagnostics,
46+
details: {
47+
coding: [
48+
{
49+
system: "https://fhir.nhs.uk/CodeSystem/http-error-codes",
50+
code: errorMap[status].codingCode,
51+
display: errorMap[status].codingDisplay
52+
}
53+
]
54+
}
55+
}
56+
]
57+
})
58+
}
59+
60+
/**
61+
* @returns VTL response template that maps workflow failures to FHIR OperationOutcome payloads.
62+
*/
63+
export const stateMachineErrorResponseTemplate = (status: string) => `#set($context.responseOverride.header["Content-Type"] ="application/fhir+json")
64+
${getOperationOutcome(status)}`

0 commit comments

Comments
 (0)