In order to proceed with the demo, you should have the following:
- AWS CLI installed
- AWS SAM installed
- Configure your AWS credentials
- Download the contents of this directory to use it when going over the demo.
- Make sure to enable the Anthropic Claude Instant V1 model from the Bedrock console
In this demo, we want to assess how we can use AWS Application Composer and other AWS tools in order to create an MVP on AWS. For the purpose of this demo, we want to be able to generate children stories in mp3 format by giving in the context of our story as well as the language in which we want our story to be narated. We want to be able to access our mvp on any browser.
This is the architecture of the solution we are going to build:
- Serve frontend with AWS Amplify Hosting
- Call Amazon API Gateway backend
- Trigger an AWS Lambda function from API Gateway
- Trigger a syncronous execution of AWS Step Functions workflow from the Lambda function
- Call Anthropic Claude Instant V1 model through Amazon Bedrock to generate a unique story
- Translate story if needed with Amazon Translate
- Convert story to speech with Amazon Polly
- Store generated story in an Amazon S3 bucket
- Return mp3 URL to frontend
Now we will start building the backend of our solution. In order to do so, we will be leveraging AWS Application Composer.
- Let's start by downloading the contents of this directory on your local computer. You should have two directories:
mvp-stories/backend
mvp-stories/frontend
- Head to the Application Composer console
- Click on create project
- Drag and drop an API Gateway resource into the canvas
- Click on the resource
- Click on Details
- Enter the logical ID to be MvpStoriesApi
- Scroll down to the routes section and change the GET method to POST
- Click on Add corsallowedorigins and set value to * > NOTE: All origins allowed for simplicity and the purpose of this demo, can be changed later to only allow the actual frontend making the requests
- Repeat for CORS allowed headers and CORS allowed methods
- Click save
- Add the Lambda resource that will be triggered from API Gateway.
- Click on Details
- Enter the logical ID to be MvpStoriesWorkflowTrigger
- Update Source path field to
src/handlers
. This is the location where your Lambda code will be stored locally. - Update Handler field to
workflowTrigger.handler
. This is the name of the js file containing the Lambda code and the relative handler method that is exposed - Click save
- Connect the created MvpStoriesApi to the MvpStoriesWorkflowTrigger
- Add a Step Function resource to the canvas
- Click on Details
- Set the logical ID the Step Function resource to MvpStoriesWorkflow
- Click save
- Link the MvpStoriesWorkflowTrigger resource to MvpStoriesWorkflow resource
- Drag an additional Lambda function resource, similarly to the firstly created Lambda function resource. Change the following details for your function:
- Logical ID to MvpStoriesTextToSpeech
- Source path to
src/handlers
- Handler to
textToSpeech.handler
- Add the following IAM policy in the Permissions section:
This will give the permission to your Lambda function to call the Amazon Polly service which is needed for the Text to Speech conversion of our story.
- Statement: - Effect: Allow Action: - polly:* Resource: - '*'
- Drag and drop an S3 Bucket resource. Change the logical ID to MvpStoriesBucket
- Link the MvpStoriesTextToSpeech resource with tge MvpStoriesBucket resource
- Drag an additional Lambda function resource, similarly to the firstly created Lambda function resource. Change the following details for your function:
- Logical ID to MvpStoriesBedrock
- Source path to
src/handlers
- Handler tobedrock.handler
- Add the following IAM policy in the Permissions section:This will give the permission to your Lambda function to invoke your LLM through Amazon Bedrock.- Statement: - Effect: Allow Action: bedrock:InvokeModel Resource: '*'
- Click on the template tab and copy the contents. This IaC (Infrastructure as Code) template describes your serverless architecture using AWS SAM (Serverless Application Model). Paste the copied code to a
template.yaml
in your backend foldermvp-stories/backend
.
Currently the Step Function workflow resource in your Application Composer canvas contains a basic state machine definition. We actually want to create a state definition that matches our use case presented in the architecture.
-
Let's open a new tab and head to the Step Function console.
-
Choose authoring method as Design your workflow visually
-
Choose Express type of workflow (as for the sake of this demo, we will be running everything syncronously)
-
Drag and drop an AWS Lamba Invoke state.
-
Change the state name to Generate Story
-
Under API parameters, choose the function name to be Enter function name and
${MvpStoriesBedrockArn}
as shown on the image below. This value will be dynamically filled in with the actual ARN of our Lambda function when our backend is deployed: -
Now we need to know whether our story needs translation or not. In order to do so, we need to make a choice. Drag and drop a Choice state after the Generate Story state.
-
Change the state name to Needs translation ?
-
Enter the Needs translation in the comment section
-
Set condition for rule one as follows:
- Not: NOT
- Variable: $$.Execution.Input.locale (value of locale provided as input to statemachine)
- Operator: is equal to
- Value: String constant
- en
-
Let's start with the case where the story does not need translation. Drag and drop an AWS Lamba Invoke step.
-
Change the state name to Convert Story to Speech
-
Under API parameters, choose the function name to be Enter function name and
${MvpStoriesTextToSpeechArn}
as shown on the image below. This value will be dynamically filled in with the actual ARN of our Lambda function when our backend is deployed: -
Under Payload select Enter Payload and fill in the following payload:
{ "story.$": "$", "locale.$": "$$.Execution.Input.locale" }
With this, we are creating a json to be passed to the MvpStoriesTextToSpeech Lambda function execution.
-
Now lets handle the case were we actually need to translate our story. Drag and drop a Translate TranslateText step for the Needs Translation branch
-
Change the state name to Translate story
-
Change the API parameters for the Amazon Translate call to the following json:
{ "SourceLanguageCode": "en", "TargetLanguageCode.$": "$$.Execution.Input.locale", "Text.$": "$" }
-
Similarly to the Generate Story step, we want to pass the translated story as input to the next state. Let's filter out the story from the result of the TranslateText call 1. Click on Output 2. Select the Filter output with OutputPath option and type:
$.TranslatedText
-
After our Translate story step, we want to perform the same steps as the Default branch of our Choice state.
-
Now that our workflow is setup, we can export it by clicking on Import/Export
-
Copy the downloaded file to your backend folder and name the file
statemachine.asl.json
There are a few things that we need to modify in our template.yaml
file to make our application behave as we want to (and that we unfortunately cannot do directly in Application Composer at the time of writing this document).
-
Head to the resource called MvpStoriesWorkflow. Remove the Defininition key from that resource and replace it with:
DefinitionUri: ./statemachine.asl.json
Here, we are informing SAM where to find our workflow definition that we created from the Step Function console.
-
Add another key to MvpStoriesWorkflow:
DefinitionSubstitutions: MvpStoriesTextToSpeechArn: !GetAtt MvpStoriesTextToSpeech.Arn MvpStoriesBedrockArn: !GetAtt MvpStoriesBedrock.Arn
This addition allows us to dynamically pass the name of the 2 Lambda functions we will be using in our state machine (MvpStoriesTextToSpeech and MvpStoriesBedrock)
-
We need to add a few permissions to MvpStoriesWorkflow, add the following permissions under the Policies key:
Policies: ...EXISTING_POLICIES... - Statement: - Effect: Allow Action: - translate:* Resource: - '*' - LambdaInvokePolicy: FunctionName: !Ref MvpStoriesTextToSpeech - LambdaInvokePolicy: FunctionName: !Ref MvpStoriesBedrock
This gives the permission to MvpStoriesWorkflow to call the Amazon Translate service and invoke the two lambda functions used in our workflow.
-
Change the Type key of MvpStoriesWorkflow from
STANDARD
toEXPRESS
. This will allow us to invoke our Step Function workflow synchronously. -
Head to the MvpStoriesWorkflowTrigger and change the Policies key from
Policies: - StepFunctionsExecutionPolicy: StateMachineName: !GetAtt MvpStoriesWorkflow.Name
to
Policies: - Statement: - Effect: Allow Action: states:StartSyncExecution Resource: !GetAtt MvpStoriesWorkflow.Arn
This will give the permission to the MvpStoriesWorkflowTrigger Lambda function to invoke the MvpStoriesWorkflow resource synchrounously.
-
Finally, go to the end of the
template.yml
document and paste the following code:Outputs: MvpStoriesApi: Description: "API Gateway endpoint URL for Prod stage" Value: !Sub "https://${MvpStoriesApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
This change will make sure that everytime you deploy your application with SAM, the API Gateway endpoint will be printed as output.
Now that we have our IaC settled, there is a last thing we need to take care of before being able to deploy. In Application Composer, we have defined 2 Lambda resources. We have defined their configuration but we did not define the actual code contained in those Lambda functions. Let's do this now.
- create a the
src/handlers
in yourbackend
directory. - In the
src/handlers
folder run the following commands:This will allow you to import and use the Bedrock runtime client from within your Lambda functionsnpm init npm install @aws-sdk/client-bedrock-runtime
- Create a
src/handlers/workflowTrigger.mjs
file and type in the following codeThis code will handle the request incoming from API Gateway, call the Step Function workflow syncronously and return the formatted results.import { SFNClient, StartSyncExecutionCommand } from "@aws-sdk/client-sfn" export const handler = async (event) => { const body = JSON.parse(event.body) const sfnInput = { stateMachineArn: process.env.MVPSTORIESWORKFLOW_STATE_MACHINE_ARN, input: JSON.stringify({ context: body.context, locale: body.locale || 'en' }) } const sfnClient = new SFNClient() const sfnCommand = new StartSyncExecutionCommand(sfnInput) const sfnResponse = await sfnClient.send(sfnCommand) return { statusCode: 200, headers: { "Access-Control-Allow-Headers" : "*", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "*" }, body: JSON.stringify(JSON.parse(sfnResponse.output)) } }
- Create a
src/handlers/bedrock.mjs
file and type in the following codeThis code will call the Anthropic Claude Instant V1 model from Amazon Bedrock with a given prompt. Feel free to test out any other text generation model from Bedrock. Make sure to enable the model from the Bedrock console before trying to use it in this demo.import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime" const client = new BedrockRuntimeClient() export const handler = async (event) => { const command = new InvokeModelCommand({ "modelId": "anthropic.claude-instant-v1", "contentType": "application/json", "accept": "*/*", "body": JSON.stringify({ "prompt": `\n\nHuman: Write an approx 100 words long children story about ${event.context}. Only provide the generated story as response, no leading text. \n\nAssistant:`, "max_tokens_to_sample": 300, "temperature": 1, "top_k": 250, "top_p": 0.999, "stop_sequences": [ "\n\nHuman:" ], "anthropic_version": "bedrock-2023-05-31" }) }) const response = await client.send(command) return JSON.parse(Buffer.from(response.body).toString('utf8')).completion }
- Create a
src/handlers/textToSpeech.mjs
file and type in the following codeThis code will syntetize speech of the translated story or original story, it will upload the results to an S3 bucket and will generate a presigned URL on which users will be able to access the audio version of the children story and return that along with the input event.import { PollyClient, SynthesizeSpeechCommand } from "@aws-sdk/client-polly" import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3" import { Upload } from "@aws-sdk/lib-storage" import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { randomUUID } from 'crypto' export const handler = async (event) => { const pollyInput = { Engine: "neural", OutputFormat: 'mp3', Text: event.story, VoiceId: getVoiceId(event.locale), } const pollyClient = new PollyClient() const pollyCommand = new SynthesizeSpeechCommand(pollyInput) const pollyResponse = await pollyClient.send(pollyCommand) const mp3FileName = `${randomUUID()}.mp3` const signedUrlExpiresIn = 60 * 5 const s3UploadInput = { Body: pollyResponse.AudioStream, Bucket: process.env.MVPSTORIESBUCKET_BUCKET_NAME, Key: mp3FileName, ContentType: 'audio/mpeg' } const s3Client = new S3Client() const s3Upload = new Upload({ client: s3Client, params: s3UploadInput, }) await s3Upload.done() const s3GetObjectInput = { Bucket: process.env.MVPSTORIESBUCKET_BUCKET_NAME, Key: mp3FileName } const s3GetObjectCommand = new GetObjectCommand(s3GetObjectInput) const mp3Url = await getSignedUrl(s3Client, s3GetObjectCommand, { expiresIn: signedUrlExpiresIn }) return { ...event, mp3Url } } const getVoiceId = locale => { if (locale == 'fr') return "Lea" if (locale == 'nl') return "Laura" if (locale == 'ar') return "Hala" if (locale == 'it') return "Bianca" return "Joanna" }
Now that we have everything in place we can deploy our backend. In order to do so we will need to run 2 commands:
- From the root of your
backend
directory, runsam build
- Run
sam deploy --guided
This command will ask for some information regarding your deployment, you can fill the values in as follows:Note: It will warn you that MvpStoriesWorkflowTrigger has no Authorization defined. Explicetely allow it for the sake of this demo.
At the end of your deployment results, you should be able to find the outputs that should look like this: Copy the API endpoint and test your api with the following command
curl --location 'API_ENDPOINT' \
--header 'Content-Type: application/json' \
--data '{
"context": "a baby sphynx lost in an Egyptian pyramid",
"locale": "fr"
}'
The result should contained a presigned URL to access the mp3 version of your story.
Now that you have a fully functional API, let's include that in our frontend. For the sake of this example, we have created a very basic vue3 application with a basic UI that we will be deploying with AWS Amplify Hosting.
- Let's head to our Vue application and more specifically to the part of our application submitting our request to our API.
- Let's update the API endpoint to the one generated by SAM when deploying our backend.
- Now that our frontend is ready, let's build the static files in order to be able upload them on AWS Amplify Hosting. With vue3, this can be done running the
npm install
andnpm run build
commands. This will create the necessary html, css and js files in adist
directory. - Let's now head to the Amplify console.
- Select option Deploy without Git provider
- Click on Continue
- Enter the name of your Amplify application, eg: Mvp Stories
- Drag and drop the
dist
folder containing your html, css and js files to be used for hosting. - Click Save and deploy
- Once deployed, you will see a page telling you that the deployment is successful and will be able to access your app on the link provided.
You should now be able to test out your app, which makes a request to your backend and displays an mp3 story you can play from your webapp.
For the sake of the demo, we have kept many things simple and have taken some shortcuts. This is intended as the main value out of this demo is for you to understand how you can leverage Application Composer and other serverless technologies to quickly build your MVP.
It is not intended to be a guide for you to build a production ready product as many things could/should be improved before doing so.
A few ideas on how to improve on the current solution:
- Do not deploy your solution manually but integrate your git provider with a CI/CD pipeline responsible for deploying your solution:
- The
sam pipeline
command can help you setup a deployment pipeline. More information about this here - Amplify hosting can be directly connected to your git provider, meaning that on every push, it would re-build your frontend and deploy it for you.
- The
- Have different deployments for different environments
- Let your application work
async
by submitting the request, integrating API Gateway with Step Functions directly (without the need to go through a Lambda invocation) and working with websockets to notify the user when the story has been generated. - ...