├── .github
├── dependabot.yml
└── workflows
│ └── scheduled_check_infra.yml
├── .gitignore
├── .nvmrc
├── CONTRIBUTING.md
├── LICENSE
├── LICENSE-SAMPLECODE
├── LICENSE-SUMMARY
├── README.md
├── biome.json
├── docs
├── assets
│ └── main-diagram.drawio
└── static
│ ├── .gitkeep
│ ├── aws-logo.png
│ ├── main-diagram.drawio.svg
│ └── powertools-workshop-architecture-numbered.png
├── frontend
├── .gitignore
├── .graphqlconfig.yml
├── index.html
├── package.json
├── public
│ ├── aws_logo.svg
│ └── favicon.ico
├── schema.json
├── src
│ ├── components
│ │ ├── App
│ │ │ ├── App.tsx
│ │ │ ├── Body.tsx
│ │ │ ├── ErrorPage.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── MenuItem.tsx
│ │ │ └── index.ts
│ │ ├── Settings
│ │ │ ├── Settings.tsx
│ │ │ └── index.ts
│ │ └── Upload
│ │ │ ├── DropZone.tsx
│ │ │ ├── FileUpload.tsx
│ │ │ ├── Upload.helpers.ts
│ │ │ ├── Upload.tsx
│ │ │ ├── UploadingTable.tsx
│ │ │ └── index.ts
│ ├── graphql
│ │ ├── mutations.ts
│ │ ├── queries.ts
│ │ └── subscriptions.ts
│ ├── helpers
│ │ ├── API.ts
│ │ ├── API.types.ts
│ │ └── cache.ts
│ ├── index.css
│ ├── main.tsx
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── functions
├── dotnet
│ ├── .gitkeep
│ ├── PowertoolsWorkshop.csproj
│ ├── module1
│ │ ├── Services
│ │ │ ├── AppSyncService.cs
│ │ │ ├── Constants.cs
│ │ │ ├── ImageManipulationService.cs
│ │ │ └── ThumbnailGeneratorService.cs
│ │ ├── ThumbnailGeneratorFunction.cs
│ │ └── [complete]ThumbnailGeneratorFunction.cs
│ ├── module2
│ │ ├── ImageDetectionFunction.cs
│ │ ├── Services
│ │ │ ├── ApiService.cs
│ │ │ ├── ApiUrlParameter.cs
│ │ │ ├── ImageDetectionService.cs
│ │ │ └── [complete]ImageDetectionService.cs
│ │ └── [complete]ImageDetectionFunction.cs
│ └── module3
│ │ └── ApiEndpointHandlerFunction.cs
├── java
│ ├── .gitkeep
│ └── modules
│ │ ├── module1
│ │ ├── pom.xml
│ │ └── src
│ │ │ └── main
│ │ │ ├── java
│ │ │ └── com
│ │ │ │ └── amazonaws
│ │ │ │ └── powertools
│ │ │ │ └── workshop
│ │ │ │ ├── Module1Handler.java
│ │ │ │ ├── Module1HandlerComplete.java
│ │ │ │ ├── S3EBEvent.java
│ │ │ │ ├── S3Object.java
│ │ │ │ ├── UpdateFileStatusException.java
│ │ │ │ └── Utils.java
│ │ │ └── resources
│ │ │ └── log4j2.xml
│ │ ├── module2
│ │ ├── pom.xml
│ │ └── src
│ │ │ └── main
│ │ │ ├── java
│ │ │ └── com
│ │ │ │ └── amazonaws
│ │ │ │ └── powertools
│ │ │ │ └── workshop
│ │ │ │ ├── APIHost.java
│ │ │ │ ├── ImageDetectionException.java
│ │ │ │ ├── ImageMetadata.java
│ │ │ │ ├── Module2Handler.java
│ │ │ │ ├── Module2HandlerComplete.java
│ │ │ │ └── Utils.java
│ │ │ └── resources
│ │ │ └── log4j2.xml
│ │ └── module3
│ │ ├── pom.xml
│ │ └── src
│ │ └── main
│ │ ├── java
│ │ └── com
│ │ │ └── amazonaws
│ │ │ └── powertools
│ │ │ └── workshop
│ │ │ └── Module3Handler.java
│ │ └── resources
│ │ └── log4j2.xml
├── python
│ ├── .gitkeep
│ └── modules
│ │ ├── module1
│ │ ├── app.py
│ │ ├── app_complete.py
│ │ ├── constants.py
│ │ ├── graphql.py
│ │ ├── requirements.txt
│ │ └── utils.py
│ │ ├── module2
│ │ ├── app.py
│ │ ├── app_complete.py
│ │ ├── exceptions.py
│ │ ├── requirements.txt
│ │ └── utils.py
│ │ └── module3
│ │ ├── app.py
│ │ └── requirements.txt
└── typescript
│ ├── api
│ ├── clean-deleted-files
│ │ └── index.ts
│ ├── get-presigned-download-url
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── get-presigned-upload-url
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ └── mark-file-queued
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── commons
│ ├── appsync-signed-operation.ts
│ ├── clients
│ │ ├── cognito.ts
│ │ ├── dynamodb.ts
│ │ ├── eventbridge.ts
│ │ ├── rekognition.ts
│ │ └── s3.ts
│ ├── middlewares
│ │ └── requestResponseMetric.ts
│ └── powertools.ts
│ ├── constants.ts
│ ├── modules
│ ├── module1
│ │ ├── index.complete.ts
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── module2
│ │ ├── errors.ts
│ │ ├── index.complete.ts
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ └── module3
│ │ └── index.ts
│ ├── package.json
│ ├── tsconfig.json
│ ├── types
│ └── API.ts
│ └── workshop-services
│ └── users-generator
│ └── index.ts
├── infra
├── .gitignore
├── .npmignore
├── README.md
├── cdk.json
├── infra.ts
├── lib
│ ├── attendant-ide
│ │ ├── completion-construct.ts
│ │ ├── compute-construct.ts
│ │ ├── constants.ts
│ │ ├── distribution-construct.ts
│ │ ├── index.ts
│ │ ├── network-construct.ts
│ │ ├── random-password-construct.ts
│ │ └── zshrc-sample.txt
│ ├── constants.ts
│ ├── content-hub-repository
│ │ ├── README.md
│ │ ├── api-construct.ts
│ │ ├── assets
│ │ │ ├── download_architecture.svg
│ │ │ ├── download_architecture.tldr
│ │ │ ├── upload_architecture.svg
│ │ │ └── upload_architecture.tldr
│ │ ├── functions-construct.ts
│ │ ├── index.ts
│ │ ├── schema.graphql
│ │ └── storage-construct.ts
│ ├── frontend
│ │ ├── README.md
│ │ ├── assets
│ │ │ ├── frontend_hosting_architecture.svg
│ │ │ └── frontend_hosting_architecture.tldr
│ │ ├── auth-construct.ts
│ │ ├── distribution-construct.ts
│ │ ├── functions-construct.ts
│ │ ├── index.ts
│ │ └── storage-construct.ts
│ ├── ide-stack.ts
│ ├── image-detection
│ │ ├── README.md
│ │ ├── assets
│ │ │ ├── image_detection_architecture.svg
│ │ │ └── image_detection_architecture.tldr
│ │ ├── functions-construct.ts
│ │ ├── index.ts
│ │ └── queues-construct.ts
│ ├── infra-stack.ts
│ ├── monitoring
│ │ ├── dashboard-construct.ts
│ │ └── index.ts
│ ├── reporting-service
│ │ ├── README.md
│ │ ├── api-construct.ts
│ │ ├── assets
│ │ │ ├── reporting_service_architecture.svg
│ │ │ └── reporting_service_architecture.tldr
│ │ ├── functions-construct.ts
│ │ └── index.ts
│ └── thumbnail-generator
│ │ ├── README.md
│ │ ├── assets
│ │ ├── thumbnail_generator_architecture.svg
│ │ └── thumbnail_generator_architecture.tldr
│ │ ├── functions-construct.ts
│ │ ├── index.ts
│ │ ├── queues-construct.ts
│ │ └── storage-construct.ts
├── package.json
└── tsconfig.json
├── package-lock.json
├── package.json
└── scripts
├── convert-template.mjs
├── create-aws-exports.mjs
├── package.json
└── shared.mjs
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "github-actions"
9 | directory: "/"
10 | schedule:
11 | interval: "monthly"
12 | commit-message:
13 | prefix: chore
14 | include: scope
15 |
16 | - package-ecosystem: npm
17 | directory: /
18 | labels: [ ]
19 | schedule:
20 | interval: monthly
21 | versioning-strategy: increase
22 | groups:
23 | aws-sdk:
24 | patterns:
25 | - "@aws-sdk/**"
26 | - "@smithy/**"
27 | - "aws-sdk-client-mock"
28 | - "aws-sdk-client-mock-jest"
29 | aws-cdk:
30 | patterns:
31 | - "@aws-cdk/**"
32 | - "aws-cdk-lib"
33 | - "aws-cdk"
34 |
--------------------------------------------------------------------------------
/.github/workflows/scheduled_check_infra.yml:
--------------------------------------------------------------------------------
1 | name: Scheduled check infrastructure
2 |
3 | # PROCESS
4 | #
5 | # This workflow is run on a scheduled basis to check that the infrastructure stack and the IDE stack are compliant with the cdk_nag rules.
6 | #
7 | # 1. Setup codebase and dependencies
8 | # 2. Run the CDK synth command to generate the CloudFormation template for the infrastructure stack
9 | # 3. Run the CDK synth command to generate the CloudFormation template for the IDE stack
10 |
11 | # USAGE
12 | #
13 | # NOTE: meant to use as a scheduled task only (or manually for debugging purposes).
14 |
15 | on:
16 | workflow_dispatch:
17 |
18 | schedule:
19 | - cron: '0 9 * * 1'
20 |
21 | permissions:
22 | contents: read
23 |
24 | jobs:
25 | run_cdk_nag:
26 | runs-on: ubuntu-latest
27 | steps:
28 | - name: Checkout code
29 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
30 | - name: Setup NodeJS
31 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
32 | with:
33 | node-version: 20
34 | cache: "npm"
35 | - name: Setup dependencies
36 | run: npm ci
37 | - name: Synth infrastructure
38 | run: npm run infra:synth -- --c CI=true
39 | - name: Synth IDE stack
40 | run: npm run ide:synth -- --c CI=true
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/iron
2 |
--------------------------------------------------------------------------------
/LICENSE-SAMPLECODE:
--------------------------------------------------------------------------------
1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this
4 | software and associated documentation files (the "Software"), to deal in the Software
5 | without restriction, including without limitation the rights to use, copy, modify,
6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
7 | permit persons to whom the Software is furnished to do so.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15 |
--------------------------------------------------------------------------------
/LICENSE-SUMMARY:
--------------------------------------------------------------------------------
1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 |
3 | The documentation is made available under the Creative Commons Attribution-ShareAlike 4.0 International License. See the LICENSE file.
4 |
5 | The sample code within this documentation is made available under the MIT-0 license. See the LICENSE-SAMPLECODE file.
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AWS Lambda Powertools for TypeScript Workshop
2 |
3 | 
4 |
5 | The architecture of this workload allows users to upload media assets and have them converted to different web-friendly media formats. The flow of the application is as follows:
6 |
7 | 1. End users access an authenticated web application that they can use to upload media assets.
8 | 2. The application is hosted on Amazon S3 and distributed via Amazon CloudFront.
9 | 3. When an user uploads a media asset, the application obtains a pre-signed upload URL from a GraphQL API managed by Amazon AppSync.
10 | 4. The AppSync API forwards the request to an AWS Lambda function that generates the pre-signed URL and stores the file metadata on Amazon DynamoDB.
11 | 5. Using the pre-signed url obtained from the API, the user uploads the asset directly to S3.
12 | 6. This action sends a notification to Amazon EventBridge.
13 | 7. The events are then filtered and routed to one or more SQS queues, from which they are picked up by Lambda functions.
14 | 8. Each type of media file is processed by a dedicated component that takes the original file, converts it, and saves the rendition back to S3.
15 | 9. The processing units update the status of each file in the DynamoDB table.
16 |
17 | Workshop llink: https://catalog.workshops.aws/powertools-for-aws-lambda/en-US
18 |
19 | ## Deploy
20 |
21 | See [CONTRIBUTING](CONTRIBUTING.md#setup) for more information.
22 |
23 | ## Security
24 |
25 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
26 |
27 | ## License Summary
28 |
29 | The documentation is made available under the Creative Commons Attribution-ShareAlike 4.0 International License. See the LICENSE file.
30 |
31 | The sample code within this documentation is made available under the MIT-0 license. See the LICENSE-SAMPLECODE file.
32 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.1/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "linter": {
7 | "enabled": true,
8 | "rules": {
9 | "recommended": true
10 | }
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "space",
15 | "indentWidth": 2,
16 | "lineEnding": "lf",
17 | "lineWidth": 80
18 | },
19 | "javascript": {
20 | "formatter": {
21 | "semicolons": "always",
22 | "bracketSpacing": true,
23 | "quoteStyle": "single",
24 | "trailingCommas": "es5"
25 | }
26 | },
27 | "files": {
28 | "ignore": [
29 | "node_modules",
30 | "coverage",
31 | "lib",
32 | "cdk.out",
33 | "site",
34 | ".aws-sam",
35 | "functions/dotnet",
36 | "functions/java",
37 | "functions/python",
38 | "functions/solutions",
39 | "scripts",
40 | "frontend"
41 | ]
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/docs/assets/main-diagram.drawio:
--------------------------------------------------------------------------------
1 | 7V1bd5s4F/01eYwXAsTlMbbjTmel33SaZLq+eelSQLbVAnK5JE5+/UgCbIPkxGm4xDXpamKEAPnsra1zdOPMmITrDzFaLT9RHwdnuuavz4zpma4DqLnsD095LFI0E+Ypi5j4Rdo24Zo84TJjkZoRHyeVjCmlQUpW1USPRhH20koaimP6UM02p0H1qSu0wFLCtYcCOfUr8dNlkQosd3viD0wWy+LRjm7nJ0JUZi6+SbJEPn3YSTIuz4xJTGmafwrXExxw65V2ya+b7Tm7KViMo/SQC87//p7G32+fvkwvP335F36aOfSP8+Jb3KMgq37hJH0sTRDTLPIxvws4M8YPS5Li6xXy+NkHhjpLW6ZhUJyekyCY0IDG4lpD0+zJeMrSkzSmP3B5JqIR5plplO5knomfIv26KAAoj3NqcLuN5a9elPoexyle7yQVpviAaYjT+JFlKc+62qhgYkFNA4AR1LY/xS0ftqibBZLLHbwNp+BawbPF5klbKNiHAo1XIFPyaAca/TSgMSz7nUPjSNAYpwENtBwZmJ7BkCXMPA0wjNLOBRishoy091NLDE0CBp4GMAyICjCWZvYLhSm3JTckZSZgzhRep2e6FbAijO9i9mmRboywg5PIVgEjxgl5Qncig8aOV5REqSg4HJ9BDgvKUprkluUXoIAsIvY5wHN+K25OwvysiyI5pRzthIFPosUNP5iem1V8DE3CrwlJqzU2lqmQNGAo8CoxbB4vuYG5zu7SPZDt4GT9zLg7KYx0ntv+gmUA1mq9Pfm+QQZWG6ACoERVU6CqN4Dq9eSfTzfjr6ur+/Tpz4/LJwCDT+dAAlVCICBCu3JBK+MNvaaCO7YyGxIsvW4uw1Way1LYC7RlL+Nle2GfBWvFIY3TJV3QCAWX29Rdw+HIv+ARIc+7whFPCe7EYUlTYXoUp2W2oilhF84IL7uoA9XWhjUt0IRjiyOxbcw0BU4yqHuRY2VY4CLp+7//oNuHvxbw9v/Glfu/i+je/3letCf86z+Lr4rrMQ5QSu6r4Z4KvOJ2n3md3wmcdLNGFlPbOIHlbRKaxR4urtyNSWs3M/SaC1MnU24J6UaCT5vveRDF1HY8oE6+zLEq7ts8V1QoHOfed5ymjwUbuGS+yMwDKSeT6mB52CWZshUC4ECWvZFReumevJpPjdGgEak5Thrkpn3GOPBATbKbZsuhjcdzpd5B9OLzR5bwAaX4AT1K8NIs5Y3vZNONWEr4jrnZvxkvwngRI5/ganwBTdt0d85NScxuRGgk2pGYA1MPYCY2MMBMBexc/LAzPkqWG0qVzdQVusPBZ+Z2Fbe/o2lKw73uVumTeaxUON7vTGxoU1iFPxIlq9wcc7Lm5RgnS7TiJ8P1gvf4jtBDYo6YmyhY9NHj5eFeY/6pmgutyLdFYf5G3BYT6BXhMCxd4bXYjuyz2A1EWmolMQcl6U4h1O2JXYbb+9uTlt0KOdy+QuGdj+RAe5ZFuUg0qkdlF0hVbqYMY83e11/y7pRGxJo4vrzHecgJ9qlPIGz7bV6aspmQyHTfm7bIHZ3TxwiFdCpH8m9tzcypzU6+rjVjP+bYPZnWzBe29++aoVtdsxjdem7IZAfqJBuyr6svt354u7hZwznwP65Dw56el1Fq6y2ZqY+M6ogLhOavBtuKu5mW0WnDaFqny6q2yeJqxktuz6FMcaEzcoBtmwaAtq3ZfftTivGLZ2kj+vWOhjGKXsjeSAQ0wxwBR4z0mowIdUoxBdn50a1f4xfQHEeilANtYOr5b7dTekF5uOU3otdBzZzSLnpHARvQnWo3sG0bHSuMYhZB4TwzWHVtiu+JhxOJFQ2OmG382UPHzBrwOgGwav3vUDlMprc0TKaEQjFv4BSg0KwKEq6umlnTMRK6hIQl2f23nMFhwWq9MB27pwloamDk8Qr7NIBhFaDaL6Obdocz0NRoyH2+zomgodXbD8vqGQu528I9USwsoJrD0S0acrhfdp2cHhxW/3DIYXQ5xn9ycNi6qj+/LTh+gD/h7TyFTzcf0V+PwfX64cY4ZD5YNbB8HRaXF5fTy6nCnPtgUGIW8N75MfJ+LERZamebQMaszQEqMdiBBRqujAswYAOOrxIZ2e89hmrSAVSGU5v7BXuvRbIr/Fw1OiWsqrOqgK0K7TuFSvaTj2HZTPdQ6W7vUMlu9DEsqukeKlNz+oZK9rGPYZlNP40V7Bss2QM/hh61LsBypWU4Wt9gycNCx9DL1glYFajs7XKbvqCSh2+OoQuuj3rl9O8HAnmE5xg66TrxLmpouf27gkDVT5HPY01WKCpnsl7RGIf8Xqsk4399YR4GKeEjvYiV5UxnBeAbdSR8LC/N+FnkkxVJ+HAaO8ABKTIlYqjPpzyRZElI+WGKw5W4JYk84hM/E2PIGf/FoGGP57nT8tH8KESLCPGnBORnhkbs0y0/jyMSimfzLCQ/uGeJKMyf/jMjCfsTUUafjGfCaxx7JEX5TF0tCwIUerR8bp6ZJKQsjXgcWYkL+S8kvnbIvgUtDcCKk/LyTPNHoSzl5SVxJspd2o7wp8V4FeMlZpUhFqYUifc0yFasQFgUXdhNw0nCDz1WF7YICKNk7Nc8WxDEs0W8+DlVWUIW82Jcrj28SnGWAybsSj0PYU9c4WUr4qM0v1pYYBVT4nOi52jlSIiCeVmwQrkN+U3mc+IRfuzjBMd5vpAGeYFRbnoiTJxscMzCUUkwRthdjv3SQuUGlqSaL9Z+lT41oghubYBFl8XAVHWOgdbkQO4cG+RgkINBDjqRA6fqeQNb4RxAhXcAAGxLD1RrJwc9GPRg0IPu9WAzYfdlPbDb0gPVCshBDwY9GPSgez0wy9lyL+pBEwvx1XqgWkg26MGgB4Me9NF9oJifqe4/MNvSA9USwEEPBj0Y9KB7PbBMRX+iOl5orT9RtbZz0INBDwY96F4PbGgf6h8YbemBajHuoAeDHgx60L0eOPah8UI5P7R5PZCn9gx6MOjBoAd96IHrKsYb1f5BW+ONui7pwVGsKexjwjPY7BfQ1+QxfRgdHtR7UO++1BtIcqCaTdqxgJuygA/LK89US/ZUa2G7le9h8G6Q70G+exvMr/tzunpRVGsCrtz9VHbAL0L0JLgxoYuIpFRCJvmBU4+XVt6Ha8z0bJL/h1POKZYy4rojJarSbDkRyNnYH6B6Qj1RlWbLiUDOxo/KUlcTVWk2lEtcvxoorga1q8UmZsObC1rf69kraF3Wa6kSK6r63notvZsK2nIr39Zez8oKLcdoX/Edr80BwcV2i3srcwv71Ivse923d8erg/ep93JztkMiE8gzP5Uksloi0ansF17bDPXFVyvtb0JlcN+6AzQcgeoukhBo9Y1UD94Duv6aLwhr+rNnS1Zmf/5+nU22ormv8+t1m7YqrSjPJCodkbqrOAlo5s9i+pKaDa7Ju3JNXBNOZ/rrXBN4YWhjeDquCWf2XDC7kYZFt6s9/l2+iEJZyeXpQZto41rePmioze+3NlvahWHYr6vNum0D1qadSm1OjGZqcf3NaBZU7ZLbaT2Wh/GZW6bYx3uILn4tuvCpl4V5pkbaAfeQMNUyO2SQYtuQ4bVq3b9W7U20qr/navPC375kSbGdwb4gQphsHBMeAA5+x/H4HbOZOXPGr/M7xhNgwNPxOzCn9l1O7Wb6p2qvT3wHDohyo4LT7KFqvOfJgNWZBm9495gJtJGmaY5jA9N17foL69p895iaNvJ0hYE2DdGmPkHF/nXaQPgcbRQ3bps2B/SHtx3qjJk9zN8w1ImzAH9rKFKuN1QQHNZMWa01Uwe8InMgTv/EcbRD3u/RLXWe6yv9+3oIWoag5d3V3bd0lv5sqK/LtWv77/c9IUMferqOvqdru/aifVaxw5jSdNeD5d/uE/Uxz/Ef
--------------------------------------------------------------------------------
/docs/static/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/powertools-for-aws-lambda-workshop/5beb34e7d7b18f90bfb77b2b8bfa3755b6365925/docs/static/.gitkeep
--------------------------------------------------------------------------------
/docs/static/aws-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/powertools-for-aws-lambda-workshop/5beb34e7d7b18f90bfb77b2b8bfa3755b6365925/docs/static/aws-logo.png
--------------------------------------------------------------------------------
/docs/static/powertools-workshop-architecture-numbered.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/powertools-for-aws-lambda-workshop/5beb34e7d7b18f90bfb77b2b8bfa3755b6365925/docs/static/powertools-workshop-architecture-numbered.png
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Build
27 | build
28 |
29 | # AWS Config
30 | src/aws-exports.cjs
--------------------------------------------------------------------------------
/frontend/.graphqlconfig.yml:
--------------------------------------------------------------------------------
1 | projects:
2 | Codegen Project:
3 | schemaPath: schema.json
4 | includes:
5 | - src/graphql/**/*.ts
6 | excludes:
7 | - ./amplify/**
8 | - src/helpers/API.types.ts
9 | extensions:
10 | amplify:
11 | codeGenTarget: typescript
12 | generatedFileName: src/helpers/API.types.ts
13 | docsFilePath: src/graphql
14 | region: eu-west-1
15 | apiId: null
16 | frontend: javascript
17 | framework: react
18 | maxDepth: 2
19 | extensions:
20 | amplify:
21 | version: 3
22 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | File Uploader
8 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "2.1.0",
5 | "type": "module",
6 | "author": {
7 | "name": "Amazon Web Services",
8 | "url": "https://aws.amazon.com"
9 | },
10 | "scripts": {
11 | "start": "vite",
12 | "build": "tsc && vite build",
13 | "preview": "vite preview",
14 | "deploy:clean": "aws s3 rm s3://$(cat ../infra/cdk.out/params.json | jq -r '.powertoolsworkshopinfra | with_entries(select(.key|match(\".*WebsiteBucketName[a-zA-Z0-9_]+\"))) | to_entries | map([.value])[0][0]') --recursive",
15 | "deploy:sync": "aws s3 sync build/ s3://$(cat ../infra/cdk.out/params.json | jq -r '.powertoolsworkshopinfra | with_entries(select(.key|match(\".*WebsiteBucketName[a-zA-Z0-9_]+\"))) | to_entries | map([.value])[0][0]') --exclude uploads",
16 | "deploy": "npm run deploy:clean && npm run deploy:sync",
17 | "deploy:headless": "aws s3 rm s3://$(cat build/bucket) --recursive && aws s3 sync build/ s3://$(cat build/bucket) --exclude uploads",
18 | "deploy:invalidateCache": "aws cloudfront create-invalidation --paths \"/index.html\" --distribution-id $(cat ../infra/cdk.out/params.json | jq -r '.powertoolsworkshopinfra | with_entries(select(.key|match(\".*DistributionId[a-zA-Z0-9_]+\"))) | to_entries | map([.value])[0][0]')"
19 | },
20 | "dependencies": {
21 | "@aws-amplify/ui-react": "^6.0.6",
22 | "@aws-crypto/sha256-js": "^5.2.0",
23 | "aws-amplify": "^6.14.4",
24 | "axios": "^1.9.0",
25 | "normalize.css": "^8.0.1",
26 | "react": "^18.3.1",
27 | "react-dom": "^18.3.1",
28 | "react-dropzone": "^14.3.8",
29 | "react-router-dom": "^6.26.1"
30 | },
31 | "devDependencies": {
32 | "@types/react": "^18.3.18",
33 | "@types/react-dom": "^18.2.7",
34 | "@vitejs/plugin-react": "^4.4.1",
35 | "typescript": "^5.6.3",
36 | "vite": "^6.3.4"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/public/aws_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
39 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/powertools-for-aws-lambda-workshop/5beb34e7d7b18f90bfb77b2b8bfa3755b6365925/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/src/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { Outlet } from 'react-router-dom';
3 |
4 | import Body from './Body';
5 | import Header from './Header';
6 |
7 | type AppProps = Record;
8 |
9 | const App: React.FC = () => {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 | >
17 | );
18 | };
19 |
20 | export default App;
21 |
--------------------------------------------------------------------------------
/frontend/src/components/App/Body.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, useTheme } from '@aws-amplify/ui-react';
2 | import type React from 'react';
3 |
4 | type BodyProps = {
5 | children?: React.ReactNode;
6 | };
7 |
8 | const Body: React.FC = ({ children }) => {
9 | const { tokens } = useTheme();
10 |
11 | return (
12 |
23 | {children}
24 |
25 | );
26 | };
27 |
28 | export default Body;
29 |
--------------------------------------------------------------------------------
/frontend/src/components/App/ErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import { Heading, Text } from '@aws-amplify/ui-react';
2 | import { useRouteError } from 'react-router-dom';
3 |
4 | import Body from './Body';
5 | import Header from './Header';
6 |
7 | type RouterError = {
8 | statusText?: string;
9 | message: string;
10 | };
11 |
12 | export const ErrorPage: React.FC = () => {
13 | const error = useRouteError() as RouterError;
14 | console.error(error);
15 |
16 | return (
17 | <>
18 |
19 |
20 | Oops!
21 | Sorry, an unexpected error has occurred.
22 |
23 | {error.statusText || error.message}
24 |
25 |
26 | >
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/frontend/src/components/App/Header.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Flex,
4 | Image,
5 | Link as UiLink,
6 | useAuthenticator,
7 | useTheme,
8 | } from '@aws-amplify/ui-react';
9 | import type React from 'react';
10 | import { Link as ReactRouterLink } from 'react-router-dom';
11 |
12 | import MenuItem from './MenuItem';
13 |
14 | const ITEMS = [
15 | {
16 | to: '/',
17 | label: 'Upload',
18 | },
19 | {
20 | to: '/settings',
21 | label: 'Settings',
22 | },
23 | ];
24 |
25 | const Header: React.FC = () => {
26 | const { signOut } = useAuthenticator();
27 | const { tokens } = useTheme();
28 |
29 | return (
30 |
39 |
45 |
46 |
47 |
48 |
49 |
55 | {ITEMS.map((item, idx) => (
56 |
62 | ))}
63 |
64 |
70 |
82 |
83 |
84 | );
85 | };
86 |
87 | export default Header;
88 |
--------------------------------------------------------------------------------
/frontend/src/components/App/MenuItem.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Link as UiLink, useTheme } from '@aws-amplify/ui-react';
2 | import type React from 'react';
3 | import { Link, useHref, useLocation, useResolvedPath } from 'react-router-dom';
4 |
5 | export type MenuItemProps = {
6 | children?: React.ReactNode;
7 | total: number;
8 | to: string;
9 | label: string;
10 | };
11 |
12 | const MenuItem: React.FC = ({ total, to, label }) => {
13 | const href = useHref(to);
14 | const path = useResolvedPath(to);
15 | const location = useLocation();
16 | const theme = useTheme();
17 |
18 | const toPathname = path.pathname;
19 | const locationPathname = location.pathname;
20 |
21 | const isActive =
22 | locationPathname === toPathname ||
23 | (locationPathname.startsWith(toPathname) &&
24 | locationPathname.charAt(toPathname.length) === '/');
25 |
26 | return (
27 |
32 |
42 | {label}
43 |
44 |
45 | );
46 | };
47 |
48 | export default MenuItem;
49 |
--------------------------------------------------------------------------------
/frontend/src/components/App/index.ts:
--------------------------------------------------------------------------------
1 | export * from './App';
2 | export { default } from './App';
3 |
--------------------------------------------------------------------------------
/frontend/src/components/Settings/Settings.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Flex,
4 | Heading,
5 | SelectField,
6 | Text,
7 | useTheme,
8 | } from '@aws-amplify/ui-react';
9 | import type React from 'react';
10 | import { useEffect, useState } from 'react';
11 |
12 | import cache from '../../helpers/cache';
13 |
14 | type SettingsProps = {
15 | children?: React.ReactNode;
16 | };
17 |
18 | const Settings: React.FC = () => {
19 | const { tokens } = useTheme();
20 | const [isLoading, setLoading] = useState(false);
21 | const [settings, setSettings] = useState<{
22 | videos?: string;
23 | images?: string;
24 | }>({});
25 |
26 | useEffect(() => {
27 | const getDefaultValues = async (): Promise => {
28 | const imagesSettings = await cache.getItem('images-settings');
29 | const videosSettings = await cache.getItem('videos-settings');
30 |
31 | setSettings({
32 | videos: videosSettings,
33 | images: imagesSettings,
34 | });
35 | };
36 |
37 | getDefaultValues();
38 | }, []);
39 |
40 | const handleSubmit = (e: React.FormEvent): void => {
41 | e.preventDefault();
42 |
43 | setLoading(true);
44 | const videos = e.currentTarget.videos.value;
45 | const images = e.currentTarget.images.value;
46 | cache.setItem('videos-settings', videos);
47 | cache.setItem('images-settings', images);
48 | setTimeout(() => {
49 | setLoading(false);
50 | }, 350);
51 | };
52 |
53 | return (
54 |
60 |
65 | Settings
66 |
67 |
72 | Transform settings, changes will apply to all future uploads.
73 |
74 |
111 |
112 | );
113 | };
114 |
115 | export default Settings;
116 |
--------------------------------------------------------------------------------
/frontend/src/components/Settings/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Settings';
2 | export { default } from './Settings';
3 |
--------------------------------------------------------------------------------
/frontend/src/components/Upload/DropZone.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Text, useTheme } from '@aws-amplify/ui-react';
2 | import type React from 'react';
3 | import { useCallback } from 'react';
4 | import {
5 | type DropEvent,
6 | type FileRejection,
7 | useDropzone,
8 | } from 'react-dropzone';
9 |
10 | type DropZoneProps = {
11 | children?: React.ReactNode;
12 | onDropAccepted: (files: T[], event: DropEvent) => void;
13 | };
14 |
15 | const DropZone: React.FC = ({ onDropAccepted }) => {
16 | const { tokens } = useTheme();
17 |
18 | const onDropRejected = useCallback((rejectedFiles: FileRejection[]) => {
19 | console.log(rejectedFiles[0]);
20 | alert(
21 | `${rejectedFiles[0].file.name} is invalid.\n${rejectedFiles[0].errors[0].message}`
22 | );
23 | }, []);
24 |
25 | const { getRootProps, getInputProps, isDragActive } = useDropzone({
26 | onDropAccepted,
27 | onDropRejected,
28 | accept: {
29 | 'image/jpeg': [],
30 | 'image/png': [],
31 | 'application/json': [],
32 | 'video/mp4': [],
33 | 'video/webm': [],
34 | },
35 | multiple: true,
36 | maxFiles: 10,
37 | });
38 |
39 | return (
40 |
62 |
63 | {isDragActive ? (
64 | <>
65 |
75 |
76 | Drop the files here ...
77 |
78 | >
79 | ) : (
80 | <>
81 |
90 |
91 | Drop your files here or click here to upload
92 |
93 | >
94 | )}
95 |
96 | );
97 | };
98 |
99 | export default DropZone;
100 |
--------------------------------------------------------------------------------
/frontend/src/components/Upload/FileUpload.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge,
3 | type BadgeVariations,
4 | Button,
5 | Loader,
6 | TableCell,
7 | TableRow,
8 | } from '@aws-amplify/ui-react';
9 | import type React from 'react';
10 | import { memo, useCallback, useEffect, useRef, useState } from 'react';
11 | import { getDownloadUrl } from '../../helpers/API';
12 | import { getStatusColor, upload } from './Upload.helpers';
13 |
14 | type FileUploadProps = {
15 | children?: React.ReactNode;
16 | id: string;
17 | url?: string;
18 | status: string;
19 | file: File;
20 | setFileStatus: (id: string, status: string) => void;
21 | };
22 |
23 | const FileUpload: React.FC = memo(
24 | ({ id, file, status, url, setFileStatus }) => {
25 | const [progress, setProgress] = useState(0);
26 | const [isDownloadLoading, setDownloadLoading] = useState(false);
27 | const hasStartedRef = useRef(false);
28 |
29 | if (!url) return null;
30 |
31 | useEffect(() => {
32 | const uploadFile = async (): Promise => {
33 | setFileStatus(id, 'uploading');
34 | await upload(url, file, onUploadProgress);
35 | };
36 |
37 | if (!file || !url || hasStartedRef.current) return;
38 | hasStartedRef.current = true;
39 | uploadFile();
40 | }, [url]);
41 |
42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
43 | const onUploadProgress = useCallback((progressEvent: any): void => {
44 | const percentCompleted = Math.round(
45 | (progressEvent.loaded * 100) / progressEvent.total
46 | );
47 | setProgress(percentCompleted);
48 | if (percentCompleted === 100) setFileStatus(id, 'uploaded');
49 | }, []);
50 |
51 | const handleDownload = useCallback(async () => {
52 | setDownloadLoading(true);
53 | try {
54 | const downloadUrl = await getDownloadUrl(id);
55 | console.log(downloadUrl);
56 | window.open(downloadUrl);
57 | } finally {
58 | setDownloadLoading(false);
59 | }
60 | }, [id]);
61 |
62 | return (
63 |
64 | {file.name}
65 |
66 | {status === 'uploading' && progress !== 100 ? (
67 | <>
68 | {status} {progress}%
69 | >
70 | ) : (
71 |
72 | {status}
73 |
74 | )}
75 |
76 |
77 | {status === 'completed' ? (
78 |
87 | ) : null}
88 |
89 |
90 | );
91 | }
92 | );
93 |
94 | export default FileUpload;
95 |
--------------------------------------------------------------------------------
/frontend/src/components/Upload/Upload.helpers.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | import { getPresignedUrl } from '../../helpers/API';
4 |
5 | export const generateUUID = (): string => {
6 | let d = new Date().getTime(); //Timestamp
7 | let d2 =
8 | (typeof performance !== 'undefined' &&
9 | performance.now &&
10 | performance.now() * 1000) ||
11 | 0; //Time in microseconds since page-load or 0 if unsupported
12 |
13 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
14 | let r = Math.random() * 16; //random number between 0 and 16
15 | if (d > 0) {
16 | //Use timestamp until depleted
17 | r = ((d + r) % 16) | 0;
18 | d = Math.floor(d / 16);
19 | } else {
20 | //Use microseconds since page-load if supported
21 | r = ((d2 + r) % 16) | 0;
22 | d2 = Math.floor(d2 / 16);
23 | }
24 |
25 | return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
26 | });
27 | };
28 |
29 | export type FileWithUrl = {
30 | id: string;
31 | url?: string;
32 | status: string;
33 | file: File;
34 | };
35 |
36 | export type FileWithUrlMap = Map;
37 |
38 | export const generateUploadUrls = async (
39 | acceptedFiles: File[]
40 | ): Promise => {
41 | try {
42 | const presignedUrlData = await Promise.all(
43 | acceptedFiles.map((file) => getPresignedUrl(file))
44 | );
45 | const newFileUploadData: FileWithUrlMap = new Map();
46 | presignedUrlData.forEach((presignedUrl, idx) => {
47 | if (presignedUrl) {
48 | newFileUploadData.set(presignedUrl.id, {
49 | id: presignedUrl.id,
50 | url: presignedUrl.url,
51 | file: acceptedFiles[idx],
52 | status: 'ready',
53 | });
54 | } else {
55 | const localUUID = generateUUID();
56 | newFileUploadData.set(localUUID, {
57 | id: localUUID,
58 | file: acceptedFiles[idx],
59 | status: 'failed',
60 | });
61 | }
62 | });
63 |
64 | return newFileUploadData;
65 | } catch (err) {
66 | console.error(err);
67 | throw err;
68 | }
69 | };
70 |
71 | const getFileFromInput = (file: File): Promise => {
72 | const fileReader = new FileReader();
73 |
74 | return new Promise((resolve, reject) => {
75 | fileReader.onerror = reject;
76 | fileReader.onload = () => {
77 | resolve(fileReader.result);
78 | };
79 | fileReader.readAsArrayBuffer(file); // here the file can be read in different way Text, DataUrl, ArrayBuffer
80 | });
81 | };
82 |
83 | export const upload = async (
84 | uploadUrl: string,
85 | file: File,
86 | onUploadProgress: (e: unknown) => void
87 | ): Promise => {
88 | const blob = await getFileFromInput(file);
89 | try {
90 | console.debug('about to upload', file.name);
91 | axios.put(uploadUrl, blob, {
92 | headers: {
93 | 'Content-Type': file.type,
94 | },
95 | onUploadProgress,
96 | });
97 | } catch (err) {
98 | console.error(err);
99 | } finally {
100 | console.debug('Upload completed');
101 | }
102 | };
103 |
104 | export const getStatusColor = (status: string): string => {
105 | switch (status) {
106 | case 'completed':
107 | return 'success';
108 | case 'queued':
109 | return 'warning';
110 | case 'in-progress':
111 | return 'info';
112 | case 'failed':
113 | return 'error';
114 | default:
115 | return '';
116 | }
117 | };
118 |
--------------------------------------------------------------------------------
/frontend/src/components/Upload/Upload.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from '@aws-amplify/ui-react';
2 | import type React from 'react';
3 | import { useCallback, useRef, useState } from 'react';
4 | import type { Subscription } from 'rxjs';
5 |
6 | import { subscribeToFileUpdates } from '../../helpers/API';
7 | import DropZone from './DropZone';
8 | import { type FileWithUrlMap, generateUploadUrls } from './Upload.helpers';
9 | import UploadingTable from './UploadingTable';
10 |
11 | type UploadProps = {
12 | children?: React.ReactNode;
13 | };
14 |
15 | const Upload: React.FC = () => {
16 | const [fileUploadData, setFileUploadData] = useState(
17 | new Map()
18 | );
19 | const subscriptionRef = useRef();
20 |
21 | const setFileStatus = (id: string, status: string): void => {
22 | setFileUploadData((prev) => {
23 | const fileRef = prev.get(id)!;
24 | fileRef.status = status;
25 |
26 | return new Map([...prev, [id, fileRef]]);
27 | });
28 | };
29 |
30 | const unsubscribeIfSubscribedToFileStatusUpdates = (): void => {
31 | if (!subscriptionRef.current?.closed)
32 | subscriptionRef.current?.unsubscribe();
33 | };
34 |
35 | const subscribeFileStatusUpdates = (fileUploadData: FileWithUrlMap): void => {
36 | if (!fileUploadData) return;
37 | const syncExpressionObject: { or: { id: { eq: string } }[] } = {
38 | or: [],
39 | };
40 | for (const { id, status } of fileUploadData.values()) {
41 | if (status === 'ready') syncExpressionObject.or.push({ id: { eq: id } });
42 | }
43 | subscriptionRef.current = subscribeToFileUpdates(
44 | (message) => {
45 | const { data } = message;
46 | if (data) {
47 | const { onUpdateFileStatus } = data;
48 | if (onUpdateFileStatus) {
49 | const { id, status } = onUpdateFileStatus;
50 | if (!id || !status) return;
51 | console.debug('update received', onUpdateFileStatus);
52 | setFileStatus(id, status);
53 | }
54 | } else {
55 | console.debug('no data received');
56 | }
57 | },
58 | (err) => console.error(err),
59 | syncExpressionObject
60 | );
61 | console.debug('Listening for updates on files', fileUploadData.keys());
62 | };
63 |
64 | const onDropAccepted = useCallback(
65 | async (acceptedFiles: File[]) => {
66 | if (!acceptedFiles.length) return;
67 |
68 | const filesWithUrls = await generateUploadUrls(acceptedFiles);
69 | setFileUploadData(filesWithUrls);
70 | unsubscribeIfSubscribedToFileStatusUpdates();
71 | subscribeFileStatusUpdates(filesWithUrls);
72 | },
73 | [fileUploadData]
74 | );
75 |
76 | const clear = useCallback(() => {
77 | unsubscribeIfSubscribedToFileStatusUpdates();
78 | setFileUploadData(new Map());
79 | }, []);
80 |
81 | return (
82 |
88 | {fileUploadData.size > 0 ? (
89 |
95 | ) : (
96 |
97 | )}
98 |
99 | );
100 | };
101 |
102 | export default Upload;
103 |
--------------------------------------------------------------------------------
/frontend/src/components/Upload/UploadingTable.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Table,
4 | TableBody,
5 | TableCell,
6 | TableHead,
7 | TableRow,
8 | } from '@aws-amplify/ui-react';
9 | import type React from 'react';
10 | import { useEffect, useState } from 'react';
11 |
12 | import FileUpload from './FileUpload';
13 | import type { FileWithUrlMap } from './Upload.helpers';
14 |
15 | type UploadingTableProps = {
16 | children?: React.ReactNode;
17 | files: FileWithUrlMap;
18 | setFileStatus: (id: string, status: string) => void;
19 | goBack: () => void;
20 | onDone: () => void;
21 | };
22 |
23 | const UploadingTable: React.FC = ({
24 | files,
25 | setFileStatus,
26 | onDone,
27 | goBack,
28 | }) => {
29 | const [isMoreButtonEnabled, setIsMoreButtonEnabled] = useState(false);
30 |
31 | useEffect(() => {
32 | if (files.size === 0) return;
33 |
34 | let allFileProcessed = true;
35 | for (const file of files.values()) {
36 | if (!['completed', 'failed'].includes(file.status))
37 | allFileProcessed = false;
38 | }
39 | if (allFileProcessed) {
40 | console.info(
41 | 'All files are completed, unsubscribing from onUpdatePosition AppSync mutation'
42 | );
43 | onDone();
44 | setIsMoreButtonEnabled(true);
45 | }
46 | }, [files, onDone]);
47 |
48 | const fileUploadComponents = [];
49 | for (const file of files.values()) {
50 | fileUploadComponents.push(
51 |
52 | );
53 | }
54 |
55 | if (fileUploadComponents.length === 0) return null;
56 |
57 | return (
58 | <>
59 |
60 |
61 |
62 |
63 | File
64 |
65 |
66 | Status
67 |
68 |
69 | Action
70 |
71 |
72 |
73 | {fileUploadComponents}
74 |
75 | {isMoreButtonEnabled ? (
76 |
85 | ) : null}
86 | >
87 | );
88 | };
89 |
90 | export default UploadingTable;
91 |
--------------------------------------------------------------------------------
/frontend/src/components/Upload/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Upload';
2 | export { default } from './Upload';
3 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // this is an auto generated file. This will be overwritten
4 |
5 | import type * as APITypes from '../helpers/API.types';
6 | type GeneratedMutation = string & {
7 | __generatedMutationInput: InputType;
8 | __generatedMutationOutput: OutputType;
9 | };
10 |
11 | export const generatePresignedUploadUrl = /* GraphQL */ `mutation GeneratePresignedUploadUrl($input: PresignedUploadUrlInput) {
12 | generatePresignedUploadUrl(input: $input) {
13 | id
14 | url
15 | __typename
16 | }
17 | }
18 | ` as GeneratedMutation<
19 | APITypes.GeneratePresignedUploadUrlMutationVariables,
20 | APITypes.GeneratePresignedUploadUrlMutation
21 | >;
22 | export const updateFileStatus = /* GraphQL */ `mutation UpdateFileStatus($input: FileStatusUpdateInput) {
23 | updateFileStatus(input: $input) {
24 | id
25 | status
26 | transformedFileKey
27 | __typename
28 | }
29 | }
30 | ` as GeneratedMutation<
31 | APITypes.UpdateFileStatusMutationVariables,
32 | APITypes.UpdateFileStatusMutation
33 | >;
34 |
--------------------------------------------------------------------------------
/frontend/src/graphql/queries.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // this is an auto generated file. This will be overwritten
4 |
5 | import type * as APITypes from '../helpers/API.types';
6 | type GeneratedQuery = string & {
7 | __generatedQueryInput: InputType;
8 | __generatedQueryOutput: OutputType;
9 | };
10 |
11 | export const generatePresignedDownloadUrl = /* GraphQL */ `query GeneratePresignedDownloadUrl($id: String!) {
12 | generatePresignedDownloadUrl(id: $id) {
13 | id
14 | url
15 | __typename
16 | }
17 | }
18 | ` as GeneratedQuery<
19 | APITypes.GeneratePresignedDownloadUrlQueryVariables,
20 | APITypes.GeneratePresignedDownloadUrlQuery
21 | >;
22 |
--------------------------------------------------------------------------------
/frontend/src/graphql/subscriptions.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // this is an auto generated file. This will be overwritten
4 |
5 | import type * as APITypes from '../helpers/API.types';
6 | type GeneratedSubscription = string & {
7 | __generatedSubscriptionInput: InputType;
8 | __generatedSubscriptionOutput: OutputType;
9 | };
10 |
11 | export const onUpdateFileStatus = /* GraphQL */ `subscription OnUpdateFileStatus($filter: onUpdateFileStatusFilterInput) {
12 | onUpdateFileStatus(filter: $filter) {
13 | id
14 | status
15 | transformedFileKey
16 | __typename
17 | }
18 | }
19 | ` as GeneratedSubscription<
20 | APITypes.OnUpdateFileStatusSubscriptionVariables,
21 | APITypes.OnUpdateFileStatusSubscription
22 | >;
23 |
--------------------------------------------------------------------------------
/frontend/src/helpers/API.ts:
--------------------------------------------------------------------------------
1 | import { generateClient } from '@aws-amplify/api';
2 | import type { GraphqlSubscriptionMessage } from '@aws-amplify/api-graphql';
3 | import type { Subscription } from 'rxjs';
4 |
5 | import { generatePresignedUploadUrl } from '../graphql/mutations';
6 | import { generatePresignedDownloadUrl } from '../graphql/queries';
7 | import { onUpdateFileStatus } from '../graphql/subscriptions';
8 | import type {
9 | GeneratePresignedUploadUrlMutation,
10 | OnUpdateFileStatusSubscription,
11 | onUpdateFileStatusFilterInput,
12 | } from './API.types';
13 |
14 | const client = generateClient();
15 |
16 | export const getPresignedUrl = async (
17 | file: File
18 | ): Promise<
19 | GeneratePresignedUploadUrlMutation['generatePresignedUploadUrl']
20 | > => {
21 | try {
22 | const res = await client.graphql({
23 | query: generatePresignedUploadUrl,
24 | variables: {
25 | input: {
26 | type: file.type,
27 | },
28 | },
29 | });
30 |
31 | const data = res.data;
32 | if (!data) {
33 | console.log('Unable to get presigned url', res);
34 | throw new Error('Unable to get presigned url');
35 | }
36 | const { generatePresignedUploadUrl: presignedUrlResponse } = data;
37 |
38 | if (!presignedUrlResponse) {
39 | console.log('Unable to get presigned url', res);
40 | throw new Error('Unable to get presigned url');
41 | }
42 |
43 | return presignedUrlResponse;
44 | } catch (err) {
45 | console.error(err);
46 | throw err;
47 | }
48 | };
49 |
50 | export const subscribeToFileUpdates = (
51 | onNextHandler: (
52 | message: GraphqlSubscriptionMessage
53 | ) => void,
54 | onErrorHandler: (err: unknown) => void,
55 | filter?: onUpdateFileStatusFilterInput
56 | ): Subscription => {
57 | return client
58 | .graphql({
59 | query: onUpdateFileStatus,
60 | variables: {
61 | filter,
62 | },
63 | })
64 | .subscribe({
65 | next: onNextHandler,
66 | error: onErrorHandler,
67 | });
68 | };
69 |
70 | export const getDownloadUrl = async (id: string): Promise => {
71 | try {
72 | const res = await client.graphql({
73 | query: generatePresignedDownloadUrl,
74 | variables: {
75 | id,
76 | },
77 | });
78 |
79 | if (!res.data || !res.data.generatePresignedDownloadUrl)
80 | throw new Error('Unable to get presigned url');
81 |
82 | return res.data?.generatePresignedDownloadUrl.url;
83 | } catch (err) {
84 | console.error(err);
85 | throw err;
86 | }
87 | };
88 |
--------------------------------------------------------------------------------
/frontend/src/helpers/API.types.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // This file was automatically generated and should not be edited.
4 |
5 | export type PresignedUploadUrlInput = {
6 | type: string;
7 | };
8 |
9 | export type PresignedUrl = {
10 | __typename: 'PresignedUrl';
11 | id: string;
12 | url: string;
13 | };
14 |
15 | export type FileStatusUpdateInput = {
16 | id?: string | null;
17 | status: string;
18 | transformedFileKey?: string | null;
19 | };
20 |
21 | export type File = {
22 | __typename: 'File';
23 | id?: string | null;
24 | status: string;
25 | transformedFileKey?: string | null;
26 | };
27 |
28 | export type onUpdateFileStatusFilterInput = {
29 | id?: onUpdateFileStatusStringInput | null;
30 | status?: onUpdateFileStatusStringInput | null;
31 | and?: Array | null;
32 | or?: Array | null;
33 | };
34 |
35 | export type onUpdateFileStatusStringInput = {
36 | ne?: string | null;
37 | eq?: string | null;
38 | le?: string | null;
39 | lt?: string | null;
40 | ge?: string | null;
41 | gt?: string | null;
42 | contains?: string | null;
43 | notContains?: string | null;
44 | between?: Array | null;
45 | beginsWith?: string | null;
46 | in?: Array | null;
47 | notIn?: Array | null;
48 | };
49 |
50 | export type GeneratePresignedUploadUrlMutationVariables = {
51 | input?: PresignedUploadUrlInput | null;
52 | };
53 |
54 | export type GeneratePresignedUploadUrlMutation = {
55 | generatePresignedUploadUrl?: {
56 | __typename: 'PresignedUrl';
57 | id: string;
58 | url: string;
59 | } | null;
60 | };
61 |
62 | export type UpdateFileStatusMutationVariables = {
63 | input?: FileStatusUpdateInput | null;
64 | };
65 |
66 | export type UpdateFileStatusMutation = {
67 | updateFileStatus?: {
68 | __typename: 'File';
69 | id?: string | null;
70 | status: string;
71 | transformedFileKey?: string | null;
72 | } | null;
73 | };
74 |
75 | export type GeneratePresignedDownloadUrlQueryVariables = {
76 | id: string;
77 | };
78 |
79 | export type GeneratePresignedDownloadUrlQuery = {
80 | generatePresignedDownloadUrl?: {
81 | __typename: 'PresignedUrl';
82 | id: string;
83 | url: string;
84 | } | null;
85 | };
86 |
87 | export type OnUpdateFileStatusSubscriptionVariables = {
88 | filter?: onUpdateFileStatusFilterInput | null;
89 | };
90 |
91 | export type OnUpdateFileStatusSubscription = {
92 | onUpdateFileStatus?: {
93 | __typename: 'File';
94 | id?: string | null;
95 | status: string;
96 | transformedFileKey?: string | null;
97 | } | null;
98 | };
99 |
--------------------------------------------------------------------------------
/frontend/src/helpers/cache.ts:
--------------------------------------------------------------------------------
1 | import { Cache } from 'aws-amplify/utils';
2 |
3 | const cache = Cache.createInstance({
4 | storage: window.localStorage,
5 | keyPrefix: 'aws-lambda-powertools-workshop-',
6 | warningThreshold: 0.8,
7 | defaultPriority: 5,
8 | itemMaxSize: 200,
9 | defaultTTL: 1000 * 60 * 60 * 60 * 2,
10 | capacityInBytes: 5000000,
11 | });
12 |
13 | export default cache;
14 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | @import url(https://fonts.bunny.net/css?family=inter:300,400,500);
2 |
3 | [data-amplify-authenticator] {
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | min-height: 100vh;
8 | background: var(--amplify-colors-neutral-20);
9 | padding: 0 5px;
10 | }
11 |
12 | @media not all and (min-resolution: .001dpcm) {
13 | @supports (-webkit-appearance: none) and (stroke-color: transparent) {
14 | body {
15 | min-height: -webkit-fill-available;
16 | }
17 |
18 | [data-amplify-authenticator] {
19 | min-height: -webkit-fill-available;
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { RouterProvider, createBrowserRouter } from 'react-router-dom';
4 | import App from './components/App';
5 | import 'normalize.css';
6 | import '@aws-amplify/ui-react/styles.css';
7 | import './index.css';
8 | import { Authenticator, ThemeProvider } from '@aws-amplify/ui-react';
9 | import { Amplify } from 'aws-amplify';
10 | import awsmobile from './aws-exports.cjs';
11 |
12 | import { ErrorPage } from './components/App/ErrorPage';
13 | import Settings from './components/Settings';
14 | import Upload from './components/Upload';
15 |
16 | Amplify.configure(awsmobile);
17 |
18 | const theme = {
19 | name: 'workshop-theme',
20 | };
21 |
22 | const router = createBrowserRouter([
23 | {
24 | path: '/',
25 | element: ,
26 | errorElement: ,
27 | children: [
28 | {
29 | index: true,
30 | element: ,
31 | },
32 | {
33 | path: 'settings',
34 | element: ,
35 | },
36 | ],
37 | },
38 | ]);
39 |
40 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
41 |
42 |
43 |
58 |
59 |
60 |
61 |
62 | );
63 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [
21 | {
22 | "path": "./tsconfig.node.json"
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | resolve: { alias: { './runtimeConfig': './runtimeConfig.browser' } },
8 | build: {
9 | outDir: 'build',
10 | },
11 | server: {
12 | host: true,
13 | port: 8080,
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/functions/dotnet/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/powertools-for-aws-lambda-workshop/5beb34e7d7b18f90bfb77b2b8bfa3755b6365925/functions/dotnet/.gitkeep
--------------------------------------------------------------------------------
/functions/dotnet/PowertoolsWorkshop.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net8.0
4 | true
5 | Lambda
6 |
8 | true
9 | true
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/functions/dotnet/module1/Services/Constants.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System.Drawing;
5 |
6 | namespace PowertoolsWorkshop.Module1.Services;
7 |
8 | public static class Constants
9 | {
10 | public const string TransformedImagePrefix = "transformed/image/jpg";
11 | public const string TransformedImageExtension = ".jpeg";
12 | }
13 |
14 | public static class TransformSize
15 | {
16 | public static readonly Size Small = new Size(720, 480);
17 | public static readonly Size Medium = new Size(1280, 720);
18 | public static readonly Size Large = new Size(1920, 1080);
19 | }
20 |
21 | public static class FileStatus
22 | {
23 | public const string Queued= "queued";
24 | public const string Working= "in-progress";
25 | public const string Completed = "completed";
26 | public const string Failed = "failed";
27 | }
28 |
29 | public static class Mutations
30 | {
31 | public const string GeneratePresignedUploadUrl = @"
32 | mutation GeneratePresignedUploadUrl($input: PresignedUploadUrlInput) {
33 | generatePresignedUploadUrl(input: $input) {
34 | id
35 | url
36 | }
37 | }
38 | ";
39 |
40 | public const string UpdateFileStatus = @"
41 | mutation UpdateFileStatus($input: FileStatusUpdateInput) {
42 | updateFileStatus(input: $input) {
43 | id
44 | status
45 | transformedFileKey
46 | }
47 | }
48 | ";
49 | }
--------------------------------------------------------------------------------
/functions/dotnet/module1/Services/ImageManipulationService.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System;
5 | using System.Drawing;
6 | using System.IO;
7 | using System.Threading.Tasks;
8 | using SkiaSharp;
9 |
10 | namespace PowertoolsWorkshop.Module1.Services;
11 |
12 | public interface IImageManipulationService
13 | {
14 | Task ResizeAsync(Stream stream, Size size);
15 | }
16 |
17 | public class ImageManipulationService : IImageManipulationService
18 | {
19 | public async Task ResizeAsync(Stream stream, Size maxSize)
20 | {
21 | using var tmp = new MemoryStream();
22 | await stream.CopyToAsync(tmp);
23 | var data = Resize(tmp.ToArray(), maxSize.Width, maxSize.Height);
24 | return new MemoryStream(data);
25 | }
26 |
27 | private static byte[] Resize(byte[] fileContents, int maxWidth, int maxHeight)
28 | {
29 | using var ms = new MemoryStream(fileContents);
30 | using var sourceBitmap = SKBitmap.Decode(ms);
31 |
32 | var height = Math.Min(maxHeight, sourceBitmap.Height);
33 | var width = Math.Min(maxWidth, sourceBitmap.Width);
34 |
35 | using var scaledBitmap = sourceBitmap.Resize(new SKImageInfo(width, height), SKFilterQuality.Medium);
36 | using var scaledImage = SKImage.FromBitmap(scaledBitmap);
37 | using var data = scaledImage.Encode();
38 |
39 | return data.ToArray();
40 | }
41 | }
--------------------------------------------------------------------------------
/functions/dotnet/module2/ImageDetectionFunction.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System;
5 | using System.Threading.Tasks;
6 | using Amazon.Lambda.Core;
7 | using Amazon.Lambda.DynamoDBEvents;
8 | using AWS.Lambda.Powertools.Logging;
9 | using AWS.Lambda.Powertools.Tracing;
10 | using PowertoolsWorkshop.Module2.Services;
11 |
12 | namespace PowertoolsWorkshop
13 | {
14 | public class ImageDetectionFunction
15 | {
16 | private static IImageDetectionService _imageDetectionService;
17 |
18 | ///
19 | /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment
20 | /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the
21 | /// region the Lambda function is executed in.
22 | ///
23 | public ImageDetectionFunction()
24 | {
25 | Tracing.RegisterForAllServices();
26 | _imageDetectionService = new ImageDetectionService();
27 | }
28 |
29 | ///
30 | /// This method is called for every Lambda invocation. This method takes in a DynamoDB event object
31 | /// and can be used to respond to DynamoDB stream notifications.
32 | ///
33 | ///
34 | ///
35 | ///
36 | [Tracing(CaptureMode = TracingCaptureMode.ResponseAndError)]
37 | [Logging(LogEvent = true, LoggerOutputCase = LoggerOutputCase.PascalCase)]
38 | public async Task FunctionHandler(DynamoDBEvent dynamoEvent, ILambdaContext context)
39 | {
40 | foreach (var record in dynamoEvent.Records)
41 | await RecordHandler(record).ConfigureAwait(false);
42 | }
43 |
44 | private async Task RecordHandler(DynamoDBEvent.DynamodbStreamRecord record)
45 | {
46 | var fileId = record.Dynamodb.NewImage["id"].S;
47 | var userId = record.Dynamodb.NewImage["userId"].S;
48 | var transformedFileKey = record.Dynamodb.NewImage["transformedFileKey"].S;
49 |
50 | if (!await _imageDetectionService.HasPersonLabel(fileId, userId, transformedFileKey).ConfigureAwait(false))
51 | await _imageDetectionService.ReportImageIssue(fileId, userId).ConfigureAwait(false);
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/functions/dotnet/module2/Services/ApiService.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System;
5 | using System.Net.Http;
6 | using System.Net.Mime;
7 | using System.Text;
8 | using System.Threading.Tasks;
9 | using Amazon.Util;
10 | using Newtonsoft.Json;
11 |
12 | namespace PowertoolsWorkshop.Module2.Services;
13 |
14 | public interface IApiService
15 | {
16 | Task PostAsJsonAsync(string apiUrl, string apiKey, object content);
17 | }
18 |
19 | public class ApiService : IApiService
20 | {
21 | public async Task PostAsJsonAsync(string apiUrl, string apiKey, object content)
22 | {
23 | if (string.IsNullOrWhiteSpace(apiUrl))
24 | throw new ArgumentNullException(nameof(apiUrl));
25 |
26 | if (string.IsNullOrWhiteSpace(apiKey))
27 | throw new ArgumentNullException(nameof(apiKey));
28 |
29 | if (content is null)
30 | throw new ArgumentNullException(nameof(content));
31 |
32 | var httpClient = new HttpClient();
33 |
34 | var jsonPayload = JsonConvert.SerializeObject(content);
35 | var requestContent = new StringContent(jsonPayload, Encoding.ASCII, MediaTypeNames.Application.Json);
36 | var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, apiUrl)
37 | {
38 | Content = requestContent
39 | };
40 |
41 | httpRequestMessage.Headers.TryAddWithoutValidation("x-api-key", apiKey);
42 | httpRequestMessage.Headers.TryAddWithoutValidation(HeaderKeys.ContentTypeHeader, MediaTypeNames.Application.Json);
43 |
44 | var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage);
45 | httpResponseMessage.EnsureSuccessStatusCode();
46 | }
47 | }
--------------------------------------------------------------------------------
/functions/dotnet/module2/Services/ApiUrlParameter.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System.Text.Json.Serialization;
5 |
6 | namespace PowertoolsWorkshop.Module2.Services;
7 |
8 | public class ApiUrlParameter
9 | {
10 | [JsonPropertyName("url")] public string Url { get; set; }
11 | }
--------------------------------------------------------------------------------
/functions/dotnet/module2/Services/[complete]ImageDetectionService.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System;
5 | using System.Linq;
6 | using System.Threading.Tasks;
7 | using Amazon.Rekognition;
8 | using Amazon.Rekognition.Model;
9 | using AWS.Lambda.Powertools.Logging;
10 | using AWS.Lambda.Powertools.Parameters;
11 | using AWS.Lambda.Powertools.Parameters.SecretsManager;
12 | using AWS.Lambda.Powertools.Parameters.SimpleSystemsManagement;
13 | using AWS.Lambda.Powertools.Parameters.Transform;
14 |
15 | namespace PowertoolsWorkshop.Module2.Services;
16 |
17 | public interface IImageDetectionService
18 | {
19 | Task HasPersonLabel(string fileId, string userId, string objectKey);
20 |
21 | Task ReportImageIssue(string fileId, string userId);
22 | }
23 |
24 | public class ImageDetectionService : IImageDetectionService
25 | {
26 | private static string _filesBucketName;
27 | private static string _apiUrlParameterName;
28 | private static string _apiKeySecretName;
29 | private static IAmazonRekognition _rekognitionClient;
30 | private static IApiService _apiService;
31 | private static ISsmProvider _ssmProvider;
32 | private static ISecretsProvider _secretsProvider;
33 |
34 | public ImageDetectionService()
35 | {
36 | _filesBucketName = Environment.GetEnvironmentVariable("BUCKET_NAME_FILES");
37 | _apiUrlParameterName = Environment.GetEnvironmentVariable("API_URL_PARAMETER_NAME");
38 | _apiKeySecretName = Environment.GetEnvironmentVariable("API_KEY_SECRET_NAME");
39 |
40 | _apiService = new ApiService();
41 | _rekognitionClient = new AmazonRekognitionClient();
42 |
43 | _ssmProvider = ParametersManager.SsmProvider;
44 | _secretsProvider = ParametersManager.SecretsProvider;
45 | }
46 |
47 | public async Task HasPersonLabel(string fileId, string userId, string objectKey)
48 | {
49 | Logger.LogInformation($"Get labels for File Id: {fileId}");
50 |
51 | var response = await _rekognitionClient.DetectLabelsAsync(new DetectLabelsRequest
52 | {
53 | Image = new Image
54 | {
55 | S3Object = new S3Object
56 | {
57 | Bucket = _filesBucketName,
58 | Name = objectKey
59 | },
60 | }
61 | }).ConfigureAwait(false);
62 |
63 | if (response?.Labels is null || !response.Labels.Any())
64 | {
65 | Logger.LogWarning("No labels found in image");
66 | return false;
67 | }
68 |
69 | if (!response.Labels.Any(l =>
70 | string.Equals(l.Name, "Person", StringComparison.InvariantCultureIgnoreCase) &&
71 | l.Confidence > 75))
72 | {
73 | Logger.LogWarning("No person found in image");
74 | return false;
75 | }
76 |
77 | Logger.LogInformation("Person found in image");
78 | return true;
79 | }
80 |
81 | public async Task ReportImageIssue(string fileId, string userId)
82 | {
83 | var apiUrlParameter = await _ssmProvider
84 | .WithTransformation(Transformation.Json)
85 | .GetAsync(_apiUrlParameterName)
86 | .ConfigureAwait(false);
87 |
88 | var apiKey = await _secretsProvider
89 | .GetAsync(_apiKeySecretName)
90 | .ConfigureAwait(false);
91 |
92 | if (string.IsNullOrWhiteSpace(apiUrlParameter?.Url) || string.IsNullOrWhiteSpace(apiKey))
93 | throw new Exception($"Missing apiUrl or apiKey. apiUrl: ${apiUrlParameter?.Url}, apiKey: ${apiKey}");
94 |
95 | Logger.LogInformation("Sending report to the API");
96 |
97 | await _apiService.PostAsJsonAsync(apiUrlParameter.Url, apiKey, new { fileId, userId }).ConfigureAwait(false);
98 |
99 | Logger.LogInformation("Report sent to the API");
100 | }
101 | }
--------------------------------------------------------------------------------
/functions/dotnet/module2/[complete]ImageDetectionFunction.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System;
5 | using System.Threading.Tasks;
6 | using Amazon.Lambda.Core;
7 | using Amazon.Lambda.DynamoDBEvents;
8 | using AWS.Lambda.Powertools.Logging;
9 | using AWS.Lambda.Powertools.Tracing;
10 | using AWS.Lambda.Powertools.BatchProcessing;
11 | using AWS.Lambda.Powertools.BatchProcessing.DynamoDb;
12 | using AWS.Lambda.Powertools.Parameters;
13 | using PowertoolsWorkshop.Module2.Services;
14 |
15 | namespace PowertoolsWorkshop
16 | {
17 | public class ImageDetectionFunction
18 | {
19 | private static IImageDetectionService _imageDetectionService;
20 | private static IDynamoDbStreamBatchProcessor _batchProcessor;
21 |
22 | ///
23 | /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment
24 | /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the
25 | /// region the Lambda function is executed in.
26 | ///
27 | public ImageDetectionFunction()
28 | {
29 | Tracing.RegisterForAllServices();
30 | ParametersManager.DefaultMaxAge(TimeSpan.FromSeconds(900));
31 |
32 | _imageDetectionService = new ImageDetectionService();
33 | _batchProcessor = DynamoDbStreamBatchProcessor.Instance;
34 | }
35 |
36 | ///
37 | /// This method is called for every Lambda invocation. This method takes in a DynamoDB event object
38 | /// and can be used to respond to DynamoDB stream notifications.
39 | ///
40 | ///
41 | ///
42 | ///
43 | [Tracing(CaptureMode = TracingCaptureMode.ResponseAndError)]
44 | [Logging(LogEvent = true, LoggerOutputCase = LoggerOutputCase.PascalCase)]
45 | public async Task FunctionHandler(DynamoDBEvent dynamoEvent, ILambdaContext context)
46 | {
47 | var result = await _batchProcessor.ProcessAsync(dynamoEvent,
48 | RecordHandler.From(
49 | record =>
50 | {
51 | RecordHandler(record, context)
52 | .GetAwaiter()
53 | .GetResult();
54 | }));
55 | return result.BatchItemFailuresResponse;
56 | }
57 |
58 | [Tracing(SegmentName = "### RecordHandler")]
59 | private async Task RecordHandler(DynamoDBEvent.DynamodbStreamRecord record, ILambdaContext context)
60 | {
61 | var fileId = record.Dynamodb.NewImage["id"].S;
62 | var userId = record.Dynamodb.NewImage["userId"].S;
63 | var transformedFileKey = record.Dynamodb.NewImage["transformedFileKey"].S;
64 |
65 | Logger.AppendKey("FileId", fileId);
66 | Logger.AppendKey("UserId", userId);
67 |
68 | Tracing.AddAnnotation("FileId", fileId);
69 | Tracing.AddAnnotation("UserId", userId);
70 |
71 | if (context.RemainingTime.TotalMilliseconds < 1000)
72 | {
73 | Logger.LogWarning("Invocation is about to time out, marking all remaining records as failed");
74 | throw new Exception("Time remaining <1s, marking record as failed to retry later");
75 | }
76 |
77 | if (!await _imageDetectionService.HasPersonLabel(fileId, userId, transformedFileKey).ConfigureAwait(false))
78 | await _imageDetectionService.ReportImageIssue(fileId, userId).ConfigureAwait(false);
79 |
80 | Logger.RemoveKeys();
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/functions/dotnet/module3/ApiEndpointHandlerFunction.cs:
--------------------------------------------------------------------------------
1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 | // SPDX-License-Identifier: MIT-0
3 |
4 | using System.Collections.Generic;
5 | using System.Net.Mime;
6 | using System.Text.Json;
7 | using Amazon.Lambda.APIGatewayEvents;
8 | using Amazon.Lambda.Core;
9 | using Amazon.Util;
10 |
11 | namespace PowertoolsWorkshop
12 | {
13 | public class ApiEndpointHandlerFunction
14 | {
15 | public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context)
16 | {
17 | var body = new Dictionary
18 | {
19 | { "message", "Hello from Lambda!" }
20 | };
21 |
22 | return new APIGatewayProxyResponse
23 | {
24 | Body = JsonSerializer.Serialize(body),
25 | StatusCode = 200,
26 | Headers = new Dictionary
27 | {
28 | { HeaderKeys.ContentTypeHeader, MediaTypeNames.Application.Json }
29 | }
30 | };
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/functions/java/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/powertools-for-aws-lambda-workshop/5beb34e7d7b18f90bfb77b2b8bfa3755b6365925/functions/java/.gitkeep
--------------------------------------------------------------------------------
/functions/java/modules/module1/src/main/java/com/amazonaws/powertools/workshop/Module1Handler.java:
--------------------------------------------------------------------------------
1 | package com.amazonaws.powertools.workshop;
2 |
3 | import java.awt.image.BufferedImage;
4 | import java.io.ByteArrayInputStream;
5 | import java.io.ByteArrayOutputStream;
6 | import java.io.IOException;
7 | import java.util.Map;
8 |
9 | import com.amazonaws.services.lambda.runtime.Context;
10 | import com.amazonaws.services.lambda.runtime.RequestHandler;
11 | import java.util.UUID;
12 | import javax.imageio.ImageIO;
13 | import net.coobird.thumbnailator.Thumbnails;
14 | import org.apache.logging.log4j.LogManager;
15 | import org.apache.logging.log4j.Logger;
16 |
17 | import static com.amazonaws.powertools.workshop.Utils.getImageMetadata;
18 | import static com.amazonaws.powertools.workshop.Utils.markFileAs;
19 |
20 |
21 | /**
22 | * Handler for requests to Lambda function.
23 | */
24 | public class Module1Handler implements RequestHandler {
25 |
26 | private static final String TRANSFORMED_IMAGE_PREFIX = "transformed/image/jpg";
27 | private static final String TRANSFORMED_IMAGE_EXTENSION = ".jpeg";
28 | private static final int TRANSFORMED_IMAGE_WIDTH = 720;
29 | private static final int TRANSFORMED_IMAGE_HEIGHT = 480;
30 | private static final Logger LOGGER = LogManager.getLogger(Module1Handler.class);
31 |
32 | public String handleRequest(final S3EBEvent event, final Context context) {
33 |
34 | S3Object object = new S3Object();
35 | object.setKey(event.getDetail().getObject().get("key"));
36 | object.setEtag(event.getDetail().getObject().get("etag"));
37 |
38 | // Fetch additional metadata from DynamoDB
39 | Map metadata = getImageMetadata(object.getKey());
40 | object.setFileId(metadata.get("fileId"));
41 | object.setUserId(metadata.get("userId"));
42 |
43 | try {
44 | // Mark file as working
45 | markFileAs(object.getFileId(), "in-progress", null);
46 |
47 | String newObjectKey = processOne(object);
48 |
49 | // Mark file as done
50 | markFileAs(object.getFileId(), "completed", newObjectKey);
51 | } catch (Exception e) {
52 | // Mark file as failed
53 | markFileAs(object.getFileId(), "failed", null);
54 | throw e;
55 | }
56 |
57 | return "ok";
58 | }
59 |
60 | private String processOne(S3Object s3Object) {
61 | String newObjectKey = TRANSFORMED_IMAGE_PREFIX + "/" + UUID.randomUUID() + TRANSFORMED_IMAGE_EXTENSION;
62 |
63 | // Get the original image from S3
64 | byte[] originalImageBytes = Utils.getOriginalImageBytes(s3Object);
65 | LOGGER.info("Load image from S3: {} ({}kb)", s3Object.key, originalImageBytes.length / 1024);
66 |
67 | try {
68 | // Create thumbnail from the original image (you'll need to implement this method)
69 | byte[] thumbnail = createThumbnail(originalImageBytes);
70 |
71 | // Save the thumbnail on S3
72 | Utils.storeImageThumbnail(thumbnail, newObjectKey);
73 |
74 | // Log the result
75 | LOGGER.info("Saved image on S3: {} ({}kb)", newObjectKey, thumbnail.length / 1024);
76 |
77 |
78 | } catch (IOException e) {
79 | throw new RuntimeException(e);
80 | }
81 |
82 | return newObjectKey;
83 | }
84 |
85 | private byte[] createThumbnail(byte[] originalImageBytes) throws IOException {
86 | BufferedImage bufferedImage = Thumbnails.of(new ByteArrayInputStream(originalImageBytes))
87 | .size(TRANSFORMED_IMAGE_WIDTH, TRANSFORMED_IMAGE_HEIGHT)
88 | .outputFormat("jpg")
89 | .asBufferedImage();
90 | ByteArrayOutputStream baos = new ByteArrayOutputStream();
91 | ImageIO.write(bufferedImage, "jpg", baos);
92 | return baos.toByteArray();
93 | }
94 | }
--------------------------------------------------------------------------------
/functions/java/modules/module1/src/main/java/com/amazonaws/powertools/workshop/S3Object.java:
--------------------------------------------------------------------------------
1 | package com.amazonaws.powertools.workshop;
2 |
3 | public class S3Object {
4 | String key;
5 | String etag;
6 | String fileId;
7 | String userId;
8 |
9 | public String getKey() {
10 | return key;
11 | }
12 |
13 | public void setKey(String key) {
14 | this.key = key;
15 | }
16 |
17 | public String getEtag() {
18 | return etag;
19 | }
20 |
21 | public void setEtag(String etag) {
22 | this.etag = etag;
23 | }
24 |
25 | public String getFileId() {
26 | return fileId;
27 | }
28 |
29 | public void setFileId(String fileId) {
30 | this.fileId = fileId;
31 | }
32 |
33 | public String getUserId() {
34 | return userId;
35 | }
36 |
37 | public void setUserId(String userId) {
38 | this.userId = userId;
39 | }
40 |
41 | @Override
42 | public String toString() {
43 | return "S3Object{" +
44 | "key='" + key + '\'' +
45 | ", etag='" + etag + '\'' +
46 | ", fileId='" + fileId + '\'' +
47 | ", userId='" + userId + '\'' +
48 | '}';
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/functions/java/modules/module1/src/main/java/com/amazonaws/powertools/workshop/UpdateFileStatusException.java:
--------------------------------------------------------------------------------
1 | package com.amazonaws.powertools.workshop;
2 |
3 | public class UpdateFileStatusException extends RuntimeException {
4 |
5 | public UpdateFileStatusException(Throwable t) {
6 | super(t);
7 | }
8 |
9 | public UpdateFileStatusException(String message) {
10 | super(message);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/functions/java/modules/module1/src/main/resources/log4j2.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/functions/java/modules/module2/src/main/java/com/amazonaws/powertools/workshop/APIHost.java:
--------------------------------------------------------------------------------
1 | package com.amazonaws.powertools.workshop;
2 |
3 | public class APIHost {
4 |
5 | private String url;
6 |
7 | public APIHost(String url) {
8 | this.url = url;
9 | }
10 |
11 | public String getUrl() {
12 | return url;
13 | }
14 |
15 | public void setUrl(String url) {
16 | this.url = url;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/functions/java/modules/module2/src/main/java/com/amazonaws/powertools/workshop/ImageDetectionException.java:
--------------------------------------------------------------------------------
1 | package com.amazonaws.powertools.workshop;
2 |
3 | public class ImageDetectionException extends Exception {
4 | private final String fileId;
5 | private final String userId;
6 |
7 | public ImageDetectionException(String message, ImageMetadata metadata) {
8 | super(message);
9 | this.fileId = metadata.getFileId();
10 | this.userId = metadata.getUserId();
11 | }
12 |
13 |
14 | public static class NoLabelsFoundException extends ImageDetectionException {
15 | public NoLabelsFoundException(ImageMetadata metadata) {
16 | super("No labels found in image", metadata);
17 | }
18 | }
19 |
20 | static class NoPersonFoundException extends ImageDetectionException {
21 | public NoPersonFoundException(ImageMetadata metadata) {
22 | super("No person found in image", metadata);
23 | }
24 | }
25 | }
26 |
27 |
28 |
--------------------------------------------------------------------------------
/functions/java/modules/module2/src/main/java/com/amazonaws/powertools/workshop/ImageMetadata.java:
--------------------------------------------------------------------------------
1 | package com.amazonaws.powertools.workshop;
2 |
3 | public class ImageMetadata {
4 | private String fileId;
5 | private String userId;
6 |
7 | public ImageMetadata(String fileId, String userId) {
8 | this.fileId = fileId;
9 | this.userId = userId;
10 | }
11 |
12 | public static ImageMetadata of(String fileId, String userId) {
13 | return new ImageMetadata(fileId, userId);
14 | }
15 |
16 | public String getFileId() {
17 | return fileId;
18 | }
19 |
20 | public void setFileId(String fileId) {
21 | this.fileId = fileId;
22 | }
23 |
24 | public String getUserId() {
25 | return userId;
26 | }
27 |
28 | public void setUserId(String userId) {
29 | this.userId = userId;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/functions/java/modules/module2/src/main/resources/log4j2.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/functions/java/modules/module3/src/main/java/com/amazonaws/powertools/workshop/Module3Handler.java:
--------------------------------------------------------------------------------
1 | package com.amazonaws.powertools.workshop;
2 |
3 | import com.amazonaws.services.lambda.runtime.Context;
4 | import com.amazonaws.services.lambda.runtime.RequestHandler;
5 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
6 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
7 | import java.util.Map;
8 | import software.amazon.lambda.powertools.logging.Logging;
9 | import software.amazon.lambda.powertools.metrics.Metrics;
10 | import software.amazon.lambda.powertools.tracing.Tracing;
11 |
12 | /**
13 | * Lambda function Handler for the reporting API
14 | */
15 | public class Module3Handler implements RequestHandler {
16 |
17 | @Logging(logEvent = true)
18 | @Tracing
19 | @Metrics(captureColdStart = true)
20 | public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
21 | return new APIGatewayProxyResponseEvent()
22 | .withHeaders(Map.of("Content-Type", "application/json"))
23 | .withStatusCode(200)
24 | .withBody("{ \"message\": \"hello form module 3\"}");
25 | }
26 | }
--------------------------------------------------------------------------------
/functions/java/modules/module3/src/main/resources/log4j2.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/functions/python/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/powertools-for-aws-lambda-workshop/5beb34e7d7b18f90bfb77b2b8bfa3755b6365925/functions/python/.gitkeep
--------------------------------------------------------------------------------
/functions/python/modules/module1/app.py:
--------------------------------------------------------------------------------
1 | import boto3
2 | import os
3 | import uuid
4 | from dataclasses import dataclass
5 | from utils import (
6 | get_image_metadata,
7 | get_original_object,
8 | create_thumbnail,
9 | write_thumbnail_to_s3,
10 | )
11 | from graphql import mark_file_as
12 | from constants import (
13 | TRANSFORMED_IMAGE_PREFIX,
14 | TRANSFORMED_IMAGE_EXTENSION,
15 | FileStatus,
16 | TransformSize
17 | )
18 |
19 | S3_BUCKET_FILES = os.getenv("BUCKET_NAME_FILES", "")
20 | FILES_TABLE_NAME = os.getenv("TABLE_NAME_FILES", "")
21 | REGION_NAME = os.getenv("AWS_REGION", "")
22 |
23 | dynamodb_client = boto3.client("dynamodb", region_name=REGION_NAME)
24 | s3_client = boto3.client("s3", region_name=REGION_NAME)
25 |
26 |
27 | @dataclass
28 | class TransformImage:
29 | file_id: str
30 | user_id: str
31 | object_key: str
32 | object_etag: str
33 |
34 | def process_thumbnail(transform_image: TransformImage):
35 |
36 | object_key = transform_image.object_key
37 |
38 | new_thumbnail_key: str = (
39 | f"{TRANSFORMED_IMAGE_PREFIX}/{uuid.uuid4()}{TRANSFORMED_IMAGE_EXTENSION}"
40 | )
41 |
42 | # Get the original image from S3
43 | original_image: bytes = get_original_object(
44 | s3_client=s3_client, object_key=object_key, bucket_name=S3_BUCKET_FILES
45 | )
46 |
47 | thumbnail_size = TransformSize.SMALL.value
48 |
49 | # Create thumbnail from original image
50 | thumbnail_image = create_thumbnail(
51 | original_image=original_image,
52 | width=thumbnail_size.get("width"),
53 | height=thumbnail_size.get("height"),
54 | )
55 |
56 | # Save the thumbnail on S3
57 | write_thumbnail_to_s3(
58 | s3_client=s3_client,
59 | object_key=new_thumbnail_key,
60 | bucket_name=S3_BUCKET_FILES,
61 | body=thumbnail_image,
62 | )
63 |
64 | print(f"Saved image on S3:{new_thumbnail_key}")
65 |
66 | return new_thumbnail_key
67 |
68 |
69 | def lambda_handler(event, context):
70 |
71 | # Extract file info from the event and fetch additional metadata from DynamoDB
72 | object_key: str = event.get("detail", {}).get("object", {}).get("key")
73 | object_etag: str = event.get("detail", {}).get("object", {}).get("etag")
74 | image_metadata = get_image_metadata(
75 | dynamodb_client=dynamodb_client,
76 | table_name=FILES_TABLE_NAME,
77 | object_key=object_key,
78 | )
79 | file_id = image_metadata["fileId"]["S"]
80 | user_id = image_metadata["userId"]["S"]
81 |
82 | # Mark file as working, this will notify subscribers that the file is being processed
83 | mark_file_as(file_id, FileStatus.WORKING.value)
84 |
85 | try:
86 | transform_image = TransformImage(
87 | file_id=file_id,
88 | user_id=user_id,
89 | object_key=object_key,
90 | object_etag=object_etag,
91 | )
92 |
93 | new_thumbnail_image = process_thumbnail(transform_image=transform_image)
94 |
95 | mark_file_as(file_id, FileStatus.DONE.value, new_thumbnail_image)
96 | except Exception as exc:
97 | mark_file_as(file_id, FileStatus.FAIL.value)
98 | print("An unexpected error occurred")
99 | raise Exception(exc)
100 |
--------------------------------------------------------------------------------
/functions/python/modules/module1/constants.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | TRANSFORMED_IMAGE_PREFIX = "transformed/image/jpg"
4 | TRANSFORMED_IMAGE_EXTENSION = ".jpeg"
5 |
6 | TRANSFORM_SIZE = {
7 | "SMALL": {"width": 720, "height": 480},
8 | "MEDIUM": {"width": 1280, "height": 720},
9 | "LARGE": {"width": 1920, "height": 1080},
10 | }
11 |
12 | class TransformSize(Enum):
13 | SMALL = {"width": 720, "height": 480}
14 | MEDIUM = {"width": 1280, "height": 720}
15 | LARGE = {"width": 1920, "height": 1080}
16 |
17 |
18 | class FileStatus(Enum):
19 | QUEUED = "queued"
20 | WORKING = "in-progress"
21 | DONE = "completed"
22 | FAIL = "failed"
23 |
--------------------------------------------------------------------------------
/functions/python/modules/module1/graphql.py:
--------------------------------------------------------------------------------
1 | import boto3
2 | import os
3 | from requests_aws_sign import AWSV4Sign
4 | import requests
5 |
6 | graphql_endpoint = os.getenv("APPSYNC_ENDPOINT", "")
7 | REGION_NAME = os.getenv("AWS_REGION", "")
8 |
9 | def mark_file_as(file_id, status, transformed_file_key=None):
10 |
11 | input = {
12 | "id": file_id,
13 | "status": status,
14 | "transformedFileKey": transformed_file_key,
15 | }
16 |
17 | query = """
18 | mutation UpdateFileStatus(
19 | $input: FileStatusUpdateInput!
20 | ) {
21 | updateFileStatus(input: $input){
22 | id
23 | status
24 | transformedFileKey
25 | }
26 | }
27 | """
28 |
29 | session = boto3.session.Session()
30 | credentials = session.get_credentials()
31 |
32 | auth = AWSV4Sign(credentials, REGION_NAME, "appsync")
33 |
34 | payload = {"query": query, "variables": {"input": input}}
35 | headers = {"Content-Type": "application/json"}
36 |
37 | try:
38 | response = requests.post(
39 | graphql_endpoint, auth=auth, json=payload, headers=headers
40 | ).json()
41 | if "errors" in response:
42 | print("Error attempting to query AppSync")
43 | print(response["errors"])
44 | else:
45 | return response
46 | except Exception as exception:
47 | print("Error with Mutation")
48 | print(exception)
49 |
--------------------------------------------------------------------------------
/functions/python/modules/module1/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | aws-lambda-powertools[tracer]
3 | urllib3<2
4 | pillow
5 | requests_aws_sign
6 |
--------------------------------------------------------------------------------
/functions/python/modules/module1/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from PIL import Image
4 | import io
5 |
6 | # Should we add stubs to make easy typing?
7 |
8 |
9 | def extract_file_id(object_key: str) -> str:
10 | return object_key.split("/")[-1].split(".")[0]
11 |
12 |
13 | def get_image_metadata(dynamodb_client, table_name: str, object_key: str):
14 | try:
15 | response = dynamodb_client.get_item(
16 | TableName=table_name,
17 | Key={"id": {"S": extract_file_id(object_key)}},
18 | ProjectionExpression="id, userId",
19 | )
20 |
21 | if "Item" not in response:
22 | raise Exception("File metadata not found")
23 |
24 | return {"fileId": response["Item"]["id"], "userId": response["Item"]["userId"]}
25 | except Exception as e:
26 | raise e
27 |
28 |
29 | def get_original_object(s3_client, object_key: str, bucket_name: str):
30 |
31 | try:
32 | response = s3_client.get_object(Bucket=bucket_name, Key=object_key)
33 | body = response["Body"]
34 |
35 | chunks = []
36 | while True:
37 | data = body.read(1024) # 1kb I think it's ok
38 | if not data:
39 | break
40 | chunks.append(data)
41 | return b"".join(chunks)
42 | except Exception as e:
43 | raise Exception(
44 | f"Error getting file from S3 -> {str(e)}"
45 | ) # Handle or log the exception as needed
46 |
47 |
48 | def create_thumbnail(original_image: bytes, width: int, height: int) -> bytes | None:
49 | try:
50 | # Create a PIL Image object from the image bytes
51 | image = Image.open(io.BytesIO(original_image))
52 |
53 | # Resize the image to the specified width and height
54 | image.thumbnail((width, height))
55 |
56 | # Convert the image to JPEG format and return the bytes
57 | buffer = io.BytesIO()
58 | image.save(buffer, format="JPEG")
59 | return buffer.getvalue()
60 | except Exception as e:
61 | # Handle any exceptions that may occur during image processing
62 | print(f"Error creating thumbnail: {str(e)}")
63 | return None
64 |
65 |
66 | def write_thumbnail_to_s3(
67 | s3_client, object_key: str, bucket_name: str, body: bytes = None
68 | ):
69 | try:
70 | file_body = body
71 | # Upload the object to the specified S3 bucket
72 | s3_client.put_object(Bucket=bucket_name, Key=object_key, Body=file_body)
73 | except Exception as e:
74 | # Handle any exceptions that may occur during the S3 upload
75 | print(f"Error writing to S3: {str(e)}")
76 |
--------------------------------------------------------------------------------
/functions/python/modules/module2/app.py:
--------------------------------------------------------------------------------
1 | import os
2 | import boto3
3 | import json
4 | from aws_lambda_powertools import Logger, Tracer
5 | from aws_lambda_powertools.utilities.typing import LambdaContext
6 | from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import (
7 | DynamoDBRecord,
8 | )
9 | from utils import get_labels, report_image_issue
10 | from exceptions import NoLabelsFoundError, NoPersonFoundError, ImageDetectionError
11 |
12 |
13 | logger = Logger()
14 | tracer = Tracer()
15 |
16 | S3_BUCKET_FILES = os.getenv("BUCKET_NAME_FILES", "")
17 | API_URL_HOST = os.getenv("API_URL_HOST", "")
18 | API_KEY_SECRET_NAME = os.getenv("API_KEY_SECRET_NAME", "")
19 |
20 | secrets = boto3.client("secretsmanager")
21 |
22 | def get_secret_value(secret_id: str):
23 | return secrets.get_secret_value(SecretId=secret_id).get("SecretString")
24 |
25 |
26 | def record_handler(record: DynamoDBRecord):
27 |
28 | # Since we are applying the filter at the DynamoDB Stream level,
29 | # we know that the record has a NewImage otherwise the record would not be here
30 | user_id = record.dynamodb.new_image.get("userId")
31 | transformed_key = record.dynamodb.new_image.get("transformedFileKey")
32 | file_id = record.dynamodb.new_image.get("id")
33 |
34 | try:
35 | get_labels(S3_BUCKET_FILES, file_id, user_id, transformed_key)
36 | except (NoLabelsFoundError, NoPersonFoundError):
37 | logger.warning("No person found in the image")
38 | api_url = json.loads(API_URL_HOST).get("url")
39 | api_key = get_secret_value(API_KEY_SECRET_NAME)
40 | if not api_key:
41 | raise Exception(f"Unable to get secret {api_key}")
42 |
43 | report_image_issue(file_id=file_id, user_id=user_id, api_key=api_key, api_url=api_url)
44 | except ImageDetectionError as error:
45 | logger.error(error)
46 | raise Exception("Error detecting image")
47 |
48 | @tracer.capture_lambda_handler
49 | @logger.inject_lambda_context(log_event=True)
50 | def lambda_handler(event, context: LambdaContext):
51 | for record in event["Records"]:
52 | record_handler(DynamoDBRecord(record))
53 |
--------------------------------------------------------------------------------
/functions/python/modules/module2/app_complete.py:
--------------------------------------------------------------------------------
1 | import os
2 | from aws_lambda_powertools import Logger, Tracer
3 | from aws_lambda_powertools.utilities.typing import LambdaContext
4 | from aws_lambda_powertools.utilities.batch import (
5 | BatchProcessor,
6 | EventType,
7 | process_partial_response,
8 | )
9 | from aws_lambda_powertools.utilities.batch.types import PartialItemFailureResponse
10 | from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import (
11 | DynamoDBRecord,
12 | )
13 | from functions.python.modules.module2.app import API_KEY_SECRET_NAME, API_URL_HOST
14 | from utils import get_labels, report_image_issue
15 | from exceptions import NoLabelsFoundError, NoPersonFoundError, ImageDetectionError
16 | from aws_lambda_powertools.utilities import parameters
17 |
18 |
19 | processor = BatchProcessor(event_type=EventType.DynamoDBStreams)
20 |
21 | logger = Logger()
22 | tracer = Tracer()
23 |
24 | S3_BUCKET_FILES = os.getenv("BUCKET_NAME_FILES", "")
25 | API_URL_PARAMETER_NAME = os.getenv("API_URL_PARAMETER_NAME", "")
26 | API_KEY_SECRET_NAME = os.getenv("API_KEY_SECRET_NAME", "")
27 |
28 |
29 | @tracer.capture_method
30 | def record_handler(record: DynamoDBRecord, lambda_context: LambdaContext):
31 |
32 | if lambda_context.get_remaining_time_in_millis() < 1000:
33 | logger.warning("Invocation is about to time out, marking all remaining records as failed")
34 | raise Exception("Time remaining <1s, marking record as failed to retry later")
35 |
36 | # Since we are applying the filter at the DynamoDB Stream level,
37 | # we know that the record has a NewImage otherwise the record would not be here
38 | user_id = record.dynamodb.new_image.get("userId")
39 | transformed_key = record.dynamodb.new_image.get("transformedFileKey")
40 | file_id = record.dynamodb.new_image.get("id")
41 |
42 | # Add the file id and user id to the logger so that all the logs after this
43 | # will have these attributes and we can correlate them
44 | logger.append_keys(file_id=file_id, user_id=user_id)
45 |
46 | # Add the file id and user id as annotations to the segment so that we can correlate the logs with the traces
47 | tracer.put_annotation("file_id", file_id)
48 | tracer.put_annotation("user_id", user_id)
49 |
50 | with tracer.provider.in_subsegment("## get_labels") as subsegment:
51 | try:
52 | get_labels(S3_BUCKET_FILES, file_id, user_id, transformed_key)
53 | except (NoLabelsFoundError, NoPersonFoundError):
54 | logger.warning("No person found in the image")
55 | # Get the apiUrl and apiKey
56 | # You can replace these with the actual values or retrieve them from a secret manager.
57 | api_url = parameters.get_parameter(API_URL_PARAMETER_NAME, transform="json", max_age=900)["url"]
58 | api_key = parameters.get_secret(API_KEY_SECRET_NAME)
59 | report_image_issue(file_id=file_id, user_id=user_id, api_key=api_key, api_url=api_url)
60 | except ImageDetectionError as error:
61 | subsegment.add_exception(error)
62 | logger.error(error)
63 | finally:
64 | logger.remove_keys(["file_id", "user_id"])
65 |
66 | @tracer.capture_lambda_handler
67 | @logger.inject_lambda_context(log_event=True)
68 | def lambda_handler(event, context: LambdaContext) -> PartialItemFailureResponse:
69 | return process_partial_response(event=event, record_handler=record_handler, processor=processor, context=context)
70 |
--------------------------------------------------------------------------------
/functions/python/modules/module2/exceptions.py:
--------------------------------------------------------------------------------
1 | class ImageDetectionError(Exception):
2 | """
3 | General errors when detecting image
4 | """
5 |
6 |
7 | class NoLabelsFoundError(Exception):
8 | """
9 | No Labels were found
10 | """
11 |
12 |
13 | class NoPersonFoundError(Exception):
14 | """
15 | No Person were found
16 | """
17 |
--------------------------------------------------------------------------------
/functions/python/modules/module2/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | aws-lambda-powertools[tracer]
3 | urllib3<2
4 |
--------------------------------------------------------------------------------
/functions/python/modules/module2/utils.py:
--------------------------------------------------------------------------------
1 | import boto3
2 | from exceptions import NoLabelsFoundError, NoPersonFoundError, ImageDetectionError
3 | import requests
4 | import json
5 | from botocore import errorfactory
6 | from aws_lambda_powertools import Logger, Tracer
7 |
8 |
9 | logger = Logger(level="DEBUG")
10 | tracer = Tracer()
11 |
12 | rekognition_client = boto3.client("rekognition")
13 |
14 | @tracer.capture_method
15 | def get_labels(bucket_name, file_id, user_id, transformed_file_key):
16 | try:
17 | response = rekognition_client.detect_labels(
18 | Image={
19 | "S3Object": {
20 | "Bucket": bucket_name,
21 | "Name": transformed_file_key,
22 | },
23 | }
24 | )
25 |
26 | labels = response["Labels"]
27 |
28 | if not labels or len(labels) == 0:
29 | logger.error("No labels found in image")
30 | raise NoLabelsFoundError
31 |
32 | person_label = next(
33 | (
34 | label
35 | for label in labels
36 | if label.get("Name", "") == "Person" and label.get("Confidence", 0) > 75
37 | ),
38 | None,
39 | )
40 |
41 | if not person_label:
42 | logger.error("No person found in image")
43 | raise NoPersonFoundError
44 |
45 | except errorfactory.ClientError:
46 | raise ImageDetectionError("Object not found in S3")
47 |
48 |
49 | def report_image_issue(file_id: str, user_id: str, api_url: str, api_key: str):
50 | if not api_url or not api_key:
51 | raise Exception(f"Missing apiUrl or apiKey. apiUrl: {api_url}, apiKey: {api_key}")
52 |
53 | # Send the report to the API
54 | headers = {
55 | 'Content-Type': 'application/json',
56 | 'x-api-key': api_key,
57 | }
58 | data = {
59 | 'fileId': file_id,
60 | 'userId': user_id,
61 | }
62 |
63 | logger.debug('Sending report to the API')
64 |
65 | #requests.post(api_url, headers=headers, data=json.dumps(data))
66 |
67 | logger.debug('report sent to the API')
68 |
--------------------------------------------------------------------------------
/functions/python/modules/module3/app.py:
--------------------------------------------------------------------------------
1 | from requests import Response
2 |
3 | from aws_lambda_powertools import Logger, Metrics, Tracer
4 |
5 | from aws_lambda_powertools.event_handler import APIGatewayRestResolver
6 | from aws_lambda_powertools.event_handler.api_gateway import Response, content_types
7 | from aws_lambda_powertools.utilities.typing import LambdaContext
8 |
9 | app = APIGatewayRestResolver()
10 |
11 | logger = Logger()
12 | metrics = Metrics(namespace="workshop-opn301")
13 | tracer = Tracer()
14 |
15 | @app.get("/")
16 | def get_index():
17 | return Response(status_code=200, content_type=content_types.APPLICATION_JSON, body="Hello from module 3")
18 |
19 | @tracer.capture_lambda_handler
20 | @metrics.log_metrics(capture_cold_start_metric=True)
21 | @logger.inject_lambda_context(log_event=True)
22 | def lambda_handler(event: dict, context: LambdaContext) -> dict:
23 | return app.resolve(event, context)
24 |
--------------------------------------------------------------------------------
/functions/python/modules/module3/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | aws-lambda-powertools[tracer]
3 | urllib3<2
4 |
--------------------------------------------------------------------------------
/functions/typescript/api/clean-deleted-files/index.ts:
--------------------------------------------------------------------------------
1 | import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
2 | import { logger } from '@commons/powertools';
3 | import middy from '@middy/core';
4 |
5 | export const handler = middy(async (_event) => {}).use(
6 | injectLambdaContext(logger, { logEvent: true })
7 | );
8 |
--------------------------------------------------------------------------------
/functions/typescript/api/get-presigned-download-url/index.ts:
--------------------------------------------------------------------------------
1 | import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
2 | import { captureLambdaHandler } from '@aws-lambda-powertools/tracer/middleware';
3 | import { requestResponseMetric } from '@middlewares/requestResponseMetric';
4 | import middy from '@middy/core';
5 | import { logger as loggerMain, metrics, tracer } from '@powertools';
6 | import type { AppSyncIdentityCognito, AppSyncResolverEvent } from 'aws-lambda';
7 | import type {
8 | GeneratePresignedDownloadUrlQueryVariables,
9 | PresignedUrl,
10 | } from '../../types/API.js';
11 | import { getFileIdFromStore, getPresignedDownloadUrl } from './utils.js';
12 |
13 | const tableName = process.env.TABLE_NAME_FILES || '';
14 | const indexName = process.env.INDEX_NAME_FILES_BY_USER || '';
15 | const s3BucketFiles = process.env.BUCKET_NAME_FILES || '';
16 | const logger = loggerMain.createChild({
17 | persistentLogAttributes: {
18 | path: 'get-presigned-download-url',
19 | },
20 | });
21 |
22 | export const handler = middy(
23 | async (
24 | event: AppSyncResolverEvent
25 | ): Promise> => {
26 | try {
27 | const { id: fileId } = event.arguments;
28 | if (!fileId) throw new Error('File id not provided.');
29 | const { username: userId } = event.identity as AppSyncIdentityCognito;
30 |
31 | const transformedFileKey = await getFileIdFromStore({
32 | fileId,
33 | userId,
34 | dynamodb: {
35 | tableName,
36 | indexName,
37 | },
38 | });
39 | const downloadUrl = await getPresignedDownloadUrl({
40 | objectKey: transformedFileKey,
41 | bucketName: s3BucketFiles,
42 | });
43 |
44 | logger.debug('file requested for download', {
45 | details: { url: downloadUrl, id: fileId },
46 | });
47 |
48 | return { url: downloadUrl, id: fileId };
49 | } catch (err) {
50 | logger.error('Unable to generate presigned url', err as Error);
51 | throw err;
52 | }
53 | }
54 | )
55 | .use(captureLambdaHandler(tracer))
56 | .use(
57 | requestResponseMetric(metrics, {
58 | graphqlOperation: 'GeneratePresignedDownloadUrlQuery',
59 | })
60 | )
61 | .use(injectLambdaContext(logger, { logEvent: true }));
62 |
--------------------------------------------------------------------------------
/functions/typescript/api/get-presigned-download-url/types.ts:
--------------------------------------------------------------------------------
1 | type GetFileKeyParams = {
2 | fileId: string;
3 | userId: string;
4 | dynamodb: {
5 | tableName: string;
6 | indexName: string;
7 | };
8 | };
9 |
10 | type GetPresignedDownloadUrlParams = {
11 | objectKey: string;
12 | bucketName: string;
13 | };
14 |
15 | export type { GetFileKeyParams, GetPresignedDownloadUrlParams };
16 |
--------------------------------------------------------------------------------
/functions/typescript/api/get-presigned-download-url/utils.ts:
--------------------------------------------------------------------------------
1 | import { GetObjectCommand } from '@aws-sdk/client-s3';
2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3 | import { dynamodbClient } from '@commons/clients/dynamodb';
4 | import { s3Client } from '@commons/clients/s3';
5 | import type { GetFileKeyParams, GetPresignedDownloadUrlParams } from './types';
6 |
7 | /**
8 | * Utility function that given a key and a bucket name returns a presigned download url
9 | */
10 | const getPresignedDownloadUrl = async ({
11 | objectKey,
12 | bucketName,
13 | }: GetPresignedDownloadUrlParams): Promise =>
14 | await getSignedUrl(
15 | s3Client,
16 | new GetObjectCommand({
17 | Bucket: bucketName,
18 | Key: objectKey,
19 | }),
20 | {
21 | expiresIn: 3600,
22 | }
23 | );
24 |
25 | /**
26 | * Utility function that given a file id and a user id returns the file id from Amazon DynamoDB
27 | */
28 | const getFileIdFromStore = async ({
29 | fileId,
30 | userId,
31 | dynamodb,
32 | }: GetFileKeyParams): Promise => {
33 | const res = await dynamodbClient.query({
34 | TableName: dynamodb.tableName,
35 | IndexName: dynamodb.indexName,
36 | KeyConditionExpression: '#id = :id AND #userId = :userId',
37 | FilterExpression: '#status = :status',
38 | ExpressionAttributeNames: {
39 | '#status': 'status',
40 | '#id': 'id',
41 | '#userId': 'userId',
42 | },
43 | ExpressionAttributeValues: {
44 | ':status': 'completed',
45 | ':id': fileId,
46 | ':userId': userId,
47 | },
48 | });
49 | if (!res.Items || res.Items.length === 0)
50 | throw new Error('Unable to find object');
51 |
52 | return res.Items[0].transformedFileKey;
53 | };
54 |
55 | export { getPresignedDownloadUrl, getFileIdFromStore };
56 |
--------------------------------------------------------------------------------
/functions/typescript/api/get-presigned-upload-url/index.ts:
--------------------------------------------------------------------------------
1 | import { randomUUID } from 'node:crypto';
2 | import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
3 | import { captureLambdaHandler } from '@aws-lambda-powertools/tracer/middleware';
4 | import { requestResponseMetric } from '@middlewares/requestResponseMetric';
5 | import middy from '@middy/core';
6 | import { logger as loggerMain, metrics, tracer } from '@powertools';
7 | import type { AppSyncIdentityCognito, AppSyncResolverEvent } from 'aws-lambda';
8 | import type {
9 | GeneratePresignedUploadUrlMutationVariables,
10 | PresignedUrl,
11 | } from '../../types/API.js';
12 | import { getPresignedUploadUrl, storeFileMetadata } from './utils.js';
13 |
14 | const tableName = process.env.TABLE_NAME_FILES || '';
15 | const s3BucketFiles = process.env.BUCKET_NAME_FILES || '';
16 | const logger = loggerMain.createChild({
17 | persistentLogAttributes: {
18 | path: 'get-presigned-upload-url',
19 | },
20 | });
21 |
22 | const getObjectKey = (type: string): string => {
23 | switch (type) {
24 | case 'image/jpeg':
25 | return 'images/jpg';
26 | case 'image/png':
27 | return 'images/png';
28 | default:
29 | return 'other';
30 | }
31 | };
32 |
33 | export const handler = middy(
34 | async (
35 | event: AppSyncResolverEvent
36 | ): Promise> => {
37 | try {
38 | const fileId = randomUUID();
39 | if (!event.arguments.input) {
40 | throw new Error('Invalid input');
41 | }
42 | const { type: fileType } = event.arguments.input;
43 |
44 | const { username: userId } = event.identity as AppSyncIdentityCognito;
45 | const fileTypePrefix = getObjectKey(fileType);
46 | const fileExtension = fileType.split('/')[1];
47 | const objectKey = [
48 | 'uploads',
49 | fileTypePrefix,
50 | `${fileId}.${fileExtension}`,
51 | ].join('/');
52 |
53 | const uploadUrl = await getPresignedUploadUrl({
54 | key: objectKey,
55 | bucketName: s3BucketFiles,
56 | type: fileType,
57 | metadata: {
58 | fileId,
59 | userId,
60 | },
61 | });
62 |
63 | logger.info('File', {
64 | details: { url: uploadUrl, id: fileId },
65 | });
66 |
67 | const response = await storeFileMetadata({
68 | id: fileId,
69 | userId,
70 | key: objectKey,
71 | type: fileType,
72 | dynamodb: {
73 | tableName,
74 | },
75 | });
76 |
77 | logger.debug('[GET presigned-url] DynamoDB response', {
78 | details: response,
79 | });
80 |
81 | return { url: uploadUrl, id: fileId };
82 | } catch (err) {
83 | logger.error('unable to generate presigned url', err as Error);
84 | throw err;
85 | }
86 | }
87 | )
88 | .use(captureLambdaHandler(tracer))
89 | .use(
90 | requestResponseMetric(metrics, {
91 | graphqlOperation: 'GeneratePresignedUploadUrlMutation',
92 | })
93 | )
94 | .use(injectLambdaContext(logger, { logEvent: true }));
95 |
--------------------------------------------------------------------------------
/functions/typescript/api/get-presigned-upload-url/types.ts:
--------------------------------------------------------------------------------
1 | type GetPresignedUploadUrlParams = {
2 | key: string;
3 | bucketName: string;
4 | type: string;
5 | metadata: Record;
6 | };
7 |
8 | type StoreFileMetadataParams = {
9 | id: string;
10 | key: string;
11 | type: string;
12 | userId: string;
13 | transformParams?: string;
14 | dynamodb: {
15 | tableName: string;
16 | };
17 | };
18 |
19 | export type { GetPresignedUploadUrlParams, StoreFileMetadataParams };
20 |
--------------------------------------------------------------------------------
/functions/typescript/api/get-presigned-upload-url/utils.ts:
--------------------------------------------------------------------------------
1 | import { PutObjectCommand } from '@aws-sdk/client-s3';
2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3 | import { dynamodbClient } from '@commons/clients/dynamodb';
4 | import { s3Client } from '@commons/clients/s3';
5 | import type {
6 | GetPresignedUploadUrlParams,
7 | StoreFileMetadataParams,
8 | } from './types';
9 |
10 | /**
11 | * Utility function that given a key and a bucket name returns a presigned upload url
12 | */
13 | const getPresignedUploadUrl = async ({
14 | key,
15 | bucketName,
16 | type,
17 | metadata,
18 | }: GetPresignedUploadUrlParams): Promise =>
19 | getSignedUrl(
20 | s3Client,
21 | new PutObjectCommand({
22 | Bucket: bucketName,
23 | Key: key,
24 | ContentType: type,
25 | Metadata: metadata,
26 | }),
27 | {
28 | expiresIn: 3600,
29 | }
30 | );
31 |
32 | /**
33 | * Utility function that stores file metadata in Amazon DynamoDB
34 | */
35 | const storeFileMetadata = async ({
36 | id,
37 | key,
38 | type,
39 | userId,
40 | dynamodb,
41 | }: StoreFileMetadataParams): Promise => {
42 | await dynamodbClient.put({
43 | TableName: dynamodb.tableName,
44 | Item: {
45 | id,
46 | key,
47 | status: 'created',
48 | type,
49 | userId,
50 | expirationTime: (Math.floor(Date.now() / 1000) + 3600) * 24 * 5, // 5 days
51 | },
52 | });
53 | };
54 |
55 | export { getPresignedUploadUrl, storeFileMetadata };
56 |
--------------------------------------------------------------------------------
/functions/typescript/api/mark-file-queued/index.ts:
--------------------------------------------------------------------------------
1 | import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
2 | import { MetricUnit } from '@aws-lambda-powertools/metrics';
3 | import { logMetrics } from '@aws-lambda-powertools/metrics/middleware';
4 | import { captureLambdaHandler } from '@aws-lambda-powertools/tracer/middleware';
5 | import { FileStatus } from '@constants';
6 | import middy from '@middy/core';
7 | import { logger as loggerMain, metrics, tracer } from '@powertools';
8 | import type { EventBridgeEvent } from 'aws-lambda';
9 | import type { Detail, DetailType } from './types';
10 | import { markFileAs } from './utils';
11 |
12 | const logger = loggerMain.createChild({
13 | persistentLogAttributes: {
14 | path: 'mark-file-queued',
15 | },
16 | });
17 |
18 | const lambdaHandler = async (
19 | event: EventBridgeEvent
20 | ): Promise => {
21 | const {
22 | object: { key: objectKey },
23 | } = event.detail;
24 | const fileName = objectKey.split('/').at(-1);
25 | if (!fileName) {
26 | throw new Error('Invalid file name');
27 | }
28 | const fileId = fileName.split('.')[0];
29 |
30 | await markFileAs(fileId, FileStatus.QUEUED);
31 |
32 | logger.debug('Marked File as queued', {
33 | details: fileId,
34 | });
35 | metrics.addMetric('FilesQueued', MetricUnit.Count, 1);
36 | };
37 |
38 | const handler = middy(lambdaHandler)
39 | .use(captureLambdaHandler(tracer))
40 | .use(injectLambdaContext(logger, { logEvent: true }))
41 | .use(logMetrics(metrics));
42 |
43 | export { handler };
44 |
--------------------------------------------------------------------------------
/functions/typescript/api/mark-file-queued/types.ts:
--------------------------------------------------------------------------------
1 | import type { FileStatus } from '@constants';
2 |
3 | type Detail = {
4 | version: string;
5 | bucket: {
6 | name: string;
7 | };
8 | object: {
9 | key: string;
10 | size: number;
11 | etag: string;
12 | sequencer: string;
13 | };
14 | 'request-id': string;
15 | requester: string;
16 | 'source-ip-address': string;
17 | reason: 'PutObject';
18 | };
19 |
20 | type DetailType = 'Object Created';
21 |
22 | type FileStatusKey = keyof typeof FileStatus;
23 | type FileStatusValue = (typeof FileStatus)[FileStatusKey];
24 |
25 | export type { Detail, DetailType, FileStatusValue };
26 |
--------------------------------------------------------------------------------
/functions/typescript/api/mark-file-queued/utils.ts:
--------------------------------------------------------------------------------
1 | import { makeGraphQlOperation } from '@commons/appsync-signed-operation';
2 | import { updateFileStatus } from '@graphql/mutations';
3 | import type { FileStatusValue } from './types';
4 |
5 | /**
6 | * Utility function to update the status of a given asset.
7 | *
8 | * It takes a fileId and a status and it triggers an AppSync Mutation.
9 | * The mutation has two side effects:
10 | * - Write the new state in the DynamoDB Table
11 | * - Forward the update to any subscribed client (i.e. the frontend app)
12 | *
13 | * @param {string} fileId - The id of the file to update
14 | * @param {FileStatusValue} status - Status of the file after the mutation update
15 | */
16 | const markFileAs = async (
17 | fileId: string,
18 | status: FileStatusValue
19 | ): Promise => {
20 | await makeGraphQlOperation(process.env.APPSYNC_ENDPOINT || '', {
21 | query: updateFileStatus,
22 | operationName: 'UpdateFileStatus',
23 | variables: {
24 | input: {
25 | id: fileId,
26 | status,
27 | },
28 | },
29 | });
30 | };
31 |
32 | export { markFileAs };
33 |
--------------------------------------------------------------------------------
/functions/typescript/commons/appsync-signed-operation.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type BinaryLike,
3 | type Hmac,
4 | type KeyObject,
5 | createHash,
6 | createHmac,
7 | } from 'node:crypto';
8 | import { URL } from 'node:url';
9 | import { HttpRequest } from '@smithy/protocol-http';
10 | import { SignatureV4 } from '@smithy/signature-v4';
11 | import { logger } from './powertools';
12 |
13 | class Sha256 {
14 | private readonly hash: Hmac;
15 |
16 | public constructor(secret?: unknown) {
17 | this.hash = secret
18 | ? createHmac('sha256', secret as BinaryLike | KeyObject)
19 | : createHash('sha256');
20 | }
21 |
22 | public digest(): Promise {
23 | const buffer = this.hash.digest();
24 |
25 | return Promise.resolve(new Uint8Array(buffer.buffer));
26 | }
27 |
28 | public update(array: Uint8Array): void {
29 | this.hash.update(array);
30 | }
31 | }
32 |
33 | const signer = new SignatureV4({
34 | credentials: {
35 | accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
36 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
37 | sessionToken: process.env.AWS_SESSION_TOKEN ?? '',
38 | },
39 | service: 'appsync',
40 | region: process.env.AWS_REGION ?? '',
41 | sha256: Sha256,
42 | });
43 |
44 | const buildHttpRequest = (apiUrl: string, query: unknown): HttpRequest => {
45 | const url = new URL(apiUrl);
46 |
47 | return new HttpRequest({
48 | hostname: url.hostname,
49 | path: url.pathname,
50 | body: JSON.stringify(query),
51 | method: 'POST',
52 | headers: {
53 | 'Content-Type': 'application/json',
54 | host: url.hostname,
55 | },
56 | });
57 | };
58 |
59 | const makeGraphQlOperation = async (
60 | apiUrl: string,
61 | query: unknown
62 | ): Promise> => {
63 | // Build the HTTP request to be signed
64 | const httpRequest = buildHttpRequest(apiUrl, query);
65 | // Sign the request
66 | const signedHttpRequest = await signer.sign(httpRequest);
67 | try {
68 | // Send the request
69 | const result = await fetch(apiUrl, {
70 | headers: new Headers(signedHttpRequest.headers),
71 | body: signedHttpRequest.body,
72 | method: signedHttpRequest.method,
73 | });
74 |
75 | if (!result.ok) throw new Error(result.statusText);
76 |
77 | const body = (await result.json()) as {
78 | data: Record;
79 | errors: { message: string; errorType: string }[];
80 | };
81 |
82 | if (body?.errors) throw new Error(body.errors[0].message);
83 |
84 | return body.data;
85 | } catch (err) {
86 | logger.error('Failed to execute GraphQL operation', err as Error);
87 | throw new Error('Failed to execute GraphQL operation', { cause: err });
88 | }
89 | };
90 |
91 | export { makeGraphQlOperation };
92 |
--------------------------------------------------------------------------------
/functions/typescript/commons/clients/cognito.ts:
--------------------------------------------------------------------------------
1 | import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider';
2 | import { tracer } from '@powertools';
3 |
4 | const cognitoClient = new CognitoIdentityProviderClient({
5 | region: process.env.AWS_REGION || 'eu-central-1',
6 | });
7 | tracer.captureAWSv3Client(cognitoClient);
8 |
9 | export { cognitoClient };
10 |
--------------------------------------------------------------------------------
/functions/typescript/commons/clients/dynamodb.ts:
--------------------------------------------------------------------------------
1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2 | import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
3 |
4 | const dynamodbClient = DynamoDBDocument.from(
5 | new DynamoDBClient({
6 | apiVersion: '2012-08-10',
7 | region: process.env.AWS_REGION || 'eu-central-1',
8 | })
9 | );
10 |
11 | export { dynamodbClient };
12 |
--------------------------------------------------------------------------------
/functions/typescript/commons/clients/eventbridge.ts:
--------------------------------------------------------------------------------
1 | import { EventBridgeClient } from '@aws-sdk/client-eventbridge';
2 | import { tracer } from '@powertools';
3 |
4 | const eventbridgeClient = new EventBridgeClient({
5 | region: process.env.AWS_REGION || 'eu-central-1',
6 | });
7 | tracer.captureAWSv3Client(eventbridgeClient);
8 |
9 | export { eventbridgeClient };
10 |
--------------------------------------------------------------------------------
/functions/typescript/commons/clients/rekognition.ts:
--------------------------------------------------------------------------------
1 | import { RekognitionClient } from '@aws-sdk/client-rekognition';
2 | import { tracer } from '@powertools';
3 |
4 | const rekognitionClient = new RekognitionClient({
5 | region: process.env.AWS_REGION || 'eu-central-1',
6 | });
7 | tracer.captureAWSv3Client(rekognitionClient);
8 |
9 | export { rekognitionClient };
10 |
--------------------------------------------------------------------------------
/functions/typescript/commons/clients/s3.ts:
--------------------------------------------------------------------------------
1 | import { S3Client } from '@aws-sdk/client-s3';
2 |
3 | const s3Client = new S3Client({
4 | apiVersion: '2012-08-10',
5 | region: process.env.AWS_REGION || 'eu-central-1',
6 | });
7 |
8 | export { s3Client };
9 |
--------------------------------------------------------------------------------
/functions/typescript/commons/middlewares/requestResponseMetric.ts:
--------------------------------------------------------------------------------
1 | import type middy from '@middy/core';
2 |
3 | import type { MiddyLikeRequest } from '@aws-lambda-powertools/commons/types';
4 | import type { Metrics } from '@aws-lambda-powertools/metrics';
5 | import { MetricUnit } from '@aws-lambda-powertools/metrics';
6 |
7 | interface Options {
8 | graphqlOperation: string;
9 | }
10 |
11 | const requestResponseMetric = (
12 | metrics: Metrics,
13 | options: Options
14 | ): middy.MiddlewareObj => {
15 | const requestHandler = (request: MiddyLikeRequest): void => {
16 | request.internal = {
17 | ...request.internal,
18 | powertools: {
19 | ...(request.internal.powertools || {}),
20 | startTime: Date.now(),
21 | },
22 | };
23 | };
24 |
25 | const addTimeElapsedMetric = (startTime: number): void => {
26 | const timeElapsed = Date.now() - startTime;
27 | metrics.addMetric('latencyInMs', MetricUnit.Milliseconds, timeElapsed);
28 | };
29 |
30 | const addOperation = (): void => {
31 | metrics.addDimension('graphqlOperation', options.graphqlOperation);
32 | };
33 |
34 | const responseHandler = (request: middy.Request): void => {
35 | metrics.addDimension('httpResponseCode', '200');
36 | const { event, internal } = request;
37 | metrics.addDimension(
38 | 'consumerCountryCode',
39 | event.request.headers['cloudfront-viewer-country'].toString() || 'N/A'
40 | );
41 | addTimeElapsedMetric(internal.powertools.startTime);
42 | addOperation();
43 | metrics.publishStoredMetrics();
44 | };
45 |
46 | const responseErrorHandler = (request: middy.Request): void => {
47 | const { internal } = request;
48 | metrics.addDimension('httpResponseCode', '500');
49 | addTimeElapsedMetric(internal.powertools.startTime);
50 | addOperation();
51 | metrics.publishStoredMetrics();
52 | };
53 |
54 | return {
55 | before: requestHandler,
56 | after: responseHandler,
57 | onError: responseErrorHandler,
58 | };
59 | };
60 |
61 | export { requestResponseMetric };
62 |
--------------------------------------------------------------------------------
/functions/typescript/commons/powertools.ts:
--------------------------------------------------------------------------------
1 | import { PT_VERSION as version } from '@aws-lambda-powertools/commons';
2 | import { Logger } from '@aws-lambda-powertools/logger';
3 | import { Metrics } from '@aws-lambda-powertools/metrics';
4 | import { Tracer } from '@aws-lambda-powertools/tracer';
5 |
6 | const defaultValues = {
7 | awsAccountId: process.env.AWS_ACCOUNT_ID || 'N/A',
8 | environment: process.env.ENVIRONMENT || 'N/A',
9 | };
10 |
11 | const logger = new Logger({
12 | sampleRateValue: 0,
13 | persistentLogAttributes: {
14 | ...defaultValues,
15 | logger: {
16 | name: '@aws-lambda-powertools/logger',
17 | version,
18 | },
19 | },
20 | });
21 |
22 | const metrics = new Metrics({
23 | defaultDimensions: {
24 | ...defaultValues,
25 | commitHash: 'abcdefg12',
26 | appName: 'media-processing-application',
27 | awsRegion: process.env.AWS_REGION || 'N/A',
28 | appVersion: 'v0.0.1',
29 | runtime: process.env.AWS_EXECUTION_ENV || 'N/A',
30 | },
31 | });
32 |
33 | const tracer = new Tracer();
34 |
35 | export { logger, metrics, tracer };
36 |
--------------------------------------------------------------------------------
/functions/typescript/constants.ts:
--------------------------------------------------------------------------------
1 | const ImageSize = {
2 | SMALL: 'small',
3 | MEDIUM: 'medium',
4 | LARGE: 'large',
5 | } as const;
6 |
7 | const FileStatus = {
8 | QUEUED: 'queued',
9 | WORKING: 'in-progress',
10 | DONE: 'completed',
11 | FAIL: 'failed',
12 | } as const;
13 |
14 | const TransformSize = {
15 | [ImageSize.SMALL]: { width: 720, height: 480 },
16 | [ImageSize.MEDIUM]: { width: 1280, height: 720 },
17 | [ImageSize.LARGE]: { width: 1920, height: 1080 },
18 | } as const;
19 |
20 | const transformedImagePrefix = 'transformed/image/jpg';
21 | const transformedImageExtension = '.jpeg';
22 |
23 | export {
24 | ImageSize,
25 | FileStatus,
26 | TransformSize,
27 | transformedImagePrefix,
28 | transformedImageExtension,
29 | };
30 |
--------------------------------------------------------------------------------
/functions/typescript/modules/module1/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FileStatus,
3 | ImageSize,
4 | TransformSize,
5 | transformedImageExtension,
6 | transformedImagePrefix,
7 | } from '@constants';
8 | import middy from '@middy/core';
9 | import type { Context, EventBridgeEvent } from 'aws-lambda';
10 | import { randomUUID } from 'node:crypto';
11 | import type { Detail, DetailType, ProcessOneOptions } from './types.js';
12 | import {
13 | createThumbnail,
14 | getImageMetadata,
15 | getOriginalObject,
16 | markFileAs,
17 | writeTransformedObjectToS3,
18 | } from './utils.js';
19 |
20 | const s3BucketFiles = process.env.BUCKET_NAME_FILES || '';
21 | const filesTableName = process.env.TABLE_NAME_FILES || '';
22 |
23 | const processOne = async ({
24 | objectKey,
25 | }: ProcessOneOptions): Promise => {
26 | const newObjectKey = `${transformedImagePrefix}/${randomUUID()}${transformedImageExtension}`;
27 | // Get the original image from S3
28 | const originalImage = await getOriginalObject(objectKey, s3BucketFiles);
29 | const transform = TransformSize[ImageSize.SMALL];
30 | // Create thumbnail from original image
31 | const processedImage = await createThumbnail({
32 | imageBuffer: originalImage,
33 | width: transform.width,
34 | height: transform.height,
35 | });
36 | // Save the thumbnail on S3
37 | await writeTransformedObjectToS3({
38 | key: newObjectKey,
39 | bucketName: s3BucketFiles,
40 | body: processedImage,
41 | });
42 | console.log(`Saved image on S3: ${newObjectKey}`);
43 |
44 | return newObjectKey;
45 | };
46 |
47 | const lambdaHandler = async (
48 | event: EventBridgeEvent,
49 | context: Context
50 | ): Promise => {
51 | // Extract file info from the event and fetch additional metadata from DynamoDB
52 | const objectKey = event.detail.object.key;
53 | const etag = event.detail.object.etag;
54 | const { fileId, userId } = await getImageMetadata(filesTableName, objectKey);
55 |
56 | // Mark file as working, this will notify subscribers that the file is being processed
57 | await markFileAs(fileId, FileStatus.WORKING);
58 |
59 | try {
60 | const newObjectKey = await processOne({
61 | fileId,
62 | objectKey,
63 | userId,
64 | etag,
65 | });
66 |
67 | await markFileAs(fileId, FileStatus.DONE, newObjectKey);
68 | } catch (error) {
69 | console.error('An unexpected error occurred', error);
70 |
71 | await markFileAs(fileId, FileStatus.FAIL);
72 |
73 | throw error;
74 | }
75 | };
76 |
77 | export const handler = middy(lambdaHandler);
78 |
--------------------------------------------------------------------------------
/functions/typescript/modules/module1/types.ts:
--------------------------------------------------------------------------------
1 | import type { FileStatus } from '@constants';
2 |
3 | /**
4 | * @param {string} key - The key to be used for the new object on S3
5 | * @param {string} bucketName - The bucket name where the file is uploaded
6 | */
7 | interface WriteFileToS3PropsBase {
8 | key: string;
9 | bucketName: string;
10 | }
11 |
12 | /**
13 | * @param {string} pathToFile - The local path where the file is stored
14 | */
15 | interface WriteFileToS3Props extends WriteFileToS3PropsBase {
16 | pathToFile: string;
17 | body?: never;
18 | }
19 |
20 | /**
21 | * @param {Buffer} - The buffer containing the file
22 | */
23 | interface WriteBufferToS3Props extends WriteFileToS3PropsBase {
24 | body: Buffer;
25 | pathToFile?: never;
26 | }
27 |
28 | type Detail = {
29 | version: string;
30 | bucket: {
31 | name: string;
32 | };
33 | object: {
34 | key: string;
35 | size: number;
36 | etag: string;
37 | sequencer: string;
38 | };
39 | 'request-id': string;
40 | requester: string;
41 | 'source-ip-address': string;
42 | reason: 'PutObject';
43 | };
44 |
45 | type DetailType = 'Object Created';
46 |
47 | type FileStatusKey = keyof typeof FileStatus;
48 | type FileStatusValue = (typeof FileStatus)[FileStatusKey];
49 |
50 | type CreateThumbnailParams = {
51 | imageBuffer: Buffer;
52 | width: number;
53 | height: number;
54 | };
55 |
56 | type ProcessOneOptions = {
57 | fileId: string;
58 | objectKey: string;
59 | etag: string;
60 | userId: string;
61 | };
62 |
63 | export type {
64 | Detail,
65 | DetailType,
66 | WriteFileToS3Props,
67 | WriteBufferToS3Props,
68 | FileStatusKey,
69 | FileStatusValue,
70 | CreateThumbnailParams,
71 | ProcessOneOptions,
72 | };
73 |
--------------------------------------------------------------------------------
/functions/typescript/modules/module2/errors.ts:
--------------------------------------------------------------------------------
1 | import type { ImageMetadata } from './types';
2 |
3 | class ImageDetectionError extends Error {
4 | public fileId: string;
5 | public userId: string;
6 |
7 | public constructor(
8 | message: string,
9 | metadata: ImageMetadata,
10 | options?: ErrorOptions
11 | ) {
12 | super(message, options);
13 | this.name = 'ImageDetectionError';
14 | this.fileId = metadata.fileId;
15 | this.userId = metadata.userId;
16 | }
17 | }
18 |
19 | class NoLabelsFoundError extends ImageDetectionError {
20 | public constructor(metadata: ImageMetadata, options?: ErrorOptions) {
21 | super('No labels found in image', metadata, options);
22 | this.name = 'NoLabelsFoundError';
23 | }
24 | }
25 |
26 | class NoPersonFoundError extends ImageDetectionError {
27 | public constructor(metadata: ImageMetadata, options?: ErrorOptions) {
28 | super('No person found in image', metadata, options);
29 | this.name = 'NoPersonFoundError';
30 | }
31 | }
32 |
33 | export { ImageDetectionError, NoLabelsFoundError, NoPersonFoundError };
34 |
--------------------------------------------------------------------------------
/functions/typescript/modules/module2/index.ts:
--------------------------------------------------------------------------------
1 | import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
2 | import { captureLambdaHandler } from '@aws-lambda-powertools/tracer/middleware';
3 | import type { AttributeValue } from '@aws-sdk/client-dynamodb';
4 | import {
5 | GetSecretValueCommand,
6 | SecretsManagerClient,
7 | } from '@aws-sdk/client-secrets-manager';
8 | import { unmarshall } from '@aws-sdk/util-dynamodb';
9 | import { logger, tracer } from '@commons/powertools';
10 | import middy from '@middy/core';
11 | import type { Context, DynamoDBRecord, DynamoDBStreamEvent } from 'aws-lambda';
12 | import { NoLabelsFoundError, NoPersonFoundError } from './errors.js';
13 | import { getLabels, reportImageIssue } from './utils.js';
14 |
15 | const s3BucketFiles = process.env.BUCKET_NAME_FILES || '';
16 | const apiUrlHost = process.env.API_URL_HOST || '';
17 | const apiKeySecretName = process.env.API_KEY_SECRET_NAME || '';
18 |
19 | const secretsClient = new SecretsManagerClient({});
20 |
21 | const getSecret = async (secretName: string): Promise => {
22 | const command = new GetSecretValueCommand({
23 | SecretId: secretName,
24 | });
25 | const response = await secretsClient.send(command);
26 | const secret = response.SecretString;
27 | if (!secret) {
28 | throw new Error(`Unable to get secret ${secretName}`);
29 | }
30 |
31 | return secret;
32 | };
33 |
34 | const recordHandler = async (record: DynamoDBRecord): Promise => {
35 | // Since we are applying the filter at the DynamoDB Stream level,
36 | // we know that the record has a NewImage otherwise the record would not be here
37 | const data = unmarshall(
38 | record.dynamodb?.NewImage as Record
39 | );
40 | const { id: fileId, userId, transformedFileKey } = data;
41 |
42 | try {
43 | // Get the labels from Rekognition
44 | await getLabels(s3BucketFiles, fileId, userId, transformedFileKey);
45 | } catch (error) {
46 | // If no person was found in the image, report the issue to the API for further investigation
47 | if (
48 | error instanceof NoPersonFoundError ||
49 | error instanceof NoLabelsFoundError
50 | ) {
51 | await reportImageIssue(fileId, userId, {
52 | apiUrl: JSON.parse(apiUrlHost).url,
53 | apiKey: await getSecret(apiKeySecretName),
54 | });
55 |
56 | return;
57 | }
58 |
59 | throw error;
60 | }
61 | };
62 |
63 | export const handler = middy(
64 | async (event: DynamoDBStreamEvent, _context: Context): Promise => {
65 | const records = event.Records;
66 |
67 | for (const record of records) {
68 | await recordHandler(record);
69 | }
70 | }
71 | )
72 | .use(captureLambdaHandler(tracer))
73 | .use(injectLambdaContext(logger, { logEvent: true }));
74 |
--------------------------------------------------------------------------------
/functions/typescript/modules/module2/types.ts:
--------------------------------------------------------------------------------
1 | type ImageMetadata = {
2 | userId: string;
3 | fileId: string;
4 | };
5 |
6 | export type { ImageMetadata };
7 |
--------------------------------------------------------------------------------
/functions/typescript/modules/module2/utils.ts:
--------------------------------------------------------------------------------
1 | import { DetectLabelsCommand } from '@aws-sdk/client-rekognition';
2 | import { rekognitionClient } from '@commons/clients/rekognition';
3 | import { tracer } from '@commons/powertools';
4 | import { logger } from '@commons/powertools';
5 | import { NoLabelsFoundError, NoPersonFoundError } from './errors.js';
6 |
7 | const isError = (error: unknown): error is Error => {
8 | return error instanceof Error;
9 | };
10 |
11 | /**
12 | * Utility function that calls the Rekognition API to get the labels of an image.
13 | *
14 | * If the labels **DO NOT** include `Person` or the confidence is **BELOW** 75, it throws an error.
15 | */
16 | const getLabels = async (
17 | bucketName: string,
18 | fileId: string,
19 | userId: string,
20 | transformedFileKey: string
21 | ): Promise => {
22 | const subsegment = tracer.getSegment()?.addNewSubsegment('#### getLabels');
23 | subsegment && tracer.setSegment(subsegment);
24 |
25 | try {
26 | const response = await rekognitionClient.send(
27 | new DetectLabelsCommand({
28 | Image: {
29 | S3Object: {
30 | Bucket: bucketName,
31 | Name: transformedFileKey,
32 | },
33 | },
34 | })
35 | );
36 |
37 | const { Labels: labels } = response;
38 |
39 | if (!labels || labels.length === 0)
40 | throw new NoLabelsFoundError({ fileId, userId });
41 |
42 | const personLabel = labels.find(
43 | (label) =>
44 | ['Person'].includes(label.Name || '') &&
45 | label.Confidence &&
46 | label.Confidence > 75
47 | );
48 | if (!personLabel) throw new NoLabelsFoundError({ fileId, userId });
49 | } catch (error) {
50 | let errorMessage = 'Unable to get labels';
51 | if (
52 | error instanceof NoLabelsFoundError ||
53 | error instanceof NoPersonFoundError
54 | ) {
55 | errorMessage = error.message;
56 | }
57 | if (isError(error)) {
58 | logger.error(errorMessage, error);
59 | subsegment?.addError(error);
60 | }
61 |
62 | throw error;
63 | } finally {
64 | subsegment?.close();
65 | subsegment && tracer.setSegment(subsegment.parent);
66 | }
67 | };
68 |
69 | /**
70 | * Utility function that calls the API to report an image issue.
71 | */
72 | const reportImageIssue = async (
73 | fileId: string,
74 | userId: string,
75 | options: {
76 | apiUrl?: string;
77 | apiKey?: string;
78 | } = {}
79 | ): Promise => {
80 | const { apiUrl, apiKey } = options;
81 | if (!apiUrl || !apiKey) {
82 | throw new Error(
83 | `Missing apiUrl or apiKey. apiUrl: ${apiUrl}, apiKey: ${apiKey}`
84 | );
85 | }
86 |
87 | logger.debug('Sending report to the API');
88 |
89 | // Send the labels to the API
90 | await fetch(apiUrl, {
91 | method: 'POST',
92 | headers: new Headers({
93 | 'Content-Type': 'application/json',
94 | 'x-api-key': apiKey,
95 | }),
96 | body: JSON.stringify({
97 | fileId,
98 | userId,
99 | }),
100 | });
101 |
102 | logger.debug('report sent to the API');
103 | };
104 |
105 | export { getLabels, reportImageIssue };
106 |
--------------------------------------------------------------------------------
/functions/typescript/modules/module3/index.ts:
--------------------------------------------------------------------------------
1 | import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
2 | import { logger } from '@commons/powertools';
3 | import middy from '@middy/core';
4 | import type { APIGatewayProxyEvent } from 'aws-lambda';
5 |
6 | export const handler = middy(
7 | async (
8 | _event: APIGatewayProxyEvent
9 | ): Promise<{ statusCode: number; body: string }> => {
10 | return {
11 | statusCode: 200,
12 | body: JSON.stringify({
13 | message: 'Hello from module 3',
14 | }),
15 | };
16 | }
17 | ).use(injectLambdaContext(logger, { logEvent: true }));
18 |
--------------------------------------------------------------------------------
/functions/typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "functions",
3 | "version": "2.1.0",
4 | "description": "",
5 | "type": "module",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "updatePowertools": "npm i @aws-lambda-powertools/batch@latest @aws-lambda-powertools/idempotency@latest @aws-lambda-powertools/logger@latest @aws-lambda-powertools/metrics@latest @aws-lambda-powertools/parameters@latest @aws-lambda-powertools/tracer@latest"
9 | },
10 | "keywords": [],
11 | "author": {
12 | "name": "Amazon Web Services",
13 | "url": "https://aws.amazon.com"
14 | },
15 | "license": "MIT-0",
16 | "dependencies": {
17 | "@aws-lambda-powertools/batch": "^2.15.0",
18 | "@aws-lambda-powertools/idempotency": "^2.20.0",
19 | "@aws-lambda-powertools/logger": "^2.20.0",
20 | "@aws-lambda-powertools/metrics": "^2.15.0",
21 | "@aws-lambda-powertools/parameters": "^2.15.0",
22 | "@aws-lambda-powertools/tracer": "^2.15.0",
23 | "@aws-sdk/client-api-gateway": "^3.821.0",
24 | "@aws-sdk/client-cognito-identity-provider": "^3.821.0",
25 | "@aws-sdk/client-dynamodb": "^3.821.0",
26 | "@aws-sdk/client-eventbridge": "^3.821.0",
27 | "@aws-sdk/client-rekognition": "^3.821.0",
28 | "@aws-sdk/client-s3": "^3.821.0",
29 | "@aws-sdk/client-secrets-manager": "^3.821.0",
30 | "@aws-sdk/client-ssm": "^3.821.0",
31 | "@aws-sdk/lib-dynamodb": "^3.821.0",
32 | "@aws-sdk/s3-request-presigner": "^3.821.0",
33 | "@aws-sdk/util-dynamodb": "^3.651.0",
34 | "@jimp/jpeg": "^0.22.12",
35 | "@jimp/png": "^0.22.11",
36 | "@middy/core": "^3.6.2",
37 | "@smithy/protocol-http": "^5.0.1",
38 | "@smithy/signature-v4": "^5.0.1",
39 | "jimp": "^1.6.0"
40 | },
41 | "devDependencies": {
42 | "@types/aws-lambda": "^8.10.145"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/functions/typescript/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2022",
4 | "module": "commonjs",
5 | "lib": ["es2022"],
6 | "declaration": true,
7 | "strict": true,
8 | "noImplicitAny": true,
9 | "esModuleInterop": true,
10 | "strictNullChecks": true,
11 | "noImplicitThis": true,
12 | "alwaysStrict": true,
13 | "noUnusedLocals": false,
14 | "noUnusedParameters": false,
15 | "noImplicitReturns": true,
16 | "moduleResolution": "node",
17 | "noFallthroughCasesInSwitch": false,
18 | "inlineSourceMap": true,
19 | "inlineSources": true,
20 | "experimentalDecorators": true,
21 | "strictPropertyInitialization": false,
22 | "allowSyntheticDefaultImports": true,
23 | "typeRoots": ["./node_modules/@types"],
24 | "paths": {
25 | "@constants": ["./constants"],
26 | "@commons/*": ["./commons/*"],
27 | "@powertools": ["./commons/powertools"],
28 | "@middlewares/*": ["./commons/middlewares/*"],
29 | "@graphql/*": ["../../frontend/src/graphql/*"]
30 | }
31 | },
32 | "exclude": ["node_modules"]
33 | }
34 |
--------------------------------------------------------------------------------
/functions/typescript/types/API.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // This file was automatically generated and should not be edited.
4 |
5 | export type PresignedUploadUrlInput = {
6 | type: string;
7 | };
8 |
9 | export type PresignedUrl = {
10 | __typename: 'PresignedUrl';
11 | id: string;
12 | url: string;
13 | };
14 |
15 | export type FileStatusUpdateInput = {
16 | id?: string | null;
17 | status: string;
18 | };
19 |
20 | export type File = {
21 | __typename: 'File';
22 | id?: string | null;
23 | status: string;
24 | };
25 |
26 | export type onUpdateFileStatusFilterInput = {
27 | id?: onUpdateFileStatusStringInput | null;
28 | status?: onUpdateFileStatusStringInput | null;
29 | and?: Array | null;
30 | or?: Array | null;
31 | };
32 |
33 | export type onUpdateFileStatusStringInput = {
34 | ne?: string | null;
35 | eq?: string | null;
36 | le?: string | null;
37 | lt?: string | null;
38 | ge?: string | null;
39 | gt?: string | null;
40 | contains?: string | null;
41 | notContains?: string | null;
42 | between?: Array | null;
43 | beginsWith?: string | null;
44 | in?: Array | null;
45 | notIn?: Array | null;
46 | };
47 |
48 | export type GeneratePresignedUploadUrlMutationVariables = {
49 | input?: PresignedUploadUrlInput | null;
50 | };
51 |
52 | export type GeneratePresignedUploadUrlMutation = {
53 | generatePresignedUploadUrl?: {
54 | __typename: 'PresignedUrl';
55 | id: string;
56 | url: string;
57 | } | null;
58 | };
59 |
60 | export type UpdateFileStatusMutationVariables = {
61 | input?: FileStatusUpdateInput | null;
62 | };
63 |
64 | export type UpdateFileStatusMutation = {
65 | updateFileStatus?: {
66 | __typename: 'File';
67 | id?: string | null;
68 | status: string;
69 | } | null;
70 | };
71 |
72 | export type GeneratePresignedDownloadUrlQueryVariables = {
73 | id: string;
74 | };
75 |
76 | export type GeneratePresignedDownloadUrlQuery = {
77 | generatePresignedDownloadUrl?: {
78 | __typename: 'PresignedUrl';
79 | id: string;
80 | url: string;
81 | } | null;
82 | };
83 |
84 | export type OnUpdateFileStatusSubscriptionVariables = {
85 | filter?: onUpdateFileStatusFilterInput | null;
86 | };
87 |
88 | export type OnUpdateFileStatusSubscription = {
89 | onUpdateFileStatus?: {
90 | __typename: 'File';
91 | id?: string | null;
92 | status: string;
93 | } | null;
94 | };
95 |
--------------------------------------------------------------------------------
/functions/typescript/workshop-services/users-generator/index.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers/promises';
2 | import type { SignUpCommandOutput } from '@aws-sdk/client-cognito-identity-provider';
3 | import { SignUpCommand } from '@aws-sdk/client-cognito-identity-provider';
4 | import type { CloudFormationCustomResourceEvent } from 'aws-lambda';
5 |
6 | import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
7 | import { captureLambdaHandler } from '@aws-lambda-powertools/tracer/middleware';
8 | import { cognitoClient } from '@commons/clients/cognito';
9 | import middy from '@middy/core';
10 | import { logger, tracer } from '@powertools';
11 |
12 | const cognitoUserPoolClientID = process.env.COGNITO_USER_POOL_CLIENT_ID || '';
13 | const dummyPassword = process.env.DUMMY_PASSWORD || '';
14 |
15 | const createUser = async (
16 | email: string,
17 | password: string,
18 | cognitoUserPoolClientID: string
19 | ): Promise => {
20 | try {
21 | return await cognitoClient.send(
22 | new SignUpCommand({
23 | ClientId: cognitoUserPoolClientID,
24 | Password: password,
25 | Username: email,
26 | })
27 | );
28 | } catch (err) {
29 | logger.error('error', err as Error);
30 | throw err;
31 | }
32 | };
33 |
34 | export const handler = middy(
35 | async (event: CloudFormationCustomResourceEvent) => {
36 | if (event.RequestType === 'Create') {
37 | for await (const idx of Array(25).keys()) {
38 | const email = `dummyuser+${idx + 1}@example.com`;
39 | const password = dummyPassword;
40 |
41 | try {
42 | await createUser(email, password, cognitoUserPoolClientID);
43 | } catch (err) {
44 | logger.error('Error while creating the user', err as Error);
45 |
46 | return;
47 | }
48 |
49 | await setTimeout(250); // Simple throttle to ~4 signups / second
50 | }
51 | } else {
52 | return;
53 | }
54 | }
55 | )
56 | .use(captureLambdaHandler(tracer))
57 | .use(injectLambdaContext(logger, { logEvent: true }));
58 |
--------------------------------------------------------------------------------
/infra/.gitignore:
--------------------------------------------------------------------------------
1 | *.js
2 | !jest.config.js
3 | *.d.ts
4 | node_modules
5 |
6 | # CDK asset staging directory
7 | .cdk.staging
8 | cdk.out
9 |
--------------------------------------------------------------------------------
/infra/.npmignore:
--------------------------------------------------------------------------------
1 | *.ts
2 | !*.d.ts
3 |
4 | # CDK asset staging directory
5 | .cdk.staging
6 | cdk.out
7 |
--------------------------------------------------------------------------------
/infra/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your CDK TypeScript project
2 |
3 | This is a blank project for CDK development with TypeScript.
4 |
5 | The `cdk.json` file tells the CDK Toolkit how to execute your app.
6 |
7 | ## Deploying this project
8 |
9 | This project consists of two stacks:
10 |
11 | ### 1. powertoolsworkshopinfra
12 |
13 | This stack is responsible for creating the following resources:
14 |
15 | - AWS Lambda function
16 | - Amazon SQS queue
17 | - Amazon DynamoDB table
18 | - Amazon S3 bucket
19 | - Frontend application
20 |
21 | To deploy this stack, run the following command:
22 |
23 | `cdk deploy powertoolsworkshopinfra`
24 |
25 | ### 2. powertoolsworkshopide
26 |
27 | This stack is responsible for creating a Visual Studio Code (VSCode) instance in the browser, preconfigured with all the necessary requirements.
28 |
29 | To deploy this stack, run the following command:
30 |
31 | `cdk deploy powertoolsworkshopide`
32 |
33 | #### Setting a Custom Password
34 |
35 | The default password for the VSCode online instance is `powertools-workshop`. If you want to set a custom password, you can pass it as a parameter using the following command:
36 |
37 | `cdk deploy powertoolsworkshopide --parameters vscodePasswordParameter=YOUR_CUSTOM_PASSWORD`
38 |
39 | Replace `YOUR_CUSTOM_PASSWORD` with the desired password.
40 |
41 | ## Useful commands
42 |
43 | * `npm run build` compile typescript to js
44 | * `npm run watch` watch for changes and compile
45 | * `npm run test` perform the jest unit tests
46 | * `cdk deploy` deploy this stack to your default AWS account/region
47 | * `cdk diff` compare deployed stack with current state
48 | * `cdk synth` emits the synthesized CloudFormation template
49 |
--------------------------------------------------------------------------------
/infra/cdk.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": "tsx infra.ts",
3 | "watch": {
4 | "include": ["**"],
5 | "exclude": [
6 | "README.md",
7 | "cdk*.json",
8 | "**/*.d.ts",
9 | "**/*.js",
10 | "tsconfig.json",
11 | "package*.json",
12 | "yarn.lock",
13 | "node_modules",
14 | "test"
15 | ]
16 | },
17 | "context": {
18 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
19 | "@aws-cdk/core:stackRelativeExports": true,
20 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
21 | "@aws-cdk/aws-lambda:recognizeVersionProps": true,
22 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true,
23 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
24 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
25 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
26 | "@aws-cdk/core:checkSecretUsage": true,
27 | "@aws-cdk/aws-iam:minimizePolicies": true,
28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
29 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true,
30 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
31 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
32 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
33 | "@aws-cdk/core:target-partitions": ["aws", "aws-cn"]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/infra/infra.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import 'source-map-support/register';
3 | import {
4 | App,
5 | Aspects,
6 | type CfnResource,
7 | RemovalPolicy,
8 | type IAspect,
9 | } from 'aws-cdk-lib';
10 | import { AwsSolutionsChecks } from 'cdk-nag';
11 | import { environment, powertoolsServiceName } from './lib/constants.js';
12 | import { IdeStack } from './lib/ide-stack.js';
13 | import { InfraStack } from './lib/infra-stack.js';
14 | import type { IConstruct } from 'constructs';
15 |
16 | const app = new App();
17 | const isCI = app.node.tryGetContext('CI') === 'true';
18 | if (isCI) {
19 | console.log('Running in CI/CD mode');
20 | Aspects.of(app).add(new AwsSolutionsChecks());
21 | }
22 |
23 | class RetentionPolicyDestroySetter implements IAspect {
24 | public visit(node: IConstruct) {
25 | try {
26 | (node.node.defaultChild as CfnResource).applyRemovalPolicy(
27 | RemovalPolicy.DESTROY
28 | );
29 | } catch {}
30 | }
31 | }
32 | Aspects.of(app).add(new RetentionPolicyDestroySetter());
33 | new InfraStack(app, 'powertoolsworkshopinfra', {
34 | description: '(uksb-yso2t7jeel) (tag:powertoolsworkshopinfra)',
35 | tags: {
36 | Service: powertoolsServiceName,
37 | Environment: environment,
38 | ManagedBy: 'CDK',
39 | GithubRepo: 'aws-samples/powertools-for-aws-lambda-workshop',
40 | Owner: 'AWS',
41 | AwsRegion: process.env.CDK_DEFAULT_REGION || 'N/A',
42 | AwsAccountId: process.env.CDK_DEFAULT_ACCOUNT || 'N/A',
43 | },
44 | });
45 | new IdeStack(app, 'powertoolsworkshopide', {
46 | description: '(uksb-yso2t7jeel) (tag:powertoolsworkshopide)',
47 | tags: {
48 | Service: powertoolsServiceName,
49 | Environment: environment,
50 | ManagedBy: 'CDK',
51 | GithubRepo: 'aws-samples/powertools-for-aws-lambda-workshop',
52 | Owner: 'AWS',
53 | AwsRegion: process.env.CDK_DEFAULT_REGION || 'N/A',
54 | AwsAccountId: process.env.CDK_DEFAULT_ACCOUNT || 'N/A',
55 | },
56 | });
57 |
--------------------------------------------------------------------------------
/infra/lib/attendant-ide/completion-construct.ts:
--------------------------------------------------------------------------------
1 | import { type StackProps, CustomResource, Duration } from 'aws-cdk-lib';
2 | import { Provider } from 'aws-cdk-lib/custom-resources';
3 | import { Construct } from 'constructs';
4 | import { Runtime, Function, Code } from 'aws-cdk-lib/aws-lambda';
5 | import { customSecurityHeader, customSecurityHeaderValue } from './constants.js';
6 | import { RetentionDays } from 'aws-cdk-lib/aws-logs';
7 | import { NagSuppressions } from 'cdk-nag';
8 |
9 | interface CompletionConstructProps extends StackProps {
10 | /**
11 | * The healthcheck endpoint to check for availability.
12 | */
13 | healthCheckEndpoint: string;
14 | }
15 |
16 | export class CompletionConstruct extends Construct {
17 | public constructor(
18 | scope: Construct,
19 | id: string,
20 | props: CompletionConstructProps
21 | ) {
22 | super(scope, id);
23 |
24 | const { healthCheckEndpoint } = props;
25 |
26 | const isAvailableHandler = new Function(this, 'is-available', {
27 | runtime: Runtime.NODEJS_22_X,
28 | handler: 'index.handler',
29 | logRetention: RetentionDays.ONE_DAY,
30 | timeout: Duration.seconds(30),
31 | code: Code.fromInline(`/* global fetch */
32 | exports.handler = async () => {
33 | // make request to ide healthcheck endpoint
34 | // return true if 200, false otherwise
35 | try {
36 | const res = await fetch(process.env.IDE_HEALTHCHECK_ENDPOINT, {
37 | headers: {
38 | [process.env.CUSTOM_SECURITY_HEADER]:
39 | process.env.CUSTOM_SECURITY_HEADER_VALUE,
40 | },
41 | method: 'GET',
42 | });
43 | if (res.ok === false) {
44 | return { IsComplete: false };
45 | }
46 |
47 | return { IsComplete: true };
48 | } catch (err) {
49 | console.error(err);
50 |
51 | return { IsComplete: false };
52 | }
53 | };`),
54 | environment: {
55 | IDE_HEALTHCHECK_ENDPOINT: healthCheckEndpoint,
56 | CUSTOM_SECURITY_HEADER: customSecurityHeader,
57 | CUSTOM_SECURITY_HEADER_VALUE: customSecurityHeaderValue,
58 | },
59 | });
60 |
61 | const checkIdAvailabilityProvider = new Provider(
62 | this,
63 | 'check-id-availability-provider',
64 | {
65 | onEventHandler: new Function(this, 'no-op-handler', {
66 | runtime: Runtime.NODEJS_22_X,
67 | handler: 'index.handler',
68 | logRetention: RetentionDays.ONE_DAY,
69 | timeout: Duration.seconds(5),
70 | code: Code.fromInline('exports.handler = async () => true;'),
71 | }),
72 | isCompleteHandler: isAvailableHandler,
73 | totalTimeout: Duration.minutes(15),
74 | queryInterval: Duration.seconds(5),
75 | logRetention: RetentionDays.ONE_DAY,
76 | }
77 | );
78 |
79 | new CustomResource(this, 'Custom:IdeAvailability', {
80 | serviceToken: checkIdAvailabilityProvider.serviceToken,
81 | });
82 |
83 | NagSuppressions.addResourceSuppressions(
84 | this,
85 | [
86 | {
87 | id: 'AwsSolutions-IAM5',
88 | reason:
89 | 'This resource is managed by CDK and used to create custom resources. This is run only a handful of times during deployment.',
90 | },
91 | {
92 | id: 'AwsSolutions-IAM4',
93 | reason:
94 | 'This resource is managed by CDK and used to create custom resources. This is run only a handful of times during deployment.',
95 | },
96 | {
97 | id: 'AwsSolutions-L1',
98 | reason:
99 | 'This resource is managed by CDK and used to create custom resources. This is run only a handful of times during deployment.',
100 | },
101 | ],
102 | true
103 | );
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/infra/lib/attendant-ide/constants.ts:
--------------------------------------------------------------------------------
1 | // Header expected by the load balancer to allow traffic to the instance from the distribution
2 | const customSecurityHeader = 'X-VscodeServer';
3 | const customSecurityHeaderValue = 'PowertoolsForAWS';
4 |
5 | // Port where the IDE will be served from
6 | const idePort = '8080';
7 |
8 | // Languages
9 | const nodeVersion = 'v18';
10 | const pythonVersion = '3.11.0';
11 | const javaVersion = 'java-17-amazon-corretto-headless';
12 | const dotNetRepo =
13 | 'https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm';
14 | const dotNetVersion = ['aspnetcore-runtime-8.0', 'dotnet-sdk-8.0'].join(' ');
15 |
16 | // OS Packages
17 | const osPackages = [
18 | // General
19 | 'git',
20 | 'docker',
21 | 'jq',
22 | 'zsh',
23 | 'util-linux-user',
24 | 'gcc',
25 | 'make',
26 | 'gcc-c++',
27 | 'libunwind',
28 | 'unzip',
29 | 'zip',
30 | // Python requirements
31 | 'zlib',
32 | 'zlib-devel',
33 | 'openssl-devel',
34 | 'ncurses-devel',
35 | 'readline-devel',
36 | 'bzip2-devel',
37 | 'libffi-devel',
38 | 'sqlite-devel',
39 | 'xz-devel',
40 | // Java requirements
41 | javaVersion,
42 | // .NET requirements
43 | dotNetVersion,
44 | ];
45 |
46 | const vscodeAccessCode = "powertools-workshop"
47 |
48 | const whoamiUser = 'ec2-user';
49 | const workshopRepo = 'aws-samples/powertools-for-aws-lambda-workshop';
50 | const zshrcTemplateUrl = `https://raw.githubusercontent.com/${workshopRepo}/main/infra/lib/attendant-ide/zshrc-sample.txt`;
51 | const workshopDirectory = 'workshop';
52 |
53 | export {
54 | customSecurityHeader,
55 | customSecurityHeaderValue,
56 | idePort,
57 | nodeVersion,
58 | pythonVersion,
59 | javaVersion,
60 | dotNetRepo,
61 | dotNetVersion,
62 | osPackages,
63 | whoamiUser,
64 | workshopRepo,
65 | zshrcTemplateUrl,
66 | workshopDirectory,
67 | vscodeAccessCode
68 | };
69 |
--------------------------------------------------------------------------------
/infra/lib/attendant-ide/index.ts:
--------------------------------------------------------------------------------
1 | export { NetworkConstruct } from './network-construct.js';
2 | export { ComputeConstruct } from './compute-construct.js';
3 | export { DistributionConstruct } from './distribution-construct.js';
4 | export { CompletionConstruct } from './completion-construct.js';
5 | export { RandomPasswordConstruct } from './random-password-construct.js';
--------------------------------------------------------------------------------
/infra/lib/attendant-ide/network-construct.ts:
--------------------------------------------------------------------------------
1 | import { type StackProps } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import {
4 | Vpc,
5 | SubnetType,
6 | FlowLogDestination,
7 | FlowLogTrafficType,
8 | } from 'aws-cdk-lib/aws-ec2';
9 | import {
10 | ApplicationLoadBalancer,
11 | ApplicationProtocol,
12 | ListenerAction,
13 | ListenerCondition,
14 | Protocol,
15 | } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
16 | import { type InstanceIdTarget } from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
17 | import {
18 | customSecurityHeader,
19 | customSecurityHeaderValue,
20 | idePort,
21 | } from './constants.js';
22 | import { NagSuppressions } from 'cdk-nag';
23 |
24 | interface NetworkConstructProps extends StackProps {}
25 |
26 | export class NetworkConstruct extends Construct {
27 | public readonly vpc: Vpc;
28 | public loadBalancer?: ApplicationLoadBalancer;
29 |
30 | public constructor(
31 | scope: Construct,
32 | id: string,
33 | _props: NetworkConstructProps
34 | ) {
35 | super(scope, id);
36 |
37 | this.vpc = new Vpc(this, 'VPC', {
38 | maxAzs: 2,
39 | subnetConfiguration: [
40 | {
41 | cidrMask: 24,
42 | name: 'Public',
43 | subnetType: SubnetType.PUBLIC,
44 | },
45 | {
46 | cidrMask: 24,
47 | name: 'Private',
48 | subnetType: SubnetType.PRIVATE_WITH_EGRESS,
49 | },
50 | ],
51 | flowLogs: {
52 | VPCFlowLogs: {
53 | destination: FlowLogDestination.toCloudWatchLogs(),
54 | trafficType: FlowLogTrafficType.REJECT,
55 | },
56 | },
57 | });
58 | }
59 |
60 | public createLoadBalancerWithInstanceEc2Target(
61 | target: InstanceIdTarget
62 | ): ApplicationLoadBalancer {
63 | this.loadBalancer = new ApplicationLoadBalancer(this, 'vscode-lb', {
64 | vpc: this.vpc,
65 | internetFacing: true,
66 | });
67 |
68 | const listener = this.loadBalancer.addListener('vscode-listener', {
69 | port: 80,
70 | protocol: ApplicationProtocol.HTTP,
71 | });
72 |
73 | listener.addTargets('vscode-target', {
74 | port: 80,
75 | targets: [target],
76 | healthCheck: {
77 | path: '/healthz',
78 | port: idePort,
79 | protocol: Protocol.HTTP,
80 | },
81 | priority: 10,
82 | conditions: [
83 | ListenerCondition.httpHeader(customSecurityHeader, [
84 | customSecurityHeaderValue,
85 | ]),
86 | ],
87 | });
88 | listener.addAction('vscode-redirect', {
89 | action: ListenerAction.fixedResponse(403, {
90 | messageBody: 'Forbidden',
91 | }),
92 | });
93 |
94 | NagSuppressions.addResourceSuppressions(
95 | this.loadBalancer,
96 | [
97 | {
98 | id: 'AwsSolutions-ELB2',
99 | reason:
100 | 'This load balancer is used to provide access to the IDE for the duration of the workshop. For production usages, consider enabling access logs.',
101 | },
102 | {
103 | id: 'AwsSolutions-EC23',
104 | reason:
105 | 'This load balancer is used to provide access to the IDE for the duration of the workshop and the source IP address of the attendant is not known beforehand. For production usages, narrowing down the CIDR for inboud.',
106 | },
107 | ],
108 | true
109 | );
110 |
111 | return this.loadBalancer;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/infra/lib/attendant-ide/random-password-construct.ts:
--------------------------------------------------------------------------------
1 | import { type StackProps, CfnOutput, CustomResource, Duration, RemovalPolicy, Token } from 'aws-cdk-lib';
2 | import { Provider } from 'aws-cdk-lib/custom-resources';
3 | import { Construct } from 'constructs';
4 | import { Runtime, Function as LambdaFunction, Code } from 'aws-cdk-lib/aws-lambda';
5 | import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
6 | import { NagSuppressions } from 'cdk-nag';
7 |
8 | interface RandomPasswordConstructProps extends StackProps {}
9 |
10 | export class RandomPasswordConstruct extends Construct {
11 | public readonly randomPassword: string;
12 |
13 | public constructor(
14 | scope: Construct,
15 | id: string,
16 | _props: RandomPasswordConstructProps
17 | ) {
18 | super(scope, id);
19 |
20 | const resourcePrefix = 'rand-pass';
21 | const randomPasswordGeneratorProvider = new Provider(
22 | this,
23 | `${resourcePrefix}-provider`,
24 | {
25 | onEventHandler: new LambdaFunction(this, `${resourcePrefix}-fn`, {
26 | runtime: Runtime.NODEJS_22_X,
27 | handler: 'index.handler',
28 | logGroup: new LogGroup(this, `${resourcePrefix}-fn-log`, {
29 | removalPolicy: RemovalPolicy.DESTROY,
30 | retention: RetentionDays.ONE_DAY,
31 | }),
32 | timeout: Duration.seconds(10),
33 | code: Code.fromInline(`const { randomUUID } = require('crypto');
34 | exports.handler = async () => {
35 | return {
36 | Data: { RandomPassword: randomUUID() },
37 | };
38 | };`),
39 | }),
40 | logGroup: new LogGroup(this, `${resourcePrefix}-provider-log`, {
41 | removalPolicy: RemovalPolicy.DESTROY,
42 | retention: RetentionDays.ONE_DAY,
43 | }),
44 | }
45 | );
46 |
47 | const randomPaswordGenerator = new CustomResource(
48 | this,
49 | 'Custom:RandomPasswordForIDE',
50 | {
51 | serviceToken: randomPasswordGeneratorProvider.serviceToken,
52 | }
53 | );
54 |
55 | this.randomPassword = Token.asString(
56 | randomPaswordGenerator.getAtt('RandomPassword')
57 | );
58 |
59 | new CfnOutput(this, 'RandomPassword', {
60 | value: this.randomPassword,
61 | description: 'Password for VSCode Web IDE',
62 | })
63 |
64 | NagSuppressions.addResourceSuppressions(
65 | this,
66 | [
67 | {
68 | id: 'AwsSolutions-IAM5',
69 | reason:
70 | 'This resource is managed by CDK and used to create custom resources. This is run only a handful of times during deployment.',
71 | },
72 | {
73 | id: 'AwsSolutions-IAM4',
74 | reason:
75 | 'This resource is managed by CDK and used to create custom resources. This is run only a handful of times during deployment.',
76 | },
77 | {
78 | id: 'AwsSolutions-L1',
79 | reason:
80 | 'This resource is managed by CDK and used to create custom resources. This is run only a handful of times during deployment.',
81 | },
82 | ],
83 | true
84 | );
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/infra/lib/attendant-ide/zshrc-sample.txt:
--------------------------------------------------------------------------------
1 | # If you come from bash you might have to change your $PATH.
2 | # export PATH=$HOME/bin:/usr/local/bin:$PATH
3 |
4 | # Path to your oh-my-zsh installation.
5 | export ZSH="$HOME/.oh-my-zsh"
6 |
7 | # See https://github.com/ohmyzsh/ohmyzsh/wiki/Themes
8 | ZSH_THEME="refined"
9 |
10 | plugins=(
11 | git
12 | docker
13 | )
14 |
15 | source $ZSH/oh-my-zsh.sh
16 |
17 | # User configuration
18 |
19 | # Pyenv
20 | export PYENV_ROOT="$HOME/.pyenv"
21 | command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
22 | eval "$(pyenv init -)"
23 |
24 | # docker
25 | # advanced completion, remove if docker plugin is removed.
26 | zstyle ':completion:*:*:docker:*' option-stacking yes
27 | zstyle ':completion:*:*:docker-*:*' option-stacking yes
28 |
29 | # fnm
30 | export PATH="/home/ec2-user/.local/share/fnm:$PATH"
31 | eval "`fnm env`"
32 |
33 | # .NET
34 | export DOTNET_ROOT=/home/ec2-user/.dotnet
35 | export PATH="$DOTNET_ROOT:$DOTNET_ROOT/tools:$PATH"
36 |
37 | # AWS Region & Account ID (populated at deploy time)
--------------------------------------------------------------------------------
/infra/lib/constants.ts:
--------------------------------------------------------------------------------
1 | import { BundlingOptions, BundlingOutput, Duration } from 'aws-cdk-lib';
2 | import { RetentionDays } from 'aws-cdk-lib/aws-logs';
3 | import { Runtime, Tracing, FunctionProps } from 'aws-cdk-lib/aws-lambda';
4 | import { BundlingOptions as NodeBundlingOptions } from 'aws-cdk-lib/aws-lambda-nodejs';
5 | import os from 'os';
6 |
7 | export const environment =
8 | process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
9 |
10 | export const dynamoFilesTableName = `media-processing-app-files-${environment}`;
11 | export const dynamoFilesByUserGsiName = 'filesByUserIndex';
12 |
13 | export const websiteBucketNamePrefix = 'website';
14 | export const landingZoneBucketNamePrefix = 'media-files';
15 |
16 | export const powertoolsServiceName = 'media-processing-app';
17 | export const powertoolsLoggerLogLevel =
18 | process.env.NODE_ENV === 'production' ? 'WARN' : 'DEBUG';
19 | export const powertoolsLoggerSampleRate =
20 | process.env.NODE_ENV === 'production' ? '0.1' : '1';
21 | export const powertoolsMetricsNamespace = 'AnyCompany'; // Dummy company name
22 |
23 | export const trafficGeneratorIntervalInMinutes = 1;
24 |
25 | export const commonFunctionSettings: Partial = {
26 | runtime: Runtime.NODEJS_22_X,
27 | tracing: Tracing.ACTIVE,
28 | logRetention: RetentionDays.FIVE_DAYS,
29 | timeout: Duration.seconds(30),
30 | handler: 'handler',
31 | memorySize: 256,
32 | };
33 |
34 | export const commonNodeJsBundlingSettings: Partial = {
35 | minify: true,
36 | sourceMap: true,
37 | externalModules: ['@aws-sdk/*'],
38 | mainFields: ['module', 'main'],
39 | esbuildArgs: {
40 | '--tree-shaking': true,
41 | },
42 | metafile: true,
43 | };
44 |
45 | export const commonJavaFunctionSettings: Partial = {
46 | runtime: Runtime.JAVA_21,
47 | tracing: Tracing.ACTIVE,
48 | logRetention: RetentionDays.FIVE_DAYS,
49 | timeout: Duration.seconds(30),
50 | memorySize: 512,
51 | };
52 |
53 | export const commonJavaBundlingSettings: BundlingOptions = {
54 | image: Runtime.JAVA_21.bundlingImage,
55 | command: [
56 | '/bin/sh',
57 | '-c',
58 | 'mvn package && cp /asset-input/target/function.jar /asset-output/',
59 | ],
60 | user: 'root',
61 | outputType: BundlingOutput.ARCHIVED,
62 | volumes: [
63 | {
64 | hostPath: `${os.homedir()}/.m2'`,
65 | containerPath: '/root/.m2/',
66 | },
67 | ],
68 | };
69 |
70 | export const commonDotnetBundlingSettings: BundlingOptions = {
71 | image: Runtime.DOTNET_8.bundlingImage,
72 | user: 'root',
73 | outputType: BundlingOutput.ARCHIVED,
74 | command: [
75 | '/bin/sh',
76 | '-c',
77 | ' dotnet tool install -g Amazon.Lambda.Tools' +
78 | ' && dotnet build' +
79 | ' && dotnet lambda package --output-package /asset-output/function.zip',
80 | ],
81 | };
82 |
83 | export const commonEnvVars = {
84 | ENVIRONMENT: environment,
85 | // Powertools environment variables
86 | POWERTOOLS_SERVICE_NAME: powertoolsServiceName,
87 | POWERTOOLS_LOGGER_LOG_LEVEL: powertoolsLoggerLogLevel,
88 | POWERTOOLS_LOGGER_SAMPLE_RATE: powertoolsLoggerSampleRate,
89 | POWERTOOLS_LOGGER_LOG_EVENT: 'TRUE',
90 | POWERTOOLS_METRICS_NAMESPACE: powertoolsMetricsNamespace,
91 | NODE_OPTIONS: '--enable-source-maps',
92 | };
93 |
94 | const Language = {
95 | NodeJS: 'nodejs',
96 | DotNet: 'dotnet',
97 | Python: 'python',
98 | Java: 'java',
99 | } as const;
100 |
101 | export type Language = (typeof Language)[keyof typeof Language];
102 |
--------------------------------------------------------------------------------
/infra/lib/content-hub-repository/README.md:
--------------------------------------------------------------------------------
1 | # Content Hub Repository Service
2 |
3 | This service is responsible exposing a GraphQL API that allows to upload and download files to/from the media application. The service is also responsible for storing the image assets in a S3 bucket. Whenever an object lands in the S3 bucket, a message is published to an EventBridge event bus that triggers a Lambda function that will mark the object as uploaded. Other services can subscribe to this event bus to be notified when an object is uploaded to perform additional actions.
4 |
5 | ## Upload a new image
6 |
7 | The following section describes the flow and components involved in uploading a new image to the media application.
8 |
9 | 
10 |
11 | 1. The client sends a GraphQL mutation to the Content Hub Repository service to upload a new image.
12 | 2. The AppSync service invokes a Lambda function resolver that generates a pre-signed URL to upload the image to the S3 bucket.
13 | 3. The same Lambda function resolver also stores the image metadata in a DynamoDB table.
14 | 4. The client receives the pre-signed URL that allows to upload the image directly to the S3 bucket.
15 | 5. The client starts a GraphQL subscription to the Content Hub Repository service to be notified about status changes of the image.
16 | 6. The client uploads the image to the S3 bucket using the pre-signed URL.
17 | 7. The S3 bucket publishes a message to an EventBridge event bus to notify that a new object has been uploaded.
18 | 8. The Lambda function subscribed to the EventBridge event bus is invoked and processes the event.
19 | 9. The same Lambda function sends a GraphQL mutation to the Content Hub Repository service to mark the image as uploaded.
20 | 10. The AppSync service processes the mutation and:
21 | - a. Updates the image status in the DynamoDB table.
22 | - b. Propagates the mutation to all the clients subscribed to the image status change.
23 |
24 | ## Download an image
25 |
26 | This section describes the flow and components involved in downloading an image from the media application.
27 |
28 | 
29 |
30 | 1. The client sends a GraphQL query to the Content Hub Repository service to download an image.
31 | 2. The AppSync service invokes a Lambda function resolver that generates a pre-signed URL to download the image from the S3 bucket.
32 | 3. The same Lambda function resolve also checks the image status and ownership in the DynamoDB table.
33 | 4. The client receives the pre-signed URL that allows to download the image directly from the S3 bucket.
34 | 5. The client downloads the image from the S3 bucket using the pre-signed URL.
--------------------------------------------------------------------------------
/infra/lib/content-hub-repository/index.ts:
--------------------------------------------------------------------------------
1 | import { Construct } from 'constructs';
2 | import { IUserPool } from 'aws-cdk-lib/aws-cognito';
3 | import { Rule, Match } from 'aws-cdk-lib/aws-events';
4 | import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
5 | import { ApiConstruct } from './api-construct.js';
6 | import { FunctionsConstruct } from './functions-construct.js';
7 | import { StorageConstruct } from './storage-construct.js';
8 |
9 | interface ContentHubRepoProps {
10 | userPool: IUserPool;
11 | }
12 |
13 | export class ContentHubRepo extends Construct {
14 | public readonly api: ApiConstruct;
15 | public readonly functions: FunctionsConstruct;
16 | public readonly storage: StorageConstruct;
17 |
18 | public constructor(scope: Construct, id: string, props: ContentHubRepoProps) {
19 | super(scope, id);
20 |
21 | const { userPool } = props;
22 |
23 | this.storage = new StorageConstruct(this, 'storage-construct', {});
24 |
25 | this.functions = new FunctionsConstruct(this, 'functions-construct', {});
26 |
27 | this.storage.grantReadWriteDataOnTable(
28 | this.functions.getPresignedUploadUrlFn
29 | );
30 | this.storage.grantPutOnBucket(this.functions.getPresignedUploadUrlFn);
31 | this.storage.grantReadDataOnTable(this.functions.getPresignedDownloadUrlFn);
32 | this.storage.grantGetOnBucket(this.functions.getPresignedDownloadUrlFn);
33 | this.storage.grantReadWriteDataOnTable(this.functions.cleanDeletedFilesFn);
34 |
35 | this.api = new ApiConstruct(this, 'api-construct', {
36 | getPresignedUploadUrlFn: this.functions.getPresignedUploadUrlFn,
37 | getPresignedDownloadUrlFn: this.functions.getPresignedDownloadUrlFn,
38 | userPool: userPool,
39 | table: this.storage.filesTable,
40 | });
41 | this.api.api.grantMutation(
42 | this.functions.markCompleteUploadFn,
43 | 'updateFileStatus'
44 | );
45 | this.functions.markCompleteUploadFn.addEnvironment(
46 | 'APPSYNC_ENDPOINT',
47 | `https://${this.api.domain}/graphql`
48 | );
49 |
50 | const uploadedRule = new Rule(this, 'new-uploads', {
51 | eventPattern: {
52 | source: Match.anyOf('aws.s3'),
53 | detailType: Match.anyOf('Object Created'),
54 | detail: {
55 | bucket: {
56 | name: Match.anyOf(this.storage.landingZoneBucket.bucketName),
57 | },
58 | object: { key: Match.prefix('uploads/images/') },
59 | reason: Match.anyOf('PutObject'),
60 | },
61 | },
62 | });
63 | uploadedRule.addTarget(
64 | new LambdaFunction(this.functions.markCompleteUploadFn)
65 | );
66 |
67 | const deletedRule = new Rule(this, 'deleted-uploads', {
68 | eventPattern: {
69 | source: Match.anyOf('aws.s3'),
70 | detailType: Match.anyOf('Object Removed'),
71 | detail: {
72 | bucket: {
73 | name: Match.anyOf(this.storage.landingZoneBucket.bucketName),
74 | },
75 | object: { key: Match.prefix('uploads/images/') },
76 | reason: Match.anyOf('DeleteObject'),
77 | },
78 | },
79 | });
80 | deletedRule.addTarget(
81 | new LambdaFunction(this.functions.markCompleteUploadFn)
82 | );
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/infra/lib/content-hub-repository/schema.graphql:
--------------------------------------------------------------------------------
1 | type File @aws_iam @aws_cognito_user_pools {
2 | id: ID
3 | status: String!
4 | transformedFileKey: String
5 | }
6 |
7 | type PresignedUrl {
8 | id: String!
9 | url: String!
10 | }
11 |
12 | input FileStatusUpdateInput {
13 | id: ID
14 | status: String!
15 | transformedFileKey: String
16 | }
17 |
18 | input PresignedUploadUrlInput {
19 | type: String!
20 | }
21 |
22 | type Query {
23 | generatePresignedDownloadUrl(id: String!): PresignedUrl
24 | @aws_cognito_user_pools
25 | }
26 |
27 | type Mutation {
28 | generatePresignedUploadUrl(input: PresignedUploadUrlInput): PresignedUrl
29 | @aws_cognito_user_pools
30 | updateFileStatus(input: FileStatusUpdateInput): File
31 | @aws_iam
32 | @aws_cognito_user_pools
33 | }
34 |
35 | input onUpdateFileStatusStringInput {
36 | ne: String
37 | eq: String
38 | le: String
39 | lt: String
40 | ge: String
41 | gt: String
42 | contains: String
43 | notContains: String
44 | between: [String]
45 | beginsWith: String
46 | in: [String]
47 | notIn: [String]
48 | }
49 |
50 | input onUpdateFileStatusFilterInput {
51 | id: onUpdateFileStatusStringInput
52 | status: onUpdateFileStatusStringInput
53 | and: [onUpdateFileStatusFilterInput]
54 | or: [onUpdateFileStatusFilterInput]
55 | }
56 |
57 | type Subscription {
58 | onUpdateFileStatus(filter: onUpdateFileStatusFilterInput): File
59 | @aws_subscribe(mutations: ["updateFileStatus"])
60 | @aws_cognito_user_pools
61 | }
62 |
--------------------------------------------------------------------------------
/infra/lib/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Frontend Hosting
2 |
3 | This component is responsible for providing hosting for the frontend web application. The web application is hosted in an S3 bucket and distributed via CloudFront. Additionally, the component includes the necessary components to enable authentication and authorization for the web application.
4 |
5 | ## Architecture
6 |
7 | 
8 |
9 | 1. The user navigates to the frontend web application. If the assets are cached in a point of presence (POP), the user will receive the assets directly without having to make a request to the origin.
10 | 2. If the assets are not cached in a POP, the user will receive the assets from the S3 bucket.
11 | 3. If the user is not authenticated, they can do so by logging in via the Cognito user pool.
12 | 4. In case of new users, upon registration a Lambda function is triggered to automatically mark the user as confirmed.
--------------------------------------------------------------------------------
/infra/lib/frontend/functions-construct.ts:
--------------------------------------------------------------------------------
1 | import { StackProps, Duration, Stack } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
4 | import { NagSuppressions } from 'cdk-nag';
5 | import {
6 | commonFunctionSettings,
7 | commonNodeJsBundlingSettings,
8 | commonEnvVars,
9 | environment,
10 | } from '../constants.js';
11 |
12 | export class FunctionsConstruct extends Construct {
13 | public readonly usersGeneratorFn: NodejsFunction;
14 |
15 | public constructor(scope: Construct, id: string, _props: StackProps) {
16 | super(scope, id);
17 |
18 | const localEnvVars = {
19 | ...commonEnvVars,
20 | DUMMY_PASSWORD: 'ABCabc123456789!',
21 | AWS_ACCOUNT_ID: Stack.of(this).account,
22 | };
23 |
24 | this.usersGeneratorFn = new NodejsFunction(this, 'users-generator', {
25 | ...commonFunctionSettings,
26 | entry:
27 | '../functions/typescript/workshop-services/users-generator/index.ts',
28 | functionName: `users-generator-${environment}`,
29 | timeout: Duration.seconds(60),
30 | environment: {
31 | ...localEnvVars,
32 | // COGNITO_USER_POOL_CLIENT_ID - added at deploy time
33 | },
34 | bundling: { ...commonNodeJsBundlingSettings },
35 | });
36 |
37 | NagSuppressions.addResourceSuppressions(
38 | this.usersGeneratorFn,
39 | [
40 | {
41 | id: 'AwsSolutions-IAM4',
42 | reason:
43 | 'Intentionally using AWSLambdaBasicExecutionRole managed policy.',
44 | },
45 | {
46 | id: 'AwsSolutions-IAM5',
47 | reason:
48 | 'Wildcard needed to allow access to X-Ray and CloudWatch streams.',
49 | },
50 | ],
51 | true
52 | );
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/infra/lib/frontend/index.ts:
--------------------------------------------------------------------------------
1 | import { Construct } from 'constructs';
2 | import { AuthConstruct } from './auth-construct.js';
3 | import { DistributionConstruct } from './distribution-construct.js';
4 | import { StorageConstruct } from './storage-construct.js';
5 | import { CustomResource } from 'aws-cdk-lib';
6 | import { RetentionDays } from 'aws-cdk-lib/aws-logs';
7 | import { Provider } from 'aws-cdk-lib/custom-resources';
8 | import { FunctionsConstruct } from './functions-construct.js';
9 |
10 | class FrontendProps {}
11 |
12 | export class Frontend extends Construct {
13 | public readonly auth: AuthConstruct;
14 | public readonly cdn: DistributionConstruct;
15 | public readonly storage: StorageConstruct;
16 |
17 | public constructor(scope: Construct, id: string, _props: FrontendProps) {
18 | super(scope, id);
19 |
20 | this.auth = new AuthConstruct(this, 'auth-construct', {});
21 |
22 | this.storage = new StorageConstruct(this, 'storage-construct', {});
23 |
24 | const functions = new FunctionsConstruct(this, 'functions-construct', {});
25 | functions.usersGeneratorFn.addEnvironment(
26 | 'COGNITO_USER_POOL_CLIENT_ID',
27 | this.auth.userPoolClient.userPoolClientId
28 | );
29 |
30 | this.cdn = new DistributionConstruct(this, 'distribution-construct', {
31 | websiteBucket: this.storage.websiteBucket,
32 | });
33 |
34 | const provider = new Provider(this, 'DummyUsersProvider', {
35 | onEventHandler: functions.usersGeneratorFn,
36 | logRetention: RetentionDays.ONE_DAY,
37 | });
38 |
39 | new CustomResource(this, 'Custom:DummyUsers', {
40 | serviceToken: provider.serviceToken,
41 | });
42 | }
43 |
44 | public addApiBehavior(apiDomain: string): void {
45 | this.cdn.addApiBehavior(apiDomain);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/infra/lib/frontend/storage-construct.ts:
--------------------------------------------------------------------------------
1 | import { Stack, RemovalPolicy, CfnOutput } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import {
4 | Bucket,
5 | BucketAccessControl,
6 | BucketEncryption,
7 | } from 'aws-cdk-lib/aws-s3';
8 | import { websiteBucketNamePrefix, environment } from '../constants.js';
9 | import { NagSuppressions } from 'cdk-nag';
10 |
11 | class StorageConstructProps {}
12 |
13 | export class StorageConstruct extends Construct {
14 | public readonly websiteBucket: Bucket;
15 |
16 | public constructor(
17 | scope: Construct,
18 | id: string,
19 | _props?: StorageConstructProps
20 | ) {
21 | super(scope, id);
22 |
23 | this.websiteBucket = new Bucket(this, 'website', {
24 | bucketName: `${websiteBucketNamePrefix}-${
25 | Stack.of(this).account
26 | }-${environment}`,
27 | accessControl: BucketAccessControl.PRIVATE,
28 | encryption: BucketEncryption.S3_MANAGED,
29 | removalPolicy: RemovalPolicy.DESTROY,
30 | autoDeleteObjects: true,
31 | });
32 |
33 | NagSuppressions.addResourceSuppressions(this.websiteBucket, [
34 | {
35 | id: 'AwsSolutions-S1',
36 | reason:
37 | "This bucket is deployed as part of an AWS workshop and as such it's short-lived.",
38 | },
39 | {
40 | id: 'AwsSolutions-S2',
41 | reason:
42 | 'This bucket uses CDK default settings which block public access and allows for overriding, in this case from CloudFormation distribution.',
43 | },
44 | ]);
45 |
46 | NagSuppressions.addResourceSuppressions(
47 | this.websiteBucket,
48 | [
49 | {
50 | id: 'AwsSolutions-S10',
51 | reason:
52 | 'This bucket is deployed as part of an AWS workshop. It already uses CloudFront with redirect to HTTPS.',
53 | },
54 | ],
55 | true
56 | );
57 |
58 | new CfnOutput(this, 'WebsiteBucketName', {
59 | value: this.websiteBucket.bucketName,
60 | exportName: `${Stack.of(this).stackName}-WebsiteBucketName${
61 | environment === 'prod' ? `-prod` : ''
62 | }`,
63 | });
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/infra/lib/ide-stack.ts:
--------------------------------------------------------------------------------
1 | import { Stack, type StackProps, Fn, CfnParameter } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import {
4 | NetworkConstruct,
5 | ComputeConstruct,
6 | DistributionConstruct,
7 | CompletionConstruct,
8 | RandomPasswordConstruct,
9 | } from './attendant-ide/index.js';
10 | import { environment } from './constants.js';
11 | import { NagSuppressions } from 'cdk-nag';
12 |
13 | export class IdeStack extends Stack {
14 | public constructor(scope: Construct, id: string, props?: StackProps) {
15 | super(scope, id, props);
16 |
17 | // Create a VPC with 1 public and 1 private subnet - the private subnet has a NAT gateway
18 | const network = new NetworkConstruct(this, 'network', {});
19 | const { vpc } = network;
20 |
21 | // Import the WebsiteBucketName output from the source stack (SourceStack)
22 | const websiteBucketName = Fn.importValue(
23 | `powertoolsworkshopinfra-WebsiteBucketName${
24 | environment === 'prod' ? `-prod` : ''
25 | }`
26 | );
27 |
28 | // Generated VSCode Password
29 | const randomPassword = new RandomPasswordConstruct(this, 'random-password', {});
30 |
31 | // Create a compute instance in the private subnet
32 | const compute = new ComputeConstruct(this, 'compute', {
33 | vpc,
34 | websiteBucketName: websiteBucketName,
35 | vscodePassword: randomPassword.randomPassword,
36 | });
37 | const { instance, target } = compute;
38 |
39 | // Create a load balancer and add the instance as a target
40 | network.createLoadBalancerWithInstanceEc2Target(target);
41 | // Allow inbound HTTP from the load balancer
42 | compute.allowConnectionFromLoadBalancer(network.loadBalancer!);
43 |
44 | // Create a CloudFront distribution in front of the load balancer
45 | const { healthCheckEndpoint } = new DistributionConstruct(
46 | this,
47 | 'distribution',
48 | {
49 | origin: network.loadBalancer!.loadBalancerDnsName,
50 | }
51 | );
52 |
53 | new CompletionConstruct(this, 'completion', {
54 | healthCheckEndpoint,
55 | });
56 |
57 | [
58 | 'powertoolsworkshopide/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource',
59 | 'powertoolsworkshopide/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/DefaultPolicy/Resource',
60 | ].forEach((resourcePath: string) => {
61 | let id = 'AwsSolutions-L1';
62 | let reason = 'Resource created and managed by CDK.';
63 | if (resourcePath.endsWith('ServiceRole/Resource')) {
64 | id = 'AwsSolutions-IAM4';
65 | } else if (resourcePath.endsWith('DefaultPolicy/Resource')) {
66 | id = 'AwsSolutions-IAM5';
67 | reason +=
68 | ' This type of resource is a singleton fn that interacts with many resources so IAM policies are lax by design to allow this use case.';
69 | }
70 | NagSuppressions.addResourceSuppressionsByPath(this, resourcePath, [
71 | {
72 | id,
73 | reason,
74 | },
75 | ]);
76 | });
77 |
78 | NagSuppressions.addResourceSuppressionsByPath(this, '/powertoolsworkshopide/completion/check-id-availability-provider/waiter-state-machine/Resource', [
79 | {
80 | id: 'AwsSolutions-SF1',
81 | reason: 'Resource created and managed by CDK for custom resources.',
82 | },
83 | {
84 | id: 'AwsSolutions-SF2',
85 | reason: 'Resource created and managed by CDK for custom resources.',
86 | },
87 | ]);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/infra/lib/image-detection/README.md:
--------------------------------------------------------------------------------
1 | # Image Detection Service
2 |
3 | This service is responsible for detecting labels in thumbnails generated by the media application. The service consumes the DynamoDB Stream of the media application and processes all the images that are marked as processed. For each batch of images, the service will invoke a Lambda function that will process the batch by calling the Amazon Rekognition service. If the service detects certain labels it will forward the result to another service via HTTPs call. The function can handle partial failures so that if one or more records cannot be processed it will reported and stored in a dead letter queue without causing the whole batch to fail.
4 |
5 | ## Architecture
6 |
7 | 
8 |
9 | 1. A filtered view of the DynamoDB Stream forwards batches of images to the Image Detection Lambda function.
10 | 2. The Image Detection Lambda function calls the Amazon Rekognition service to detect labels in each image.
11 | 3. The Rekognition service reads the image directly from the S3 bucket and returns the labels detected.
12 | 4. If the Rekognition service detects labels that match the configured list of labels, the Image Detection Lambda function fetches the API URL and API Key from SSM and Secrets Manager respectively.
13 | 5. Then it forwards the result to the Reporting Service via HTTPs call.
14 | 6. If the Image Detection Lambda function fails to process one or more images, it will report the partial failure which will publish the failed records to a dead letter queue.
--------------------------------------------------------------------------------
/infra/lib/image-detection/index.ts:
--------------------------------------------------------------------------------
1 | import type { Table } from 'aws-cdk-lib/aws-dynamodb';
2 | import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
3 | import {
4 | FilterCriteria,
5 | FilterRule,
6 | StartingPosition,
7 | } from 'aws-cdk-lib/aws-lambda';
8 | import { SqsDestination } from 'aws-cdk-lib/aws-lambda-destinations';
9 | import { DynamoEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
10 | import type { Bucket } from 'aws-cdk-lib/aws-s3';
11 | import { Construct } from 'constructs';
12 | import { FunctionsConstruct } from './functions-construct.js';
13 | import { QueuesConstruct } from './queues-construct.js';
14 | import { type Language } from '../constants.js';
15 |
16 | interface ImageDetectionProps {
17 | filesBucket: Bucket;
18 | filesTable: Table;
19 | language: Language;
20 | }
21 |
22 | export class ImageDetection extends Construct {
23 | public readonly functions: FunctionsConstruct;
24 | public readonly queues: QueuesConstruct;
25 |
26 | public constructor(scope: Construct, id: string, props: ImageDetectionProps) {
27 | super(scope, id);
28 |
29 | const { filesBucket, filesTable, language } = props;
30 |
31 | this.functions = new FunctionsConstruct(this, 'functions-construct', {
32 | landingZoneBucketName: filesBucket.bucketName,
33 | language,
34 | });
35 | this.functions.imageDetectionFn.addToRolePolicy(
36 | new PolicyStatement({
37 | actions: ['rekognition:DetectLabels'],
38 | resources: ['*'],
39 | })
40 | );
41 | filesBucket.grantRead(this.functions.imageDetectionFn);
42 | this.functions.imageDetectionFn.addEnvironment(
43 | 'BUCKET_NAME_FILES',
44 | filesBucket.bucketName
45 | );
46 |
47 | this.queues = new QueuesConstruct(this, 'queues-construct', {});
48 |
49 | this.functions.imageDetectionFn.addEventSource(
50 | new DynamoEventSource(filesTable, {
51 | startingPosition: StartingPosition.LATEST,
52 | reportBatchItemFailures: true,
53 | onFailure: new SqsDestination(this.queues.deadLetterQueue),
54 | batchSize: 100,
55 | retryAttempts: 3,
56 | filters: [
57 | FilterCriteria.filter({
58 | eventName: FilterRule.isEqual('MODIFY'),
59 | dynamodb: {
60 | NewImage: {
61 | status: {
62 | S: FilterRule.isEqual('completed'),
63 | },
64 | },
65 | },
66 | }),
67 | ],
68 | })
69 | );
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/infra/lib/image-detection/queues-construct.ts:
--------------------------------------------------------------------------------
1 | import { StackProps } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import { Queue } from 'aws-cdk-lib/aws-sqs';
4 | import { Alarm } from 'aws-cdk-lib/aws-cloudwatch';
5 | import { NagSuppressions } from 'cdk-nag';
6 | import { environment } from '../constants.js';
7 |
8 | export class QueuesConstruct extends Construct {
9 | public readonly deadLetterQueue: Queue;
10 |
11 | public constructor(scope: Construct, id: string, _props: StackProps) {
12 | super(scope, id);
13 |
14 | this.deadLetterQueue = new Queue(this, 'dead-letter-queue', {
15 | queueName: `ImageDetection-DeadLetterQueue-${environment}`,
16 | enforceSSL: true,
17 | });
18 |
19 | NagSuppressions.addResourceSuppressions(this.deadLetterQueue, [
20 | {
21 | id: 'AwsSolutions-SQS3',
22 | reason: 'This is already a DLQ, an additional DLQ would be redundant.',
23 | },
24 | {
25 | id: 'AwsSolutions-SQS4',
26 | reason: 'Not using SSL intentionally, queue is not public.',
27 | },
28 | ]);
29 |
30 | const metric = this.deadLetterQueue.metric(
31 | 'ApproximateNumberOfMessagesVisible'
32 | );
33 |
34 | new Alarm(this, 'Alarm', {
35 | metric: metric,
36 | threshold: 100,
37 | evaluationPeriods: 3,
38 | datapointsToAlarm: 2,
39 | });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/infra/lib/monitoring/index.ts:
--------------------------------------------------------------------------------
1 | import { StackProps } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import { DashboardConstruct } from './dashboard-construct.js';
4 |
5 | interface MonitoringConstructProps extends StackProps {
6 | tableName: string;
7 | functionName: string;
8 | deadLetterQueueName: string;
9 | }
10 |
11 | export class MonitoringConstruct extends Construct {
12 | public readonly imageProcessingDashboard: DashboardConstruct;
13 |
14 | public constructor(
15 | scope: Construct,
16 | id: string,
17 | props: MonitoringConstructProps
18 | ) {
19 | super(scope, id);
20 |
21 | this.imageProcessingDashboard = new DashboardConstruct(
22 | this,
23 | 'dashboard-construct',
24 | props
25 | );
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/infra/lib/reporting-service/README.md:
--------------------------------------------------------------------------------
1 | # Reporting Service Architecture
2 |
3 | The Reporting Service is responsible for receiving the results of the image detection service whenever a label among the configured list of labels is detected in an image. The service is exposed as a REST API via API Gateway and handles multiple routes using a single Lambda function setup with proxy integration. The Lambda function will validate and parse the request, and then forward the request to the appropriate handler function. The handler function will then process the request and return a response to the client.
4 |
5 | ## Architecture
6 |
7 | 
8 |
9 | 1. Requests are sent to the API Gateway endpoint which forwards the request to the API Handler Lambda function which validates and parses the request and then evaluates it according to the configured routes.
10 | 2. If needed, the Lambda function will fetch additional data from the DynamoDB table.
11 | 3. Then it will publish the request to a SQS queue for further processing.
--------------------------------------------------------------------------------
/infra/lib/reporting-service/index.ts:
--------------------------------------------------------------------------------
1 | import { Construct } from 'constructs';
2 | import { FunctionsConstruct } from './functions-construct.js';
3 | import { ApiConstruct } from './api-construct.js';
4 | import { LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';
5 | import { type StackProps } from 'aws-cdk-lib';
6 | import { type Language } from '../constants.js';
7 | import { NagSuppressions } from 'cdk-nag';
8 |
9 | interface ReportingServiceProps extends StackProps {
10 | language: Language;
11 | }
12 |
13 | export class ReportingService extends Construct {
14 | public readonly functions: FunctionsConstruct;
15 | public readonly api: ApiConstruct;
16 |
17 | public constructor(
18 | scope: Construct,
19 | id: string,
20 | props: ReportingServiceProps
21 | ) {
22 | super(scope, id);
23 |
24 | const { language } = props;
25 |
26 | this.functions = new FunctionsConstruct(this, 'functions-construct', {
27 | language,
28 | });
29 |
30 | this.api = new ApiConstruct(this, 'api-construct', {});
31 |
32 | const methodResource = this.api.restApi.root.addMethod(
33 | 'POST',
34 | new LambdaIntegration(this.functions.apiEndpointHandlerFn, {
35 | proxy: true,
36 | }),
37 | {
38 | apiKeyRequired: true,
39 | }
40 | );
41 |
42 | NagSuppressions.addResourceSuppressions(
43 | methodResource,
44 | [
45 | {
46 | id: 'AwsSolutions-COG4',
47 | reason: 'Method uses API Key Authorization instead',
48 | },
49 | {
50 | id: 'AwsSolutions-APIG4',
51 | reason: 'Method uses API Key Authorization',
52 | },
53 | ],
54 | true
55 | );
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/infra/lib/thumbnail-generator/README.md:
--------------------------------------------------------------------------------
1 | # Thumbnail Generator Service
2 |
3 | This service is responsible for generating thumbnails for the images uploaded to the media application. The service is also responsible for storing the thumbnails in a S3 bucket. Whenever an object lands in the S3 bucket, a message is published to an EventBridge event bus that triggers a Lambda function that will read the object and generate the thumbnails. The service propagates real-time updates about the image status to all the clients subscribed to the relevant GraphQL subscription. Additionally, the service is idempotent, which means that if the same image is uploaded multiple times, the service will only generate the thumbnails once based on the object etag. Finally, if the service fails to generate a thumbnail, the event will be published to a dead letter queue (DLQ) for further analysis.
4 |
5 | ## Architecture
6 |
7 | 
8 |
9 | 1. When an image is uploaded to the S3 bucket, a message is published to an EventBridge event bus that triggers the thumbnail generator Lambda function.
10 | 2. The Lambda function propagates real-time status updates (i.e. `working`, `completed`, or `failed`) to the AppSync GraphQL API so that the clients can be notified about the image status.
11 | 3. The operation is checked against the DynamoDB table to ensure that it's idempotent. If the operation has already been processed, the Lambda function will update the status of the image to `completed` and return.
12 | 4. If the image is not a duplicate, the Lambda function will download the image from the S3 bucket and generate the thumbnail.
13 | 5. Then based on the result of the operation (i.e. `success` or `failure`), the Lambda function will either store the thumbnail in the S3 bucket ("5a") or throw an error so that the message is published to the DLQ ("5b").
--------------------------------------------------------------------------------
/infra/lib/thumbnail-generator/index.ts:
--------------------------------------------------------------------------------
1 | import { Stack, type StackProps } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import { Rule, Match } from 'aws-cdk-lib/aws-events';
4 | import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
5 | import { FunctionsConstruct } from './functions-construct.js';
6 | import { QueuesConstruct } from './queues-construct.js';
7 | import { StorageConstruct } from './storage-construct.js';
8 | import {
9 | type Language,
10 | environment,
11 | landingZoneBucketNamePrefix,
12 | } from '../constants.js';
13 |
14 | interface ThumbnailGeneratorProps extends StackProps {
15 | language: Language;
16 | }
17 |
18 | export class ThumbnailGenerator extends Construct {
19 | public readonly functions: FunctionsConstruct;
20 | public readonly queues: QueuesConstruct;
21 | public readonly storage: StorageConstruct;
22 |
23 | public constructor(
24 | scope: Construct,
25 | id: string,
26 | props: ThumbnailGeneratorProps
27 | ) {
28 | super(scope, id);
29 |
30 | const { language } = props;
31 |
32 | const filesBucketName = `${landingZoneBucketNamePrefix}-${
33 | Stack.of(this).account
34 | }-${environment}`;
35 |
36 | this.functions = new FunctionsConstruct(this, 'functions-construct', {
37 | language,
38 | });
39 |
40 | this.queues = new QueuesConstruct(this, 'queues-construct', {});
41 |
42 | this.storage = new StorageConstruct(this, 'storage-construct', {});
43 | this.storage.grantReadWriteDataOnTable(this.functions.thumbnailGeneratorFn);
44 | this.functions.thumbnailGeneratorFn.addEnvironment(
45 | 'IDEMPOTENCY_TABLE_NAME',
46 | this.storage.idempotencyTable.tableName
47 | );
48 |
49 | const thumbnailGeneratorRule = new Rule(
50 | this,
51 | 'thumbnail-generator-process',
52 | {
53 | eventPattern: {
54 | source: Match.anyOf('aws.s3'),
55 | detailType: Match.anyOf('Object Created'),
56 | detail: {
57 | bucket: {
58 | name: Match.anyOf(filesBucketName),
59 | },
60 | object: {
61 | key: Match.anyOf(
62 | Match.prefix('uploads/images/jpg'),
63 | Match.prefix('uploads/images/png')
64 | ),
65 | },
66 | reason: Match.anyOf('PutObject'),
67 | },
68 | },
69 | }
70 | );
71 | thumbnailGeneratorRule.addTarget(
72 | new LambdaFunction(this.functions.thumbnailGeneratorFn, {
73 | retryAttempts: 1,
74 | deadLetterQueue: this.queues.deadLetterQueue,
75 | })
76 | );
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/infra/lib/thumbnail-generator/queues-construct.ts:
--------------------------------------------------------------------------------
1 | import { StackProps } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import { Queue } from 'aws-cdk-lib/aws-sqs';
4 | import { Alarm } from 'aws-cdk-lib/aws-cloudwatch';
5 | import { NagSuppressions } from 'cdk-nag';
6 | import { environment } from '../constants.js';
7 |
8 | export class QueuesConstruct extends Construct {
9 | public readonly deadLetterQueue: Queue;
10 |
11 | public constructor(scope: Construct, id: string, _props: StackProps) {
12 | super(scope, id);
13 |
14 | this.deadLetterQueue = new Queue(this, 'dead-letter-queue', {
15 | queueName: `ThumbnailGenerator-DeadLetterQueue-${environment}`,
16 | enforceSSL: true,
17 | });
18 |
19 | NagSuppressions.addResourceSuppressions(this.deadLetterQueue, [
20 | {
21 | id: 'AwsSolutions-SQS3',
22 | reason: 'This is already a DLQ, an additional DLQ would be redundant.',
23 | },
24 | {
25 | id: 'AwsSolutions-SQS4',
26 | reason: 'Not using SSL intentionally, queue is not public.',
27 | },
28 | ]);
29 |
30 | const metric = this.deadLetterQueue.metric(
31 | 'ApproximateNumberOfMessagesVisible'
32 | );
33 |
34 | new Alarm(this, 'Alarm', {
35 | metric: metric,
36 | threshold: 100,
37 | evaluationPeriods: 3,
38 | datapointsToAlarm: 2,
39 | });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/infra/lib/thumbnail-generator/storage-construct.ts:
--------------------------------------------------------------------------------
1 | import { RemovalPolicy } from 'aws-cdk-lib';
2 | import { Construct } from 'constructs';
3 | import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
4 | import { environment } from '../constants.js';
5 | import { IGrantable } from 'aws-cdk-lib/aws-iam';
6 | import { NagSuppressions } from 'cdk-nag';
7 |
8 | interface StorageConstructProps {}
9 |
10 | export class StorageConstruct extends Construct {
11 | public readonly idempotencyTable: Table;
12 |
13 | public constructor(
14 | scope: Construct,
15 | id: string,
16 | _props: StorageConstructProps
17 | ) {
18 | super(scope, id);
19 |
20 | this.idempotencyTable = new Table(this, 'idempotency-table', {
21 | tableName: `idempotency-thumbnail-generator-${environment}`,
22 | billingMode: BillingMode.PAY_PER_REQUEST,
23 | partitionKey: { name: 'id', type: AttributeType.STRING },
24 | timeToLiveAttribute: 'expiration',
25 | removalPolicy: RemovalPolicy.DESTROY,
26 | });
27 |
28 | NagSuppressions.addResourceSuppressions(this.idempotencyTable, [
29 | {
30 | id: 'AwsSolutions-DDB3',
31 | reason:
32 | "No point-in-time recovery needed for this table, it's for a short-lived workshop.",
33 | },
34 | ]);
35 | }
36 |
37 | public grantReadDataOnTable(grantee: IGrantable): void {
38 | this.idempotencyTable.grantReadData(grantee);
39 | }
40 |
41 | public grantReadWriteDataOnTable(grantee: IGrantable): void {
42 | this.idempotencyTable.grantReadWriteData(grantee);
43 | }
44 |
45 | public grantWriteDataOnTable(grantee: IGrantable): void {
46 | this.idempotencyTable.grantWriteData(grantee);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/infra/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "infra",
3 | "version": "2.1.0",
4 | "author": {
5 | "name": "Amazon Web Services",
6 | "url": "https://aws.amazon.com"
7 | },
8 | "type": "module",
9 | "scripts": {
10 | "build": "echo 'Not applicable, run `npx cdk synth` instead to build the stack'",
11 | "lint": "biome lint .",
12 | "lint:fix": "biome check --write .",
13 | "cdk": "cdk",
14 | "cdk:destroy": "cdk destroy",
15 | "cdk:deploy": "cdk deploy --outputs-file cdk.out/params.json",
16 | "cdk:deploy:hotswap": "cdk deploy --hotswap --outputs-file cdk.out/params.json --require-approval never",
17 | "cdk:synth-dev": "cdk synth",
18 | "cdk:synth-prod": "NODE_ENV=production cdk synth"
19 | },
20 | "devDependencies": {
21 | "@aws-cdk/aws-cognito-identitypool-alpha": "^2.158.0-alpha.0",
22 | "@aws-cdk/aws-lambda-python-alpha": "^2.158.0-alpha.0",
23 | "aws-cdk": "^2.1017.1",
24 | "esbuild": "^0.25.0",
25 | "typescript": "^5.6.3"
26 | },
27 | "dependencies": {
28 | "@aws-sdk/client-cognito-identity-provider": "^3.821.0",
29 | "@aws-sdk/client-ssm": "^3.821.0",
30 | "aws-cdk-lib": "^2.200.0",
31 | "cdk-nag": "^2.28.195",
32 | "constructs": "^10.4.2",
33 | "source-map-support": "^0.5.21",
34 | "yaml": "^2.7.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/infra/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "moduleDetection": "force",
8 | "isolatedModules": true,
9 | "strict": true,
10 | "noUncheckedIndexedAccess": true,
11 | "moduleResolution": "NodeNext",
12 | "module": "NodeNext",
13 | "sourceMap": true,
14 | "experimentalDecorators": true,
15 | "declaration": true,
16 | "declarationMap": true,
17 | "removeComments": false,
18 | "pretty": true,
19 | "lib": ["ES2022"],
20 | "rootDir": "."
21 | },
22 | "include": ["lib/**/*", "bin/**/*", "infra.ts"],
23 | "exclude": ["./node_modules"],
24 | "watchOptions": {
25 | "watchFile": "useFsEvents",
26 | "watchDirectory": "useFsEvents",
27 | "fallbackPolling": "dynamicPriority"
28 | },
29 | "types": ["node"]
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aws-lambda-powertools-typescript-workshop",
3 | "version": "2.1.0",
4 | "description": "Powertools for AWS Lambda Workshop",
5 | "workspaces": ["scripts", "frontend", "infra", "functions/typescript"],
6 | "scripts": {
7 | "lint": "biome lint .",
8 | "lint:fix": "biome check --write .",
9 | "clean": "rm -rf node_modules infra/node_modules infra/cdk.out frontend/node_modules frontend/src/aws-exports.cjs functions/typescript/node_modules",
10 | "frontend:start": "npm start -w frontend",
11 | "frontend:build": "npm run build -w frontend",
12 | "frontend:deploy": "npm run deploy -w frontend",
13 | "frontend:invalidateCache": "npm run deploy:invalidateCache -w frontend",
14 | "infra:destroy": "npm run cdk:destroy -w infra -- powertoolsworkshopinfra",
15 | "infra:deploy": "npm run cdk:deploy -w infra -- powertoolsworkshopinfra",
16 | "infra:deployHot": "npm run cdk:deploy:hotswap -w infra -- powertoolsworkshopinfra",
17 | "infra:synth": "npm run cdk:synth-dev -w infra -- powertoolsworkshopinfra",
18 | "infra:synth-prod": "npm run cdk:synth-prod -w infra -- powertoolsworkshopinfra",
19 | "infra:wsPrep": "npm run convertCDKtoCfn -w scripts -- powertoolsworkshopinfra",
20 | "ide:deploy": "npm run cdk:deploy -w infra -- powertoolsworkshopide",
21 | "ide:destroy": "npm run cdk:destroy -w infra -- powertoolsworkshopide",
22 | "ide:synth": "npm run cdk:synth-dev -w infra -- powertoolsworkshopide",
23 | "ide:wsPrep": "npm run convertCDKtoCfn -w scripts -- powertoolsworkshopide",
24 | "workshop:deploy": "npm run cdk:deploy -w infra -- --all",
25 | "utils:createConfig": "npm run createConfig -w scripts",
26 | "utils:downloadFfmpegToLayer": "sh scripts/download-ffmpeg-to-layer.sh",
27 | "utils:convertCDKtoCfn": "npm run convertCDKtoCfn -w scripts"
28 | },
29 | "keywords": [],
30 | "author": "",
31 | "license": "MIT-0",
32 | "lint-staged": {
33 | "*.{js,ts}": "biome check --write"
34 | },
35 | "devDependencies": {
36 | "@biomejs/biome": "^1.9.1",
37 | "lint-staged": "^15.2.10",
38 | "tsx": "^4.19.3",
39 | "@types/node": "^22.13.8"
40 | },
41 | "dependencies": {
42 | "@types/node": "^22.13.8"
43 | },
44 | "engines": {
45 | "node": ">=18"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/scripts/create-aws-exports.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */
2 | import { readFile, writeFile } from 'node:fs/promises';
3 | import {
4 | getStackName,
5 | getStackOutputs,
6 | getValueFromNamePart,
7 | } from './shared.mjs';
8 |
9 | /**
10 | *
11 | * @param {string} path
12 | * @param {string} stackName
13 | */
14 | const getParamsLocally = async (path, stackName) => {
15 | try {
16 | const fileContent = await readFile(path);
17 | const paramsObject = JSON.parse(fileContent);
18 | const paramsKeys = Object.keys(paramsObject[stackName]);
19 | const paramsValues = paramsObject[stackName];
20 |
21 | return { keys: paramsKeys, vals: paramsValues };
22 | } catch (err) {
23 | throw err;
24 | }
25 | };
26 |
27 | const saveTemplate = async (template, path) => {
28 | try {
29 | await writeFile(
30 | path,
31 | `const awsmobile = ${JSON.stringify(template, null, 2)}
32 | export default awsmobile;
33 | `
34 | );
35 | } catch (err) {
36 | console.error(err);
37 | console.error('Unable to write file');
38 | throw err;
39 | }
40 | };
41 |
42 | (async () => {
43 | const stackName = 'powertoolsworkshopinfra';
44 | let keys;
45 | let vals;
46 | try {
47 | console.info('Trying to find output file locally.');
48 | const params = await getParamsLocally(
49 | '../infra/cdk.out/params.json',
50 | stackName
51 | );
52 | keys = params.keys;
53 | vals = params.vals;
54 | } catch (err) {
55 | console.info('Unable to find output file locally, trying remotely.');
56 | try {
57 | console.info(`Trying to find stack with ${stackName}`);
58 | const stack = await getStackName(stackName);
59 | const params = await getStackOutputs(stack.StackName);
60 | keys = params.keys;
61 | vals = params.vals;
62 | console.info(
63 | `Stack '${stack.StackName}' found remotely, using outputs from there.`
64 | );
65 | } catch (err) {
66 | console.error('Did you run `npm run infra:deploy` in the project root?');
67 | throw new Error('Unable to find parameters locally or remotely.');
68 | }
69 | }
70 | const template = {};
71 |
72 | const region = vals[getValueFromNamePart('AWSRegion', keys)];
73 | template.aws_project_region = region;
74 | template.aws_cognito_region = region;
75 | template.aws_cognito_identity_pool_id =
76 | vals[getValueFromNamePart('IdentityPoolId', keys)];
77 | template.aws_user_pools_id = vals[getValueFromNamePart('UserPoolId', keys)];
78 | template.aws_user_pools_web_client_id =
79 | vals[getValueFromNamePart('UserPoolClientId', keys)];
80 | const apiEndpointDomain = vals[getValueFromNamePart('ApiEndpoint', keys)];
81 | template.aws_appsync_authenticationType = 'AMAZON_COGNITO_USER_POOLS';
82 | template.aws_appsync_graphqlEndpoint = `https://${apiEndpointDomain}/graphql`;
83 |
84 | console.info('Creating config file at frontend/src/aws-exports.cjs');
85 |
86 | saveTemplate(template, '../frontend/src/aws-exports.cjs');
87 | })();
88 |
--------------------------------------------------------------------------------
/scripts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scripts",
3 | "version": "2.1.0",
4 | "description": "",
5 | "type": "module",
6 | "keywords": [],
7 | "author": {
8 | "name": "Amazon Web Services",
9 | "url": "https://aws.amazon.com"
10 | },
11 | "license": "MIT-0",
12 | "scripts": {
13 | "createConfig": "node create-aws-exports.mjs",
14 | "convertCDKtoCfn": "node convert-template.mjs"
15 | },
16 | "dependencies": {
17 | "@aws-sdk/client-cloudformation": "^3.821.0",
18 | "shelljs": "^0.9.2"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/scripts/shared.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */
2 | import {
3 | CloudFormationClient,
4 | DescribeStacksCommand,
5 | ListStacksCommand,
6 | } from '@aws-sdk/client-cloudformation';
7 |
8 | const cfnClient = new CloudFormationClient({});
9 |
10 | /**
11 | *
12 | * @param {string} name
13 | * @returns
14 | */
15 | const getStackName = async (name) => {
16 | try {
17 | const res = await cfnClient.send(
18 | new ListStacksCommand({
19 | StackStatusFilter: [
20 | 'CREATE_COMPLETE',
21 | 'UPDATE_COMPLETE',
22 | 'ROLLBACK_COMPLETE',
23 | ],
24 | })
25 | );
26 | const stack = res.StackSummaries.find((stack) =>
27 | stack.StackName.toUpperCase().includes(name.toUpperCase())
28 | );
29 | if (!stack) {
30 | throw new Error(
31 | `Unable to find stack that includes ${name} among loaded ones`
32 | );
33 | }
34 |
35 | return stack;
36 | } catch (err) {
37 | console.error(err);
38 | console.error('Unable to load CloudFormation stacks.');
39 | throw err;
40 | }
41 | };
42 |
43 | /**
44 | *
45 | * @param {string} stackName
46 | */
47 | const getStackOutputs = async (stackName) => {
48 | try {
49 | const res = await cfnClient.send(
50 | new DescribeStacksCommand({
51 | StackName: stackName,
52 | })
53 | );
54 | if (res.Stacks.length === 0) {
55 | throw new Error('Stack not found');
56 | }
57 | const keys = [];
58 | const outputs = {};
59 | for (const { OutputKey, OutputValue } of res.Stacks?.[0].Outputs || []) {
60 | outputs[OutputKey] = OutputValue;
61 | keys.push(OutputKey);
62 | }
63 |
64 | return {
65 | keys,
66 | vals: outputs,
67 | };
68 | } catch (err) {
69 | console.error(err);
70 | console.error('Unable to load CloudFormation Stack outputs.');
71 | throw err;
72 | }
73 | };
74 |
75 | /**
76 | *
77 | * @param {string} namePart
78 | */
79 | const getValueFromNamePart = (namePart, values) =>
80 | values.find((el) => el.toUpperCase().includes(namePart.toUpperCase()));
81 |
82 | export { getStackName, getStackOutputs, getValueFromNamePart };
83 |
--------------------------------------------------------------------------------