├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── delivery-pricing ├── Makefile ├── README.md ├── images │ └── delivery-pricing.png ├── metadata.yaml ├── resources │ └── openapi.yaml ├── src │ └── pricing │ │ ├── main.py │ │ └── requirements.txt ├── template.yaml └── tests │ ├── integ │ └── test_api.py │ └── unit │ └── test_pricing.py ├── delivery ├── Makefile ├── README.md ├── images │ └── delivery.png ├── metadata.yaml ├── resources │ └── events.yaml ├── src │ ├── on_package_created │ │ ├── main.py │ │ └── requirements.txt │ └── table_update │ │ ├── main.py │ │ └── requirements.txt ├── template.yaml └── tests │ ├── integ │ ├── test_events.py │ └── test_on_package_created.py │ └── unit │ ├── test_on_package_created.py │ └── test_table_update.py ├── docs ├── README.md ├── common_issues.md ├── conventions.md ├── decision_log.md ├── getting_started.md ├── images │ ├── architecture.png │ ├── flow.png │ ├── service_discovery_create.png │ ├── service_discovery_deploy.png │ ├── service_discovery_runtime.png │ └── testing_workflow.png ├── make_targets.md ├── service_discovery.md ├── service_structure.md ├── service_to_service.md └── testing.md ├── environments.yaml ├── frontend-api ├── Makefile ├── README.md ├── images │ └── frontend.png ├── metadata.yaml ├── resources │ └── api.graphql ├── template.yaml └── tests │ └── integ │ ├── test_graphql.py │ └── test_graphql_admin.py ├── orders ├── Makefile ├── README.md ├── images │ ├── monitoring.png │ └── orders.png ├── metadata.yaml ├── resources │ ├── events.yaml │ └── openapi.yaml ├── src │ ├── create_order │ │ ├── main.py │ │ ├── requirements.txt │ │ └── schema.json │ ├── get_order │ │ ├── main.py │ │ └── requirements.txt │ ├── on_events │ │ ├── main.py │ │ └── requirements.txt │ └── table_update │ │ ├── main.py │ │ └── requirements.txt ├── template.yaml └── tests │ ├── integ │ ├── test_api.py │ ├── test_create_order.py │ ├── test_events.py │ └── test_on_events.py │ └── unit │ ├── test_create_order.py │ ├── test_get_order.py │ ├── test_on_events.py │ └── test_table_update.py ├── payment-3p ├── .gitignore ├── .npmignore ├── Makefile ├── README.md ├── bin │ └── payment-3p.ts ├── cdk.context.json ├── cdk.jest.config.js ├── cdk.json ├── integ.jest.config.js ├── lib │ └── payment-3p-stack.ts ├── metadata.yaml ├── package-lock.json ├── package.json ├── resources │ └── openapi.yaml ├── src │ ├── cancelPayment │ │ ├── index.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ └── tsconfig.json │ ├── check │ │ ├── index.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ └── tsconfig.json │ ├── preauth │ │ ├── index.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ └── tsconfig.json │ ├── processPayment │ │ ├── index.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ └── tsconfig.json │ └── updateAmount │ │ ├── index.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ └── tsconfig.json ├── tests │ ├── integ │ │ ├── cancelPayment.test.ts │ │ ├── check.test.ts │ │ ├── preauth.test.ts │ │ ├── processPayment.test.ts │ │ └── updateAmount.test.ts │ ├── lint │ │ └── payment-3p.test.ts │ └── unit │ │ ├── cancelPayment.test.ts │ │ ├── check.test.ts │ │ ├── preauth.test.ts │ │ ├── processPayment.test.ts │ │ └── updateAmount.test.ts ├── tsconfig.json └── unit.jest.config.js ├── payment ├── Makefile ├── README.md ├── images │ ├── monitoring.png │ └── payment.png ├── metadata.yaml ├── resources │ └── openapi.yaml ├── src │ ├── on_completed │ │ ├── main.py │ │ └── requirements.txt │ ├── on_created │ │ ├── main.py │ │ └── requirements.txt │ ├── on_failed │ │ ├── main.py │ │ └── requirements.txt │ ├── on_modified │ │ ├── main.py │ │ └── requirements.txt │ └── validate │ │ ├── main.py │ │ └── requirements.txt ├── template.yaml └── tests │ ├── integ │ ├── test_api.py │ └── test_on_events.py │ └── unit │ ├── test_on_completed.py │ ├── test_on_created.py │ ├── test_on_failed.py │ ├── test_on_modified.py │ └── test_validate.py ├── pipeline ├── Makefile ├── README.md ├── images │ └── pipeline.png ├── metadata.yaml ├── resources │ ├── buildspec-build.yaml │ ├── buildspec-staging.yaml │ ├── buildspec-tests.yaml │ ├── service-pipeline-environment.yaml │ └── service-pipeline.yaml └── template.yaml ├── platform ├── Makefile ├── README.md ├── images │ └── platform.png ├── metadata.yaml ├── src │ ├── on_connect │ │ ├── main.py │ │ └── requirements.txt │ ├── on_disconnect │ │ ├── main.py │ │ └── requirements.txt │ ├── on_events │ │ ├── main.py │ │ └── requirements.txt │ └── register │ │ ├── main.py │ │ └── requirements.txt ├── template.yaml └── tests │ ├── integ │ └── test_on_events.py │ └── unit │ ├── test_on_connect.py │ ├── test_on_disconnect.py │ ├── test_on_events.py │ └── test_register.py ├── products ├── Makefile ├── README.md ├── images │ └── products.png ├── metadata.yaml ├── resources │ ├── events.yaml │ └── openapi.yaml ├── src │ ├── table_update │ │ ├── main.py │ │ └── requirements.txt │ └── validate │ │ ├── main.py │ │ └── requirements.txt ├── template.yaml └── tests │ ├── integ │ ├── test_api.py │ └── test_events.py │ └── unit │ ├── test_table_update.py │ └── test_validate.py ├── requirements.txt ├── shared ├── environments │ └── schema.yaml ├── lint │ ├── pylintrc │ ├── rules │ │ └── custom_rules.py │ └── speccy.yaml ├── makefiles │ ├── cfn-nocode.mk │ ├── cfn-python3.mk │ └── empty.mk ├── metadata │ └── schema.yaml ├── resources │ └── schemas.yaml ├── src │ └── ecom │ │ ├── ecom │ │ ├── __init__.py │ │ ├── apigateway.py │ │ ├── eventbridge.py │ │ └── helpers.py │ │ ├── requirements.txt │ │ ├── setup.py │ │ └── tests │ │ └── test_helpers.py ├── templates │ └── dlq.yaml └── tests │ ├── e2e │ └── test_happy_path.py │ ├── integ │ ├── fixtures.py │ └── helpers.py │ ├── perf │ └── perf_happy_path.py │ └── unit │ ├── coveragerc │ ├── fixtures.py │ └── helpers.py ├── tools ├── artifacts ├── build ├── check-deps ├── clean ├── deploy ├── helpers │ └── build_artifacts ├── lint ├── package ├── services ├── teardown ├── tests-e2e ├── tests-integ ├── tests-perf └── tests-unit ├── users ├── Makefile ├── README.md ├── images │ └── users.png ├── metadata.yaml ├── resources │ └── events.yaml ├── src │ └── sign_up │ │ ├── main.py │ │ └── requirements.txt ├── template.yaml └── tests │ ├── integ │ └── test_events.py │ └── unit │ └── sign_up │ └── test_sign_up.py └── warehouse ├── Makefile ├── README.md ├── images └── warehouse.png ├── metadata.yaml ├── resources └── events.yaml ├── src ├── on_order_events │ ├── main.py │ └── requirements.txt └── table_update │ ├── main.py │ └── requirements.txt ├── template.yaml └── tests ├── integ ├── test_events.py └── test_on_order_events.py └── unit ├── test_on_order_events.py └── test_table_update.py /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: setup python-3.8 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: 3.8 20 | - name: setup node-12.x 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: 12 24 | - name: install dependencies 25 | shell: bash 26 | run: | 27 | sudo apt-get install -y jq 28 | python -m pip install --upgrade pip 29 | make requirements npm-install 30 | mkdir ~/.aws 31 | touch ~/.aws/credentials ~/.aws/config 32 | ls ~/.aws/ 33 | - name: lint and unit tests 34 | shell: bash 35 | run: | 36 | make ci 37 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional documentation, we greatly value feedback and contributions from our community. 4 | 5 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary information to effectively respond to your bug report or contribution. 6 | 7 | ## Reporting Bugs/Feature Requests 8 | 9 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 10 | 11 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 12 | 13 | * A reproducible test case or series of steps 14 | * The version of our code being used 15 | * Any modifications you've made relevant to the bug 16 | * Anything unusual about your environment or deployment 17 | 18 | ## Contributing via Pull Requests 19 | 20 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 21 | 22 | 1. You are working against the latest source on the *main* branch. 23 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 24 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 25 | 26 | To send us a pull request, please: 27 | 28 | 1. Fork the repository. 29 | 2. Install and setup the required tools. See [docs/setup.md] for more information. 30 | 3. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 31 | 4. Ensure local tests pass. 32 | 5. Commit to your fork using clear commit messages. 33 | 6. Send us a pull request, answering any default questions in the pull request interface. 34 | 7. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 35 | 36 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 37 | 38 | ## Finding contributions to work on 39 | 40 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 41 | 42 | ## Code of Conduct 43 | 44 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact opensource-codeofconduct@amazon.com with any additional questions or comments. 45 | 46 | ## Security issue notifications 47 | 48 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 49 | 50 | 51 | ## Licensing 52 | 53 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 54 | 55 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 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 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to 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 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /delivery-pricing/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /delivery-pricing/README.md: -------------------------------------------------------------------------------- 1 | Delivery Pricing service 2 | ======================== 3 | 4 |

5 | Delivery Pricing Architecture Diagram 6 |

7 | 8 | This service provides an API to calculate the delivery cost for a specific list of products and an address. 9 | 10 | ## API 11 | 12 | See [resources/openapi.yaml](resources/openapi.yaml) for a list of available API paths. 13 | 14 | ## Events 15 | 16 | _This service does not emit events._ 17 | 18 | ## SSM Parameters 19 | 20 | This service defines the following SSM parameters: 21 | 22 | * `/ecommerce/{Environment}/delivery-pricing/api/arn`: ARN for the API Gateway 23 | * `/ecommerce/{Environment}/delivery-pricing/api/domain`: Domain name for the API Gateway 24 | * `/ecommerce/{Environment}/delivery-pricing/api/url`: URL for the API Gateway -------------------------------------------------------------------------------- /delivery-pricing/images/delivery-pricing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/delivery-pricing/images/delivery-pricing.png -------------------------------------------------------------------------------- /delivery-pricing/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: delivery-pricing -------------------------------------------------------------------------------- /delivery-pricing/resources/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: 4 | Fn::Sub: "${AWS::StackName}-api" 5 | version: 1.0.0 6 | description: Delivery Pricing API definition 7 | license: 8 | name: MIT-0 9 | url: https://github.com/aws/mit-0 10 | 11 | paths: 12 | /backend/pricing: 13 | post: 14 | description: | 15 | Pricing calculator for deliveries. 16 | 17 | This takes into account the dimension of the product and the address of delivery. 18 | operationId: backendPricingDelivery 19 | requestBody: 20 | required: true 21 | content: 22 | application/json: 23 | schema: 24 | type: object 25 | required: 26 | - products 27 | - address 28 | properties: 29 | products: 30 | type: array 31 | items: 32 | $ref: "../../shared/resources/schemas.yaml#/Product" 33 | address: 34 | $ref: "../../shared/resources/schemas.yaml#/Address" 35 | responses: 36 | "200": 37 | description: OK 38 | content: 39 | application/json: 40 | schema: 41 | type: object 42 | required: 43 | - price 44 | properties: 45 | price: 46 | type: integer 47 | default: 48 | description: Error 49 | content: 50 | application/json: 51 | schema: 52 | $ref: "../../shared/resources/schemas.yaml#/Message" 53 | x-amazon-apigateway-auth: 54 | type: AWS_IAM 55 | x-amazon-apigateway-integration: 56 | httpMethod: "POST" 57 | type: aws_proxy 58 | uri: 59 | Fn::Sub: "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PricingFunction.Arn}/invocations" 60 | -------------------------------------------------------------------------------- /delivery-pricing/src/pricing/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | ../shared/src/ecom/ 3 | -------------------------------------------------------------------------------- /delivery-pricing/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | 4 | 5 | Parameters: 6 | Environment: 7 | Type: String 8 | Default: dev 9 | Description: Environment name 10 | LogLevel: 11 | Type: String 12 | Default: INFO 13 | RetentionInDays: 14 | Type: Number 15 | Default: 30 16 | Description: CloudWatch Logs retention period for Lambda functions 17 | 18 | 19 | Globals: 20 | Function: 21 | Runtime: python3.9 22 | Architectures: 23 | - arm64 24 | Handler: main.handler 25 | Timeout: 30 26 | Tracing: Active 27 | Environment: 28 | Variables: 29 | ENVIRONMENT: !Ref Environment 30 | POWERTOOLS_SERVICE_NAME: delivery-pricing 31 | POWERTOOLS_TRACE_DISABLED: "false" 32 | LOG_LEVEL: !Ref LogLevel 33 | Layers: 34 | - !Sub "arn:aws:lambda:${AWS::Region}:580247275435:layer:LambdaInsightsExtension-Arm64:1" 35 | 36 | 37 | Resources: 38 | PricingFunction: 39 | Type: AWS::Serverless::Function 40 | Properties: 41 | CodeUri: src/pricing/ 42 | Events: 43 | BackendApi: 44 | Type: Api 45 | Properties: 46 | Path: /backend/pricing 47 | Method: POST 48 | RestApiId: !Ref Api 49 | Policies: 50 | - arn:aws:iam::aws:policy/CloudWatchLambdaInsightsExecutionRolePolicy 51 | 52 | PricingLogGroup: 53 | Type: AWS::Logs::LogGroup 54 | Properties: 55 | LogGroupName: !Sub "/aws/lambda/${PricingFunction}" 56 | RetentionInDays: !Ref RetentionInDays 57 | 58 | ############### 59 | # API GATEWAY # 60 | ############### 61 | 62 | Api: 63 | Type: AWS::Serverless::Api 64 | Properties: 65 | DefinitionBody: 66 | Fn::Transform: 67 | Name: "AWS::Include" 68 | Parameters: 69 | Location: "resources/openapi.yaml" 70 | EndpointConfiguration: REGIONAL 71 | StageName: prod 72 | TracingEnabled: true 73 | 74 | ApiArnParameter: 75 | Type: AWS::SSM::Parameter 76 | Properties: 77 | Name: !Sub /ecommerce/${Environment}/delivery-pricing/api/arn 78 | Type: String 79 | Value: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${Api}/prod" 80 | 81 | ApiUrlParameter: 82 | Type: AWS::SSM::Parameter 83 | Properties: 84 | Name: !Sub /ecommerce/${Environment}/delivery-pricing/api/url 85 | Type: String 86 | Value: !Sub "https://${Api}.execute-api.${AWS::Region}.amazonaws.com/prod" 87 | 88 | ApiDomainParameter: 89 | Type: AWS::SSM::Parameter 90 | Properties: 91 | Name: !Sub /ecommerce/${Environment}/delivery-pricing/api/domain 92 | Type: String 93 | Value: !Sub "${Api}.execute-api.${AWS::Region}.amazonaws.com" 94 | -------------------------------------------------------------------------------- /delivery-pricing/tests/integ/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from fixtures import get_order, get_product, iam_auth # pylint: disable=import-error,no-name-in-module 4 | from helpers import compare_dict, get_parameter # pylint: disable=import-error,no-name-in-module 5 | 6 | 7 | @pytest.fixture(scope="module") 8 | def endpoint_url(): 9 | return get_parameter("/ecommerce/{Environment}/delivery-pricing/api/url") 10 | 11 | 12 | @pytest.fixture(scope="function") 13 | def order(get_order): 14 | return get_order() 15 | 16 | 17 | def test_backend_pricing(endpoint_url, iam_auth, order): 18 | """ 19 | Test POST /backend/pricing 20 | """ 21 | 22 | res = requests.post( 23 | "{}/backend/pricing".format(endpoint_url), 24 | auth=iam_auth(endpoint_url), 25 | json={ 26 | "products": order["products"], 27 | "address": order["address"] 28 | } 29 | ) 30 | 31 | assert res.status_code == 200 32 | body = res.json() 33 | assert "pricing" in body 34 | 35 | 36 | def test_backend_pricing_no_iam(endpoint_url, iam_auth, order): 37 | """ 38 | Test POST /backend/pricing 39 | """ 40 | 41 | res = requests.post( 42 | "{}/backend/pricing".format(endpoint_url), 43 | json={ 44 | "products": order["products"], 45 | "address": order["address"] 46 | } 47 | ) 48 | 49 | assert res.status_code == 403 50 | body = res.json() 51 | assert "message" in body 52 | assert isinstance(body["message"], str) 53 | 54 | 55 | def test_backend_pricing_no_products(endpoint_url, iam_auth, order): 56 | """ 57 | Test POST /backend/pricing 58 | """ 59 | 60 | res = requests.post( 61 | "{}/backend/pricing".format(endpoint_url), 62 | auth=iam_auth(endpoint_url), 63 | json={ 64 | "address": order["address"] 65 | } 66 | ) 67 | 68 | assert res.status_code == 400 69 | body = res.json() 70 | assert "message" in body 71 | assert isinstance(body["message"], str) 72 | assert "products" in body["message"] 73 | 74 | 75 | def test_backend_pricing_no_address(endpoint_url, iam_auth, order): 76 | """ 77 | Test POST /backend/pricing 78 | """ 79 | 80 | res = requests.post( 81 | "{}/backend/pricing".format(endpoint_url), 82 | auth=iam_auth(endpoint_url), 83 | json={ 84 | "products": order["products"] 85 | } 86 | ) 87 | 88 | assert res.status_code == 400 89 | body = res.json() 90 | assert "message" in body 91 | assert isinstance(body["message"], str) 92 | assert "address" in body["message"] -------------------------------------------------------------------------------- /delivery/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /delivery/README.md: -------------------------------------------------------------------------------- 1 | # Delivery service 2 | 3 |

4 | Delivery service architecture diagram 5 |

6 | 7 | ## API 8 | 9 | _None at the moment._ 10 | 11 | ## Events 12 | 13 | _None at the moment._ 14 | 15 | ## SSM Parameters 16 | 17 | _None at the moment._ -------------------------------------------------------------------------------- /delivery/images/delivery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/delivery/images/delivery.png -------------------------------------------------------------------------------- /delivery/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: delivery 2 | dependencies: 3 | - orders 4 | - platform 5 | parameters: 6 | EventBusArn: /ecommerce/{Environment}/platform/event-bus/arn 7 | EventBusName: /ecommerce/{Environment}/platform/event-bus/name 8 | OrdersApiUrl: /ecommerce/{Environment}/orders/api/url 9 | OrdersApiArn: /ecommerce/{Environment}/orders/api/arn -------------------------------------------------------------------------------- /delivery/resources/events.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: Delivery events 4 | version: 1.0.0 5 | license: 6 | name: MIT-0 7 | 8 | paths: {} 9 | 10 | components: 11 | schemas: 12 | DeliveryCompleted: 13 | x-amazon-event-source: ecommerce.delivery 14 | x-amazon-events-detail-type: DeliveryCompleted 15 | description: Event emitted when an order has been delivered 16 | allOf: 17 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 18 | - type: object 19 | properties: 20 | detail: 21 | type: object 22 | required: 23 | - orderId 24 | properties: 25 | orderId: 26 | type: string 27 | format: uuid 28 | description: Identifier for the order relative to the package 29 | example: b2d0c356-f92b-4629-a87f-786331c2842f 30 | address: 31 | $ref: "../../shared/resources/schemas.yaml#/Address" 32 | 33 | DeliveryFailed: 34 | x-amazon-event-source: ecommerce.delivery 35 | x-amazon-events-detail-type: DeliveryFailed 36 | description: | 37 | Event emitted when the delivery service failed to deliver an order. 38 | allOf: 39 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 40 | - type: object 41 | properties: 42 | detail: 43 | type: object 44 | required: 45 | - orderId 46 | properties: 47 | orderId: 48 | type: string 49 | format: uuid 50 | description: Identifier for the order relative to the packaging request 51 | example: b2d0c356-f92b-4629-a87f-786331c2842f 52 | address: 53 | $ref: "../../shared/resources/schemas.yaml#/Address" -------------------------------------------------------------------------------- /delivery/src/on_package_created/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | aws_requests_auth 3 | boto3 4 | requests 5 | -------------------------------------------------------------------------------- /delivery/src/table_update/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /delivery/tests/integ/test_events.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | import pytest 4 | import boto3 5 | from fixtures import listener # pylint: disable=import-error 6 | from helpers import get_parameter # pylint: disable=import-error,no-name-in-module 7 | 8 | 9 | @pytest.fixture 10 | def table_name(): 11 | """ 12 | DynamoDB table name 13 | """ 14 | 15 | return get_parameter("/ecommerce/{Environment}/delivery/table/name") 16 | 17 | 18 | @pytest.fixture 19 | def order(): 20 | return { 21 | "orderId": str(uuid.uuid4()), 22 | "address": { 23 | "name": "John Doe", 24 | "companyName": "Company Inc.", 25 | "streetAddress": "123 Street St", 26 | "postCode": "12345", 27 | "city": "Town", 28 | "state": "State", 29 | "country": "SE", 30 | "phoneNumber": "+123456789" 31 | } 32 | } 33 | 34 | 35 | def test_table_update_completed(table_name, listener, order): 36 | """ 37 | Test that the TableUpdate function reacts to changes to DynamoDB and sends events to EventBridge 38 | """ 39 | 40 | table = boto3.resource("dynamodb").Table(table_name) # pylint: disable=no-member 41 | 42 | # Add a new item 43 | order["status"] = "IN_PROGRESS" 44 | table.put_item(Item=order) 45 | 46 | # Set the item to COMPLETED 47 | order["status"] = "COMPLETED" 48 | 49 | # Listen for messages on EventBridge 50 | listener( 51 | "ecommerce.delivery", 52 | lambda: table.put_item(Item=order), 53 | lambda m: order["orderId"] in m["resources"] and m["detail-type"] == "DeliveryCompleted" 54 | ) 55 | 56 | # Delete the item 57 | table.delete_item(Key={"orderId": order["orderId"]}) 58 | 59 | 60 | def test_table_update_failed(table_name, listener, order): 61 | """ 62 | Test that the TableUpdate function reacts to changes to DynamoDB and sends events to EventBridge 63 | """ 64 | 65 | table = boto3.resource("dynamodb").Table(table_name) # pylint: disable=no-member 66 | 67 | # Add a new item 68 | order["status"] = "NEW" 69 | table.put_item(Item=order) 70 | 71 | # Listen for messages on EventBridge through a listener SQS queue 72 | listener( 73 | "ecommerce.delivery", 74 | lambda: table.delete_item(Key={"orderId": order["orderId"]}), 75 | lambda m: order["orderId"] in m["resources"] and m["detail-type"] == "DeliveryFailed" 76 | ) -------------------------------------------------------------------------------- /delivery/tests/integ/test_on_package_created.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import datetime 4 | import boto3 5 | from boto3.dynamodb.conditions import Key 6 | import pytest 7 | from fixtures import get_order, get_product # pylint: disable=import-error 8 | from helpers import get_parameter # pylint: disable=import-error,no-name-in-module 9 | 10 | 11 | @pytest.fixture(scope="module") 12 | def delivery_table_name(): 13 | return get_parameter("/ecommerce/{Environment}/delivery/table/name") 14 | 15 | 16 | @pytest.fixture(scope="module") 17 | def orders_table_name(): 18 | return get_parameter("/ecommerce/{Environment}/orders/table/name") 19 | 20 | 21 | @pytest.fixture(scope="module") 22 | def event_bus_name(): 23 | """ 24 | Event Bus name 25 | """ 26 | 27 | return get_parameter("/ecommerce/{Environment}/platform/event-bus/name") 28 | 29 | 30 | @pytest.fixture 31 | def order(get_order): 32 | return get_order() 33 | 34 | 35 | @pytest.fixture 36 | def package_created_event(order, event_bus_name): 37 | """ 38 | Event indicating a package was created for delivery 39 | """ 40 | 41 | return { 42 | "Time": datetime.datetime.now(), 43 | "Source": "ecommerce.warehouse", 44 | "Resources": [order["orderId"]], 45 | "DetailType": "PackageCreated", 46 | "Detail": json.dumps(order), 47 | "EventBusName": event_bus_name 48 | } 49 | 50 | 51 | def test_on_package_created(delivery_table_name, orders_table_name, order, package_created_event): 52 | 53 | # create an order 54 | orders_table = boto3.resource("dynamodb").Table(orders_table_name) # pylint: disable=no-member 55 | orders_table.put_item(Item=order) 56 | 57 | eventbridge = boto3.client("events") 58 | 59 | # Send the event on EventBridge 60 | eventbridge.put_events(Entries=[package_created_event]) 61 | 62 | # Wait for PackageCreated event to be processed 63 | time.sleep(5) 64 | 65 | delivery_table = boto3.resource("dynamodb").Table(delivery_table_name) # pylint: disable=no-member 66 | 67 | # Check DynamoDB delivery table to see that a delivery record was created 68 | results = delivery_table.query( 69 | KeyConditionExpression=Key("orderId").eq(order["orderId"]) 70 | ) 71 | 72 | # Assertions that a new package for delivery exists 73 | package = results.get("Items", []) 74 | assert len(package) == 1 75 | assert package[0]['isNew'] == 'true' 76 | assert package[0]['status'] == 'NEW' 77 | 78 | # Cleanup the tables 79 | orders_table.delete_item(Key={"orderId": order["orderId"]}) 80 | delivery_table.delete_item(Key={"orderId": order["orderId"]}) 81 | 82 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Documentation 2 | ============= 3 | 4 | ## How does it work? 5 | 6 | * [Make targets](make_targets.md): commands to build, deploy, test, etc. services 7 | * [Service discovery](service_discovery.md): how to discover other services' resources 8 | * [Service structure](service_structure.md): the key pieces of a service 9 | * [Service-to-service communication](service_to_service.md): how services can communicate with each other 10 | * [Testing](testing.md): the different testing methods used in this project 11 | 12 | ## How do I use it? 13 | 14 | * [Common issues](common_issues.md): Help around common problems you might experience when setting up this project 15 | * [Getting started](getting_started.md): instructions to setup the project 16 | 17 | ## Other documents 18 | 19 | * [Conventions](conventions.md): conventions regarding this project 20 | * [Decision log](decision_log.md): history of all decisions made in this project -------------------------------------------------------------------------------- /docs/common_issues.md: -------------------------------------------------------------------------------- 1 | # Common issues 2 | 3 | ## I get an error "ModuleNotFoundError: No module named '_ctypes'". 4 | 5 | If you are using Linux, you are probably missing the libffi-dev package on your system. You need to install that package using your distribution's package manager. For example `sudo apt-get install libffi-dev` or `sudo yum install libffi-dev`, then re-run `make setup`. 6 | 7 | ## During the build step, I get an error that the md5sum command is not found. 8 | 9 | On MacOS, the md5sum command is not available by default. If you are using homebrew, you can install [the coreutils formula](https://formulae.brew.sh/formula/coreutils) which contains md5sum. 10 | 11 | ## I get "python-build: definition not found: 3.9.7" on make setup. 12 | 13 | Python 3.9.7 is a supported target since [pyenv 2.0.6](https://github.com/pyenv/pyenv/releases/tag/v2.0.6). Make sure you are using version 2.0.6 or newer of Pyenv. 14 | 15 | You can verify the version of pyenv by typing `pyenv --version`. If you have the [pyenv-update plugin](https://github.com/pyenv/pyenv-update), you can run `pyenv update` to update it to the latest version. -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | If you are experience an issue while setting up this project, please take a look at the [Common issues](common_issues.md) section of the documentation. If you cannot find a solution to your problem there, please [create a ticket](https://github.com/aws-samples/aws-serverless-ecommerce-platform/issues/new) explaining the issue that you are experiencing and the steps to reproduce it. 5 | 6 | ## Setup the development environment 7 | 8 | To set up the development environment, you will need to install __pyenv__ on your computer. You can find installation instruction at [https://github.com/pyenv/pyenv#installation](https://github.com/pyenv/pyenv#installation). Please make sure that you have the [required tools and libraries](https://github.com/pyenv/pyenv/wiki/Common-build-problems) installed in your environment. If you're using [AWS Cloud9](https://aws.amazon.com/cloud9/) with Amazon Linux 2, you can use `make setup-cloud9` to install all necessary tools. 9 | 10 | When __pyenv__ is installed, you can run `make setup` to configure the Python environment for this project, including development tools and dependencies. 11 | 12 | You will also need [Node](https://nodejs.org/en/) version 12 or greater, [jq](https://stedolan.github.io/jq/) and __md5sum__. __md5sum__ is not available by default on MacOS but can be installed through the [coreutils formula in homebrew](https://formulae.brew.sh/formula/coreutils). 13 | 14 | ## Deploy the infrastructure on AWS 15 | 16 | If you want to deploy the entire project into your AWS account in a dev environment, you can run the command `make all` in the [root](../) of this project. Please note that this will create an S3 bucket to store artifacts as part of the packaging step. 17 | 18 | If you want to deploy only a specific service and its dependencies, you can use the command `make deps-${SERVICE}`. 19 | 20 | These commands will lint, build, run unit tests, package, deploy and run integration tests on the services. 21 | 22 | ### Deploy the production pipeline 23 | 24 | If you want to deploy a complete pipeline to a production environment, you can run `make bootstrap-pipeline`, which will deploy all services in all environments needed by the pipeline, the CI/CD pipeline itself and seed a CodeCommit repository with the latest commit from this repository. 25 | 26 | When you want to push modifications to AWS, you can run `git push aws HEAD:main`, which will push the latest commit from the current branch to the main branch in the CodeCommit repository. 27 | 28 | ## Useful commands 29 | 30 | All the following commands can be run without the service name (e.g. `make tests-integ` to run integration tests for all services). 31 | 32 | * __`make ci-${SERVICE}`__ (e.g. make ci-products): lint, build and run unit tests for a specific service. 33 | * __`make all-${SERVICE}`__ (e.g. make all-orders): lint, build, run unit tests, package, deploy to AWS and run integration tests for a specific service. 34 | * __`make tests-unit-${SERVICE}`__: run unit tests for a service, useful when you had a bug in the unit tests but don't need to rebuild the Lambda functions. 35 | * __`make tests-integ-${SERVICE}`__: run integration tests for a service, for when you had a bug in the integration tests. 36 | * __`make tests-e2e`__: run end-to-end tests that check if the overall ordering workflows work as expected. 37 | 38 | ## Creating or modifying a service 39 | 40 | To read how you can create a new service or modify an existing one, please read the [service structure documentation](service_structure.md). 41 | -------------------------------------------------------------------------------- /docs/images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/docs/images/architecture.png -------------------------------------------------------------------------------- /docs/images/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/docs/images/flow.png -------------------------------------------------------------------------------- /docs/images/service_discovery_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/docs/images/service_discovery_create.png -------------------------------------------------------------------------------- /docs/images/service_discovery_deploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/docs/images/service_discovery_deploy.png -------------------------------------------------------------------------------- /docs/images/service_discovery_runtime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/docs/images/service_discovery_runtime.png -------------------------------------------------------------------------------- /docs/images/testing_workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/docs/images/testing_workflow.png -------------------------------------------------------------------------------- /docs/make_targets.md: -------------------------------------------------------------------------------- 1 | Make targets 2 | ============ 3 | 4 | ## Project-level targets 5 | 6 | These targets are part of the [Makefile at the root of this repository](../Makefile). 7 | 8 | * __all-$SERVICE__: Lint, build, run unit tests, package, deploy and run integration test for a specific service. You can also run `make all` to run this command against all services. 9 | * __ci-$SERVICE__: Lint, build and run unit tests for a specific service. You can also run `make ci` to run this command against all services. 10 | * __tests-e2e__: Run end-to-end tests using public APIs to validate that the entire platform works as expected. 11 | * __validate__: Check if the necessary tools are installed. 12 | * __setup__: Configure the development environment. 13 | * __activate__: Activate the pyenv virtual environment for Python. 14 | * __requirements__: Install python dependencies for this project. 15 | * __npm-install__: Install node dependencies for this project. 16 | * __bootstrap-pipeline__: Setup all three environments (tests, staging and prod) and the CI/CD pipeline to deploy to production. This also initializes a git repository on [AWS CodeCommit](https://aws.amazon.com/codecommit/) that will be used to trigger the pipeline. 17 | 18 | ## Service-level target 19 | 20 | These targets should be defined in the Makefile of each individual service. You can run the target by running `make $TARGET-$SERVICE` in the root of this project, or `make $TARGET` to run it against all services, e.g. `make tests-unit-all`. 21 | 22 | * __artifacts__: Create a zip file containing the template and artifacts for the CI/CD pipeline. 23 | * __build__: Build the resources to deploy the service, such as Lambda functions, OpenAPI specification, CloudFormation templates, etc. 24 | * __check-deps__: Checks if the dependencies of this service are deployed in the target environment. 25 | * __clean__: Remove the build artifacts for the service. 26 | * __deploy__: Deploy/update the service on AWS, usually create/update the CloudFormation stack. 27 | * __lint__: Run linting checks on source code, CloudFormation template, etc. 28 | * __package__: Package and store artifacts in an S3 bucket in preparation for deployment. 29 | * __teardown__: Tear down resources for that services on AWS, usually delete the CloudFormation stack. 30 | * __tests-integ__: Run integration tests against resources deployed on AWS. 31 | * __tests-unit__: Run unit tests locally. 32 | 33 | ## Environment variables 34 | 35 | You can tweak some of the behaviour by using the following environment variables. 36 | 37 | * __ENVIRONMENT__: The target environment on AWS on which you want to deploy resources or run integration tests. This default to `dev`. 38 | -------------------------------------------------------------------------------- /docs/service_structure.md: -------------------------------------------------------------------------------- 1 | Service structure 2 | ================= 3 | 4 | At minimum, a service should contain two files: __Makefile__ and __metadata.yaml__. The Makefile contains instructions to build, package, deploy, test a given service using [GNU make](https://www.gnu.org/software/make/). The metadata.yaml file contains information such as dependencies and CloudFormation parameters. 5 | 6 | When using one of the [default Makefiles](../shared/makefiles/), there might be other files which will be described throughout this document. 7 | 8 | For the list of targets for Makefile, please refer to the [Make targets](make_targets.md) page. 9 | 10 | ## metadata.yaml 11 | 12 | The __metadata.yaml__ files contains information such as its name, dependencies and feature flags. See the schema definition to know what you can use for your service. Here's an example of a metadata file: 13 | 14 | ```yaml 15 | name: my-service 16 | 17 | # These will be used to check whether all dependencies for a service are 18 | # deployed and check if there are any circular dependency. This allows to 19 | # redeploy the entire infrastructure from scratch. 20 | dependencies: 21 | - products 22 | - platform 23 | 24 | # This section is used for service discovery. See the 'Service discovery' page in the documentation for more information. 25 | parameters: 26 | EventBusName: /ecommerce/{Environment}/platform/event-bus/name 27 | ProductsApiArn: /ecommerce/{Environment}/products/api/arn 28 | ProductsApiUrl: /ecommerce/{Environment}/products/api/url 29 | 30 | # Boolean flags regarding a specific service. For example, the pipeline service 31 | # does not support environments, or some services might not support tests. 32 | # This section is optional and the values provided there are the default values. 33 | flags: 34 | environment: true 35 | skip-tests: false 36 | ``` 37 | 38 | ## template.yaml 39 | 40 | _This section is applicable when using one of the [default Makefiles](../shared/makefiles/). If you're using a custom Makefile, you have the freedom to structure this section as you see fit._ 41 | 42 | The __template.yaml__ file is the CloudFormation template that defines the resources that are part of the service. 43 | 44 | ## resources/ folder 45 | 46 | _This section is applicable when using one of the [default Makefiles](../shared/makefiles/). If you're using a custom Makefile, you have the freedom to structure this section as you see fit._ 47 | 48 | This folder contains resource files such as OpenAPI document for API Gateway REST APIs or EventBridge event schemas, nested CloudFormation templates, etc. 49 | 50 | By convention, the API Gateway REST API document should be named __resources/openapi.yaml__ and the EventBridge event schema documents should be named __resources/events.yaml__. These files are linted automatically as part of the process using the lint command. See the [testing](testing.md) section of the documentation to learn more. 51 | 52 | ## src/ folder 53 | 54 | _This section is applicable when using one of the [default Makefiles](../shared/makefiles/). If you're using a custom Makefile, you have the freedom to structure this section as you see fit._ 55 | 56 | This section contains the source code of Lambda functions. The code should not be placed directly into this folder but Lambda functions should have dedicated folders within it. 57 | 58 | ## tests/ folder 59 | 60 | _This section is applicable when using one of the [default Makefiles](../shared/makefiles/). If you're using a custom Makefile, you have the freedom to structure this section as you see fit._ 61 | 62 | The tests/ folder contains unit and integration tests for the service. See the [testing section](testing.md) of the documentation to learn more. -------------------------------------------------------------------------------- /docs/service_to_service.md: -------------------------------------------------------------------------------- 1 | Service-to-service communication 2 | ================================ 3 | 4 | Services can perform four basic operations in the context of service-to-service communication: query, command, emit and react. 5 | 6 | A service __queries__ another when it needs to retrieve information synchronously from that service but this does not result in any state modification. For example, the _delivery_ service querying the _orders_ service to retrieve the delivery address for a given order, or the _orders_ service querying the _products_ service to validate the list of products in an order creation request. 7 | 8 | A service sends a __command__ to another service when it needs that other service to perform an action and needs to know immediately if it happened. For example, the _payment_ service sends a command to _payment-3p_ to process a payment. If this fails, the _payment_ service need to retry or alert. 9 | 10 | A service __emits__ an event when it needs to propagate that something happened but does not care about what other services do with this information. For example, when an order is created, the _orders_ service is not responsible for making sure that the _warehouse_ service processes that event correctly, but the latter needs to know that this happened to create a packaging request. 11 | 12 | Finally, a service __reacts__ to an event when it listens and processes an event from another service. 13 | 14 | The first two operations (query and command) are often __synchronous__ as they require an immediate response from the other service, while the latter two are __asynchronous__. 15 | 16 | By using asynchronous messaging whenever possible, we decouple services from any availability and latency issues. A service that emits an event should not be responsible to track which services need to receive that event and whether they processed it correctly. For this reason, the platform leverages [Amazon EventBridge](eventbridge.md) heavily. A service will send an event to EventBridge, which uses rules and targets to know where to route events. -------------------------------------------------------------------------------- /environments.yaml: -------------------------------------------------------------------------------- 1 | dev: 2 | parameters: 3 | LogLevel: DEBUG 4 | RetentionInDays: "7" 5 | 6 | tests: 7 | parameters: 8 | LogLevel: DEBUG 9 | RetentionInDays: "30" 10 | 11 | staging: 12 | parameters: 13 | LogLevel: DEBUG 14 | RetentionInDays: "30" 15 | flags: 16 | can-tests-integ: false 17 | 18 | prod: 19 | parameters: 20 | LogLevel: INFO 21 | RetentionInDays: "30" 22 | flags: 23 | can-tests-integ: false 24 | can-tests-e2e: false 25 | is-prod: true -------------------------------------------------------------------------------- /frontend-api/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build cloudformation ${SERVICE} 13 | .PHONY: build 14 | 15 | check-deps: 16 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 17 | 18 | clean: 19 | @${ROOT}/tools/clean ${SERVICE} 20 | 21 | deploy: 22 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 23 | 24 | lint: 25 | @${ROOT}/tools/lint cloudformation ${SERVICE} 26 | @${ROOT}/tools/lint openapi ${SERVICE} 27 | 28 | package: 29 | @${ROOT}/tools/package cloudformation ${SERVICE} 30 | 31 | teardown: 32 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 33 | 34 | tests-integ: 35 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 36 | 37 | tests-unit: 38 | @echo "Skipping unit tests" -------------------------------------------------------------------------------- /frontend-api/README.md: -------------------------------------------------------------------------------- 1 | Frontend API service 2 | ================ 3 | 4 |

5 | Frontend architecture diagram 6 |

7 | 8 | ## API 9 | 10 | See [resources/api.graphql](resources/api.graphql) for the GraphQL API. 11 | 12 | ## Events 13 | 14 | _None at the moment._ 15 | 16 | ## SSM Parameters 17 | 18 | This service defines the following SSM parameters: 19 | 20 | * `/ecommerce/{Environment}/frontend-api/api/arn`: ARN for the GraphQL API 21 | * `/ecommerce/{Environment}/frontend-api/api/id`: ID of the GraphQL API 22 | * `/ecommerce/{Environment}/frontend-api/api/url`: URL of the GraphQL API -------------------------------------------------------------------------------- /frontend-api/images/frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/frontend-api/images/frontend.png -------------------------------------------------------------------------------- /frontend-api/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: frontend-api 2 | dependencies: 3 | - delivery 4 | - delivery-pricing 5 | - orders 6 | - products 7 | - users 8 | - warehouse 9 | parameters: 10 | DeliveryPricingApiArn: /ecommerce/{Environment}/delivery-pricing/api/arn 11 | DeliveryPricingApiDomain: /ecommerce/{Environment}/delivery-pricing/api/domain 12 | DeliveryTableName: /ecommerce/{Environment}/delivery/table/name 13 | OrdersTableName: /ecommerce/{Environment}/orders/table/name 14 | OrdersCreateOrderArn: /ecommerce/{Environment}/orders/create-order/arn 15 | ProductsTableName: /ecommerce/{Environment}/products/table/name 16 | UserPoolId: /ecommerce/{Environment}/users/user-pool/id 17 | WarehouseTableName: /ecommerce/{Environment}/warehouse/table/name -------------------------------------------------------------------------------- /orders/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /orders/README.md: -------------------------------------------------------------------------------- 1 | Orders service 2 | ============== 3 | 4 | The __orders__ service acts a the single source of truth for data related to an order: delivery address, products, user, etc. 5 | 6 | As orders are (somewhat) immutables, information about the delivery address and products are replicated within the order to ensure consistency over time. In some cases (e.g. if a product cannot be packaged, is substituted to another item, etc.), the order might be modified. This service also monitors the change in state from other services (mainly delivery and warehouse) so that users can probe the status of their order. 7 | 8 | When a user creates an order, this service also acts as a central gate that contacts other services to verify that the user input is valid. For example, this checks that the products exist and that the prices are correct. If any check fails, this will return an error to the end-user. 9 | 10 |

11 | Orders architecture diagram 12 |

13 | 14 | ## Monitoring and KPIs 15 | 16 | On the business level, the main key performance indicators (KPIs) are the number of order created. The service should also track the number of orders fulfilled and failed. However, these metrics are the result of actions from other services. 17 | 18 | From an operational point of view, the latency or errors from the CreateUpdate Lambda function are directly visible to end-users, and therefore should be measured closely. For this purpose, there is an alarm that is breached if the latency exceeds 1 second at p99, meaning that more than 1% of all requests take more than 1 second to complete. 19 | 20 | The number of errors from all components and latency for the GetOrder (internal API call) is also tracked as a secondary operational metric. 21 | 22 |

23 | Orders monitoring dashboard 24 |

25 | 26 | ## API 27 | 28 | See [resources/openapi.yaml](resources/openapi.yaml) for a list of available API paths. 29 | 30 | ## Events 31 | 32 | See [resources/events.yaml](resources/events.yaml) for a list of available events. 33 | 34 | ## SSM Parameters 35 | 36 | This service defines the following SSM parameters: 37 | 38 | * `/ecommerce/{Environment}/orders/api/url`: URL for the API Gateway 39 | * `/ecommerce/{Environment}/orders/api/arn`: ARN for the API Gateway 40 | * `/ecommerce/{Environment}/orders/create-order/arn`: ARN for the Create Order Lambda Function -------------------------------------------------------------------------------- /orders/images/monitoring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/orders/images/monitoring.png -------------------------------------------------------------------------------- /orders/images/orders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/orders/images/orders.png -------------------------------------------------------------------------------- /orders/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: orders 2 | dependencies: 3 | - delivery-pricing 4 | - platform 5 | - products 6 | parameters: 7 | DeliveryApiArn: /ecommerce/{Environment}/delivery-pricing/api/arn 8 | DeliveryApiUrl: /ecommerce/{Environment}/delivery-pricing/api/url 9 | EventBusArn: /ecommerce/{Environment}/platform/event-bus/arn 10 | EventBusName: /ecommerce/{Environment}/platform/event-bus/name 11 | PaymentApiArn: /ecommerce/{Environment}/payment/api/arn 12 | PaymentApiUrl: /ecommerce/{Environment}/payment/api/url 13 | ProductsApiArn: /ecommerce/{Environment}/products/api/arn 14 | ProductsApiUrl: /ecommerce/{Environment}/products/api/url -------------------------------------------------------------------------------- /orders/resources/events.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: Orders events 4 | version: 1.0.0 5 | license: 6 | name: MIT-0 7 | 8 | paths: {} 9 | 10 | components: 11 | schemas: 12 | OrderCreated: 13 | x-amazon-events-source: ecommerce.order 14 | x-amazon-events-detail-type: OrderCreated 15 | description: Event emitted when an order is created. 16 | allOf: 17 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 18 | - type: object 19 | properties: 20 | detail: 21 | $ref: "../../shared/resources/schemas.yaml#/Order" 22 | 23 | OrderModified: 24 | x-amazon-events-source: ecommerce.order 25 | x-amazon-events-detail-type: OrderModified 26 | description: Event emitted when an order is modified. 27 | allOf: 28 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 29 | - type: object 30 | properties: 31 | detail: 32 | type: object 33 | properties: 34 | old: 35 | $ref: "../../shared/resources/schemas.yaml#/Order" 36 | new: 37 | $ref: "../../shared/resources/schemas.yaml#/Order" 38 | changed: 39 | type: array 40 | description: Array containing field names that were changed 41 | items: 42 | type: string 43 | 44 | OrderDeleted: 45 | x-amazon-events-source: ecommerce.order 46 | x-amazon-events-detail-type: OrderDeleted 47 | description: Event emitted when an order is deleted. 48 | allOf: 49 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 50 | - type: object 51 | properties: 52 | detail: 53 | $ref: "../../shared/resources/schemas.yaml#/Order" 54 | -------------------------------------------------------------------------------- /orders/resources/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: 4 | Fn::Sub: "${AWS::StackName}-api" 5 | version: 1.0.0 6 | description: Orders service API definition 7 | license: 8 | name: MIT-0 9 | url: https://github.com/aws/mit-0 10 | 11 | paths: 12 | /backend/{orderId}: 13 | get: 14 | description: | 15 | Retrieve a single order. 16 | 17 | This is a backend operation that requires IAM credentials. 18 | operationId: backendGetOrder 19 | parameters: 20 | - name: orderId 21 | in: path 22 | description: Order ID in UUID format 23 | required: true 24 | schema: 25 | type: string 26 | format: uuid 27 | responses: 28 | 200: 29 | description: Order item 30 | content: 31 | application/json: 32 | schema: 33 | type: object 34 | properties: 35 | order: 36 | $ref: "../../shared/resources/schemas.yaml#/Order" 37 | default: 38 | description: Something went wrong 39 | content: 40 | application/json: 41 | schema: 42 | $ref: "../../shared/resources/schemas.yaml#/Message" 43 | security: 44 | - AWS_IAM: [] 45 | x-amazon-apigateway-integration: 46 | httpMethod: "POST" 47 | type: aws_proxy 48 | uri: 49 | Fn::Sub: "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetOrderFunction.Arn}/invocations" 50 | 51 | 52 | components: 53 | schemas: 54 | OrderRequest: 55 | type: object 56 | description: Information necessary for creating an order. 57 | properties: 58 | products: 59 | type: array 60 | items: 61 | $ref: "../../shared/resources/schemas.yaml#/Product" 62 | address: 63 | $ref: "../../shared/resources/schemas.yaml#/Address" 64 | deliveryPrice: 65 | type: integer 66 | minimum: 0 67 | total: 68 | type: integer 69 | minimum: 0 70 | securitySchemes: 71 | AWS_IAM: 72 | type: apiKey 73 | name: Authorization 74 | in: header 75 | x-amazon-apigateway-authtype: awsSigv4 -------------------------------------------------------------------------------- /orders/src/create_order/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | aws_requests_auth 3 | boto3 4 | jsonschema==3.2.0 5 | requests 6 | -------------------------------------------------------------------------------- /orders/src/create_order/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ 4 | "userId", "products", "address", "deliveryPrice", "paymentToken" 5 | ], 6 | "properties": { 7 | "userId": { 8 | "type": "string" 9 | }, 10 | "products": { 11 | "type": "array", 12 | "items": { 13 | "type": "object", 14 | "required": [ 15 | "productId", "name", "price", "package" 16 | ], 17 | "properties": { 18 | "productId": { 19 | "type": "string" 20 | }, 21 | "name": { 22 | "type": "string" 23 | }, 24 | "price": { 25 | "type": "integer" 26 | }, 27 | "package": { 28 | "type": "object", 29 | "required": ["width", "length", "height", "weight"], 30 | "properties": { 31 | "width": { 32 | "type": "integer" 33 | }, 34 | "length": { 35 | "type": "integer" 36 | }, 37 | "height": { 38 | "type": "integer" 39 | }, 40 | "weight": { 41 | "type": "integer" 42 | } 43 | } 44 | }, 45 | "quantity": { 46 | "type": "integer" 47 | } 48 | } 49 | } 50 | }, 51 | "address": { 52 | "type": "object", 53 | "required": ["name", "streetAddress", "city", "country", "phoneNumber"], 54 | "properties": { 55 | "name": { 56 | "type": "string" 57 | }, 58 | "companyName": { 59 | "type": "string" 60 | }, 61 | "streetAddress": { 62 | "type": "string" 63 | }, 64 | "postCode": { 65 | "type": "string" 66 | }, 67 | "city": { 68 | "type": "string" 69 | }, 70 | "state": { 71 | "type": "string" 72 | }, 73 | "country": { 74 | "type": "string" 75 | }, 76 | "phoneNumber": { 77 | "type": "string" 78 | } 79 | } 80 | }, 81 | "deliveryPrice": { 82 | "type": "integer" 83 | }, 84 | "paymentToken": { 85 | "type": "string" 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /orders/src/get_order/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | GetOrdersFunction 3 | """ 4 | 5 | 6 | import os 7 | from typing import Optional 8 | import boto3 9 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 10 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 11 | from ecom.apigateway import iam_user_id, response # pylint: disable=import-error 12 | 13 | 14 | ENVIRONMENT = os.environ["ENVIRONMENT"] 15 | TABLE_NAME = os.environ["TABLE_NAME"] 16 | 17 | 18 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 19 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 20 | logger = Logger() # pylint: disable=invalid-name 21 | tracer = Tracer() # pylint: disable=invalid-name 22 | 23 | 24 | @tracer.capture_method 25 | def get_order(order_id: str) -> Optional[dict]: 26 | """ 27 | Returns order from DynamoDB 28 | """ 29 | 30 | # Send request to DynamoDB 31 | res = table.get_item(Key={"orderId": order_id}) # pylint: disable=no-member 32 | order = res.get("Item", None) 33 | 34 | # Log retrieved informations 35 | if order is None: 36 | logger.warning({ 37 | "message": "No order retrieved for the order ID", 38 | "orderId": order_id 39 | }) 40 | else: 41 | logger.debug({ 42 | "message": "Order retrieved", 43 | "order": order 44 | }) 45 | 46 | return order 47 | 48 | 49 | @logger.inject_lambda_context 50 | @tracer.capture_lambda_handler 51 | def handler(event, _): 52 | """ 53 | Lambda function handler for GetOrder 54 | """ 55 | 56 | logger.debug({"message": "Event received", "event": event}) 57 | 58 | # Retrieve the userId 59 | user_id = iam_user_id(event) 60 | if user_id is not None: 61 | logger.info({"message": "Received get order from IAM user", "userArn": user_id}) 62 | tracer.put_annotation("userArn", user_id) 63 | tracer.put_annotation("iamUser", True) 64 | iam_user = True 65 | else: 66 | logger.warning({"message": "User ID not found in event"}) 67 | return response("Unauthorized", 401) 68 | 69 | # Retrieve the orderId 70 | try: 71 | order_id = event["pathParameters"]["orderId"] 72 | except (KeyError, TypeError): 73 | logger.warning({"message": "Order ID not found in event"}) 74 | return response("Missing orderId", 400) 75 | 76 | # Set a trace annotation 77 | tracer.put_annotation("orderId", order_id) 78 | 79 | # Retrieve the order from DynamoDB 80 | order = get_order(order_id) 81 | 82 | # Check that the order can be sent to the user 83 | # This includes both when the item is not found and when the user IDs do 84 | # not match. 85 | if order is None or (not iam_user and user_id != order["userId"]): 86 | return response("Order not found", 404) 87 | 88 | # Send the response 89 | return response(order) 90 | -------------------------------------------------------------------------------- /orders/src/get_order/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /orders/src/on_events/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | -------------------------------------------------------------------------------- /orders/src/table_update/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | TableUpdateFunction 3 | """ 4 | 5 | 6 | import os 7 | from typing import List 8 | import boto3 9 | from boto3.dynamodb.types import TypeDeserializer 10 | from aws_lambda_powertools.tracing import Tracer 11 | from aws_lambda_powertools.logging.logger import Logger 12 | from ecom.eventbridge import ddb_to_event # pylint: disable=import-error 13 | 14 | 15 | ENVIRONMENT = os.environ["ENVIRONMENT"] 16 | EVENT_BUS_NAME = os.environ["EVENT_BUS_NAME"] 17 | 18 | 19 | eventbridge = boto3.client("events") # pylint: disable=invalid-name 20 | type_deserializer = TypeDeserializer() # pylint: disable=invalid-name 21 | logger = Logger() # pylint: disable=invalid-name 22 | tracer = Tracer() # pylint: disable=invalid-name 23 | 24 | 25 | @tracer.capture_method 26 | def send_events(events: List[dict]): 27 | """ 28 | Send events to EventBridge 29 | """ 30 | 31 | logger.info("Sending %d events to EventBridge", len(events)) 32 | # EventBridge only supports batches of up to 10 events 33 | for i in range(0, len(events), 10): 34 | eventbridge.put_events(Entries=events[i:i+10]) 35 | 36 | 37 | @logger.inject_lambda_context 38 | @tracer.capture_lambda_handler 39 | def handler(event, _): 40 | """ 41 | Lambda function handler for Orders Table stream 42 | """ 43 | 44 | logger.debug({ 45 | "message": "Input event", 46 | "event": event 47 | }) 48 | 49 | logger.debug({ 50 | "message": "Records received", 51 | "records": event.get("Records", []) 52 | }) 53 | 54 | events = [ 55 | ddb_to_event(record, EVENT_BUS_NAME, "ecommerce.orders", "Order", "orderId") 56 | for record in event.get("Records", []) 57 | ] 58 | 59 | logger.info("Received %d event(s)", len(events)) 60 | logger.debug({ 61 | "message": "Events processed from records", 62 | "events": events 63 | }) 64 | 65 | send_events(events) 66 | -------------------------------------------------------------------------------- /orders/src/table_update/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /orders/tests/integ/test_events.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import uuid 4 | import pytest 5 | import boto3 6 | from fixtures import get_order, get_product, listener # pylint: disable=import-error 7 | from helpers import get_parameter # pylint: disable=import-error,no-name-in-module 8 | 9 | 10 | @pytest.fixture 11 | def table_name(): 12 | """ 13 | DynamoDB table name 14 | """ 15 | 16 | return get_parameter("/ecommerce/{Environment}/orders/table/name") 17 | 18 | 19 | @pytest.fixture 20 | def order(get_order): 21 | return get_order() 22 | 23 | 24 | def test_table_update(table_name, listener, order): 25 | """ 26 | Test that the TableUpdate function reacts to changes to DynamoDB and sends 27 | events to EventBridge 28 | """ 29 | table = boto3.resource("dynamodb").Table(table_name) # pylint: disable=no-member 30 | 31 | # Listen for messages on EventBridge 32 | listener( 33 | "ecommerce.orders", 34 | # Add a new item 35 | lambda: table.put_item(Item=order), 36 | lambda m: order["orderId"] in m["resources"] and m["detail-type"] == "OrderCreated" 37 | ) 38 | 39 | # Listen for messages on EventBridge 40 | listener( 41 | "ecommerce.orders", 42 | # Change the status to cancelled 43 | lambda: table.update_item( 44 | Key={"orderId": order["orderId"]}, 45 | UpdateExpression="set #s = :s", 46 | ExpressionAttributeNames={ 47 | "#s": "status" 48 | }, 49 | ExpressionAttributeValues={ 50 | ":s": "CANCELLED" 51 | } 52 | ), 53 | lambda m: order["orderId"] in m["resources"] and m["detail-type"] == "OrderModified" 54 | ) 55 | 56 | # Listen for messages on EventBridge 57 | listener( 58 | "ecommerce.orders", 59 | # Delete the item 60 | lambda: table.delete_item(Key={"orderId": order["orderId"]}), 61 | lambda m: order["orderId"] in m["resources"] and m["detail-type"] == "OrderDeleted" 62 | ) -------------------------------------------------------------------------------- /payment-3p/.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 | 10 | # Prevent ignoring the 'lib' folder 11 | !lib -------------------------------------------------------------------------------- /payment-3p/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /payment-3p/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | export service_dir ?= ${ROOT}/${SERVICE} 6 | 7 | export AWS_SDK_LOAD_CONFIG = true 8 | 9 | _install: 10 | npm install 11 | 12 | artifacts: 13 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 14 | 15 | build: _install 16 | # Copy files to the build folder 17 | if [ ! -d build/ ]; then \ 18 | mkdir build/ ; \ 19 | fi 20 | cp -rp src/ build/src/ 21 | npx cdk synth --verbose > build/template.yaml 22 | 23 | # Install packages and transpile to Javascript for each Lambda function 24 | for function_folder in build/src/* ; do \ 25 | cd $$function_folder ; \ 26 | npm i ; \ 27 | cd ${CURDIR} ; \ 28 | npx tsc -p $$function_folder ; \ 29 | done 30 | 31 | # Package artifacts 32 | if [ ! -d build/artifacts/ ]; then \ 33 | mkdir build/artifacts/ ; \ 34 | fi 35 | 36 | ${ROOT}/tools/helpers/build_artifacts . build/artifacts/ 37 | .PHONY: build 38 | 39 | check-deps: 40 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 41 | 42 | clean: 43 | @${ROOT}/tools/clean ${SERVICE} 44 | 45 | deploy: 46 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 47 | 48 | lint: _install 49 | npm run tests-cdk 50 | 51 | package: 52 | @${ROOT}/tools/package cloudformation ${SERVICE} 53 | 54 | teardown: 55 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 56 | 57 | tests-integ: _install 58 | if yq -r ' .'${ENVIRONMENT}'.flags["can-tests-e2e"] | if . == null then true else . end ' ${ROOT}/environments.yaml | grep -q true; then \ 59 | npm run tests-integ ; \ 60 | else \ 61 | echo "The environment ${ENVIRONMENT} does not support tests. Skipping" ; \ 62 | fi 63 | 64 | tests-unit: 65 | npm run tests-unit -------------------------------------------------------------------------------- /payment-3p/README.md: -------------------------------------------------------------------------------- 1 | # 3rd party payment service 2 | 3 | This service simulates a third party backend system. 4 | 5 | ## API 6 | 7 | See [resources/openapi.yaml](resources/openapi.yaml) for a list of available API paths. 8 | 9 | ## Events 10 | 11 | _This service does not emit any event._ 12 | 13 | ## SSM Parameters 14 | 15 | This service defines the following SSM parameters: 16 | 17 | * `/ecommerce/{Environment}/payment-3p/api/url`: URL for the API Gateway -------------------------------------------------------------------------------- /payment-3p/bin/payment-3p.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import { Payment3PStack } from '../lib/payment-3p-stack'; 5 | 6 | const app = new cdk.App(); 7 | new Payment3PStack(app, 'Payment3PStack'); 8 | app.synth(); -------------------------------------------------------------------------------- /payment-3p/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "@aws-cdk/core:enableStackNameDuplicates": "true", 3 | "aws-cdk:enableDiffNoFail": "true" 4 | } 5 | -------------------------------------------------------------------------------- /payment-3p/cdk.jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/tests/lint/" 4 | ], 5 | testMatch: [ '**/*.test.ts'], 6 | "transform": { 7 | "^.+\\.tsx?$": "ts-jest" 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /payment-3p/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node bin/payment-3p.ts" 3 | } 4 | -------------------------------------------------------------------------------- /payment-3p/integ.jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: [ '/tests/integ/**/*.test.ts'], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | reporters: [ 7 | "default", 8 | ["jest-junit", {outputDirectory: "../reports/", outputName: `payment-3p-integ.xml`}] 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /payment-3p/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: payment-3p -------------------------------------------------------------------------------- /payment-3p/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payment-3p", 3 | "version": "0.1.0", 4 | "bin": { 5 | "payment-3p": "bin/payment-3p.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "tests-cdk": "jest --projects cdk.jest.config.js", 11 | "tests-unit": "jest --projects unit.jest.config.js", 12 | "tests-integ": "AWS_SDK_LOAD_CONFIG=true jest --projects integ.jest.config.js", 13 | "cdk": "cdk" 14 | }, 15 | "dependencies": { 16 | "@aws-cdk/assert": "1.132.0", 17 | "@aws-cdk/aws-dynamodb": "^1.132.0", 18 | "@aws-cdk/aws-logs": "^1.132.0", 19 | "@aws-cdk/aws-sam": "^1.132.0", 20 | "@aws-cdk/aws-ssm": "^1.132.0", 21 | "@aws-cdk/core": "1.132.0", 22 | "@types/chai": "^4.2.10", 23 | "@types/jest": "^27.0.2", 24 | "@types/node": "^10.17.17", 25 | "@types/uuid": "^7.0.0", 26 | "aws-cdk": "^1.132.0", 27 | "aws-sdk-mock": "^5.1.0", 28 | "axios": "^0.21.4", 29 | "babel": "^6.23.0", 30 | "babel-jest": "^27.2.4", 31 | "chai": "^4.2.0", 32 | "jest": "^27.2.4", 33 | "jest-junit": "^13.0.0", 34 | "source-map-support": "^0.5.16", 35 | "ts-jest": "^27.0.5", 36 | "ts-node": "^8.1.0", 37 | "typescript": "^4.1.3", 38 | "uuid": "^8.3.2" 39 | }, 40 | "repository": "https://github.com/aws-samples/aws-serverless-ecommerce-platform", 41 | "license": "MIT-0" 42 | } 43 | -------------------------------------------------------------------------------- /payment-3p/src/cancelPayment/index.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 2 | const TABLE_NAME = process.env.TABLE_NAME || "TABLE_NAME"; 3 | const client = new DocumentClient(); 4 | 5 | // Generate a response for API Gateway 6 | export function response( 7 | body: string | object, 8 | statusCode: number = 200, 9 | allowOrigin: string = "*", 10 | allowHeaders: string = "Content-Type,X-Amz-Date,Authorization,X-Api-Key,x-requested-with", 11 | allowMethods: string = "GET,POST,PUT,DELETE,OPTIONS" 12 | ) { 13 | if (typeof body === "string") { 14 | body = { message: body }; 15 | } 16 | return { 17 | statusCode: statusCode, 18 | body: JSON.stringify(body), 19 | headers: { 20 | "Access-Control-Allow-Headers": allowHeaders, 21 | "Access-Control-Allow-Origin": allowOrigin, 22 | "Access-Control-Allow-Methods": allowMethods 23 | } 24 | } 25 | } 26 | 27 | // Process paymentToken 28 | export async function cancelPayment(client: DocumentClient, paymentToken: string) : Promise { 29 | try { 30 | const response = await client.get({ 31 | TableName: TABLE_NAME, 32 | Key: { paymentToken } 33 | }).promise(); 34 | if (!response.Item) 35 | return false; 36 | 37 | await client.delete({ 38 | TableName: TABLE_NAME, 39 | Key: { paymentToken } 40 | }).promise(); 41 | return true; 42 | } catch (dbError) { 43 | console.log({"message": "Error cancelling the paymentToken from the database", "errormsg": dbError}); 44 | return null; 45 | } 46 | } 47 | 48 | // Lambda function handler 49 | export const handler = async (event: any = {}) : Promise => { 50 | // Load body 51 | if (!event.body) 52 | return response("Missing body in event.", 400); 53 | const body = JSON.parse(event.body); 54 | 55 | // Validate body 56 | if (!body.paymentToken) 57 | return response("Missing 'paymentToken' in request body.", 400); 58 | if (typeof body.paymentToken !== "string") 59 | return response("'paymentToken' is not a string.", 400); 60 | 61 | // Check token 62 | const result = await exports.cancelPayment(client, body.paymentToken); 63 | if (result === null) { 64 | return response("Internal error", 500); 65 | } else { 66 | return response({"ok": result}); 67 | } 68 | } -------------------------------------------------------------------------------- /payment-3p/src/cancelPayment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check", 3 | "version": "1.0.0", 4 | "description": "Check Function", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/aws-samples/aws-serverless-ecommerce-platform.git" 9 | }, 10 | "license": "MIT-0", 11 | "dependencies": { 12 | "@types/node": "^13.9.1", 13 | "aws-sdk": "^2.814.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /payment-3p/src/cancelPayment/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false, 20 | "typeRoots": ["./node_modules/@types"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /payment-3p/src/check/index.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 2 | const TABLE_NAME = process.env.TABLE_NAME || "TABLE_NAME"; 3 | const client = new DocumentClient(); 4 | 5 | // Generate a response for API Gateway 6 | export function response( 7 | body: string | object, 8 | statusCode: number = 200, 9 | allowOrigin: string = "*", 10 | allowHeaders: string = "Content-Type,X-Amz-Date,Authorization,X-Api-Key,x-requested-with", 11 | allowMethods: string = "GET,POST,PUT,DELETE,OPTIONS" 12 | ) { 13 | if (typeof body === "string") { 14 | body = { message: body }; 15 | } 16 | return { 17 | statusCode: statusCode, 18 | body: JSON.stringify(body), 19 | headers: { 20 | "Access-Control-Allow-Headers": allowHeaders, 21 | "Access-Control-Allow-Origin": allowOrigin, 22 | "Access-Control-Allow-Methods": allowMethods 23 | } 24 | } 25 | } 26 | 27 | // Check if the token is valid 28 | export async function checkToken(client : DocumentClient, paymentToken: string, amount: number) : Promise { 29 | try { 30 | const response = await client.get({ 31 | TableName: TABLE_NAME, 32 | Key: { paymentToken } 33 | }).promise(); 34 | if (!response.Item) 35 | return false; 36 | return response.Item.amount >= amount; 37 | } catch (dbError) { 38 | console.log({"message": "Error retrieving the paymentToken from the database", "errormsg": dbError}); 39 | return null; 40 | } 41 | } 42 | 43 | // Lambda function handler 44 | export const handler = async (event: any = {}) : Promise => { 45 | // Load body 46 | if (!event.body) 47 | return response("Missing body in event.", 400); 48 | const body = JSON.parse(event.body); 49 | 50 | // Validate body 51 | if (!body.paymentToken) 52 | return response("Missing 'paymentToken' in request body.", 400); 53 | if (typeof body.paymentToken !== "string") 54 | return response("'paymentToken' is not a string.", 400); 55 | if (!body.amount) 56 | return response("Missing 'amount' in request body.", 400); 57 | if (typeof body.amount !== "number") 58 | return response("'amount' is not a number.", 400); 59 | if (body.amount < 0) 60 | return response("'amount' should be a positive number.", 400); 61 | 62 | // Check token 63 | const result = await exports.checkToken(client, body.paymentToken, body.amount); 64 | if (result === null) { 65 | return response("Internal error", 500); 66 | } else { 67 | return response({"ok": result}); 68 | } 69 | } -------------------------------------------------------------------------------- /payment-3p/src/check/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check", 3 | "version": "1.0.0", 4 | "description": "Check Function", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/aws-samples/aws-serverless-ecommerce-platform.git" 9 | }, 10 | "license": "MIT-0", 11 | "dependencies": { 12 | "@types/node": "^13.9.1", 13 | "aws-sdk": "^2.814.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /payment-3p/src/check/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false, 20 | "typeRoots": ["./node_modules/@types"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /payment-3p/src/preauth/index.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | const TABLE_NAME = process.env.TABLE_NAME || "TABLE_NAME"; 4 | 5 | const client = new DocumentClient(); 6 | 7 | // Generate a response for API Gateway 8 | export function response( 9 | body: string | object, 10 | statusCode: number = 200, 11 | allowOrigin: string = "*", 12 | allowHeaders: string = "Content-Type,X-Amz-Date,Authorization,X-Api-Key,x-requested-with", 13 | allowMethods: string = "GET,POST,PUT,DELETE,OPTIONS" 14 | ) { 15 | if (typeof body === "string") { 16 | body = { message: body }; 17 | } 18 | return { 19 | statusCode: statusCode, 20 | body: JSON.stringify(body), 21 | headers: { 22 | "Access-Control-Allow-Headers": allowHeaders, 23 | "Access-Control-Allow-Origin": allowOrigin, 24 | "Access-Control-Allow-Methods": allowMethods 25 | } 26 | } 27 | } 28 | 29 | // Generate a token for the transaction 30 | export async function genToken(client: DocumentClient, cardNumber: string, amount: number) : Promise { 31 | var paymentToken = uuidv4(); 32 | try { 33 | await client.put({ 34 | TableName: TABLE_NAME, 35 | Item: { 36 | paymentToken: paymentToken, 37 | amount: amount 38 | } 39 | }).promise(); 40 | return paymentToken; 41 | } catch (dbError) { 42 | console.log({"message": "Error storing payment token in database", "errormsg": dbError}); 43 | return null; 44 | } 45 | } 46 | 47 | // Lambda function handler 48 | export const handler = async (event: any = {}) : Promise => { 49 | // Load body 50 | if (!event.body) 51 | return response("Missing body in event.", 400); 52 | const body = JSON.parse(event.body); 53 | 54 | // Validate body 55 | if (!body.cardNumber) 56 | return response("Missing 'cardNumber' in request body.", 400); 57 | if (typeof body.cardNumber !== "string") 58 | return response("'cardNumber' is not a string.", 400); 59 | if (body.cardNumber.length !== 16) 60 | return response("'cardNumber' is not 16 characters long.", 400); 61 | if (!body.amount) 62 | return response("Missing 'amount' in request body.", 400); 63 | if (typeof body.amount !== "number") 64 | return response("'amount' is not a number.", 400); 65 | if (body.amount < 0) 66 | return response("'amount' should be a positive number.", 400) 67 | 68 | // Generate the token 69 | var paymentToken = await exports.genToken(client, body.cardNumber, body.amount); 70 | 71 | // Send a response 72 | if (paymentToken === null) { 73 | return response("Failed to generate a token", 500); 74 | } else { 75 | return response({"paymentToken": paymentToken}); 76 | } 77 | } -------------------------------------------------------------------------------- /payment-3p/src/preauth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preauth", 3 | "version": "1.0.0", 4 | "description": "Pre Authorization Function", 5 | "main": "index.js", 6 | "dependencies": { 7 | "@types/node": "^13.9.0", 8 | "@types/uuid": "^7.0.0", 9 | "aws-sdk": "^2.814.0", 10 | "uuid": "^7.0.2" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/aws-samples/aws-serverless-ecommerce-platform.git" 15 | }, 16 | "license": "MIT-0" 17 | } 18 | -------------------------------------------------------------------------------- /payment-3p/src/preauth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false, 20 | "typeRoots": ["./node_modules/@types"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /payment-3p/src/processPayment/index.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 2 | const TABLE_NAME = process.env.TABLE_NAME || "TABLE_NAME"; 3 | const client = new DocumentClient(); 4 | 5 | // Generate a response for API Gateway 6 | export function response( 7 | body: string | object, 8 | statusCode: number = 200, 9 | allowOrigin: string = "*", 10 | allowHeaders: string = "Content-Type,X-Amz-Date,Authorization,X-Api-Key,x-requested-with", 11 | allowMethods: string = "GET,POST,PUT,DELETE,OPTIONS" 12 | ) { 13 | if (typeof body === "string") { 14 | body = { message: body }; 15 | } 16 | return { 17 | statusCode: statusCode, 18 | body: JSON.stringify(body), 19 | headers: { 20 | "Access-Control-Allow-Headers": allowHeaders, 21 | "Access-Control-Allow-Origin": allowOrigin, 22 | "Access-Control-Allow-Methods": allowMethods 23 | } 24 | } 25 | } 26 | 27 | // Process paymentToken 28 | export async function processPayment(client: DocumentClient, paymentToken: string) : Promise { 29 | try { 30 | const response = await client.get({ 31 | TableName: TABLE_NAME, 32 | Key: { paymentToken } 33 | }).promise(); 34 | if (!response.Item) 35 | return false; 36 | 37 | await client.delete({ 38 | TableName: TABLE_NAME, 39 | Key: { paymentToken } 40 | }).promise(); 41 | return true; 42 | } catch (dbError) { 43 | console.log({"message": "Error processing the paymentToken from the database", "errormsg": dbError}); 44 | return null; 45 | } 46 | } 47 | 48 | // Lambda function handler 49 | export const handler = async (event: any = {}) : Promise => { 50 | // Load body 51 | if (!event.body) 52 | return response("Missing body in event.", 400); 53 | const body = JSON.parse(event.body); 54 | 55 | // Validate body 56 | if (!body.paymentToken) 57 | return response("Missing 'paymentToken' in request body.", 400); 58 | if (typeof body.paymentToken !== "string") 59 | return response("'paymentToken' is not a string.", 400); 60 | 61 | // Check token 62 | const result = await exports.processPayment(client, body.paymentToken); 63 | if (result === null) { 64 | return response("Internal error", 500); 65 | } else { 66 | return response({"ok": result}); 67 | } 68 | } -------------------------------------------------------------------------------- /payment-3p/src/processPayment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check", 3 | "version": "1.0.0", 4 | "description": "Check Function", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/aws-samples/aws-serverless-ecommerce-platform.git" 9 | }, 10 | "license": "MIT-0", 11 | "dependencies": { 12 | "@types/node": "^13.9.1", 13 | "aws-sdk": "^2.814.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /payment-3p/src/processPayment/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false, 20 | "typeRoots": ["./node_modules/@types"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /payment-3p/src/updateAmount/index.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 2 | import { ConfigurationServicePlaceholders } from 'aws-sdk/lib/config_service_placeholders'; 3 | const TABLE_NAME = process.env.TABLE_NAME || "TABLE_NAME"; 4 | const client = new DocumentClient(); 5 | 6 | // Generate a response for API Gateway 7 | export function response( 8 | body: string | object, 9 | statusCode: number = 200, 10 | allowOrigin: string = "*", 11 | allowHeaders: string = "Content-Type,X-Amz-Date,Authorization,X-Api-Key,x-requested-with", 12 | allowMethods: string = "GET,POST,PUT,DELETE,OPTIONS" 13 | ) { 14 | if (typeof body === "string") { 15 | body = { message: body }; 16 | } 17 | return { 18 | statusCode: statusCode, 19 | body: JSON.stringify(body), 20 | headers: { 21 | "Access-Control-Allow-Headers": allowHeaders, 22 | "Access-Control-Allow-Origin": allowOrigin, 23 | "Access-Control-Allow-Methods": allowMethods 24 | } 25 | } 26 | } 27 | 28 | // Update amount if it's less than the current value 29 | export async function updateAmount(client : DocumentClient, paymentToken: string, amount: number) : Promise { 30 | try { 31 | // Retrieve the paymentToken from DynamoDB 32 | const response = await client.get({ 33 | TableName: TABLE_NAME, 34 | Key: { paymentToken } 35 | }).promise(); 36 | // If the paymentToken doesn't exist, we cannot update it. 37 | // Therefore, the operation fails. 38 | if (!response.Item) 39 | return false; 40 | 41 | // We can only update if the amount is less or equal to the current 42 | // amount for that paymentToken. 43 | if (response.Item.amount < amount) 44 | return false; 45 | 46 | // Update the amount. 47 | await client.put({ 48 | TableName: TABLE_NAME, 49 | Item: { 50 | paymentToken: paymentToken, 51 | amount: amount 52 | } 53 | }).promise(); 54 | return true; 55 | 56 | } catch (dbError) { 57 | console.log({"message": "Error updating the paymentToken in the database", "errormsg": dbError}); 58 | return null; 59 | } 60 | } 61 | 62 | // Lambda function handler 63 | export const handler = async (event: any = {}) : Promise => { 64 | // Load body 65 | if (!event.body) 66 | return response("Missing body in event.", 400); 67 | const body = JSON.parse(event.body); 68 | 69 | // Validate body 70 | if (!body.paymentToken) 71 | return response("Missing 'paymentToken' in request body.", 400); 72 | if (typeof body.paymentToken !== "string") 73 | return response("'paymentToken' is not a string.", 400); 74 | if (!body.amount) 75 | return response("Missing 'amount' in request body.", 400); 76 | if (typeof body.amount !== "number") 77 | return response("'amount' is not a number.", 400); 78 | if (body.amount < 0) 79 | return response("'amount' should be a positive number.", 400); 80 | 81 | // Update amount 82 | const result = await exports.updateAmount(client, body.paymentToken, body.amount); 83 | if (result === null) { 84 | return response("Internal error", 500); 85 | } else { 86 | return response({"ok": result}); 87 | } 88 | } -------------------------------------------------------------------------------- /payment-3p/src/updateAmount/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check", 3 | "version": "1.0.0", 4 | "description": "Check Function", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/aws-samples/aws-serverless-ecommerce-platform.git" 9 | }, 10 | "license": "MIT-0", 11 | "dependencies": { 12 | "@types/node": "^13.9.1", 13 | "aws-sdk": "^2.814.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /payment-3p/src/updateAmount/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false, 20 | "typeRoots": ["./node_modules/@types"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /payment-3p/tests/integ/cancelPayment.test.ts: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const axios = require('axios'); 3 | import { v4 as uuidv4 } from 'uuid'; 4 | const env = process.env.ENVIRONMENT || "dev"; 5 | 6 | test('cancelPayment', async () => { 7 | const ssm = new AWS.SSM(); 8 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const apiUrl = (await ssm.getParameter({ 11 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 12 | }).promise()).Parameter.Value; 13 | const tableName = (await ssm.getParameter({ 14 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 15 | }).promise()).Parameter.Value; 16 | 17 | const item = { 18 | paymentToken: uuidv4(), 19 | amount: 3000 20 | }; 21 | 22 | await dynamodb.put({ 23 | TableName: tableName, 24 | Item: item 25 | }).promise(); 26 | 27 | await axios.post(apiUrl+"/cancelPayment", { 28 | paymentToken: item.paymentToken 29 | }).then((response: any) => { 30 | expect(response.status).toBe(200); 31 | expect(typeof response.data.ok).toBe("boolean"); 32 | expect(response.data.ok).toBe(true); 33 | }, (error : any) => { 34 | expect(error).toBe(undefined); 35 | }); 36 | 37 | const ddbResponse = await dynamodb.get({ 38 | TableName: tableName, 39 | Key: { paymentToken: item.paymentToken } 40 | }).promise(); 41 | expect(ddbResponse.Item).toBe(undefined); 42 | }); 43 | 44 | test('cancelPayment without item', async () => { 45 | const ssm = new AWS.SSM(); 46 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 47 | 48 | const apiUrl = (await ssm.getParameter({ 49 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 50 | }).promise()).Parameter.Value; 51 | const tableName = (await ssm.getParameter({ 52 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 53 | }).promise()).Parameter.Value; 54 | 55 | await axios.post(apiUrl+"/cancelPayment", { 56 | paymentToken: "TOKEN" 57 | }).then((response: any) => { 58 | expect(response.status).toBe(200); 59 | expect(typeof response.data.ok).toBe("boolean"); 60 | expect(response.data.ok).toBe(false); 61 | }, (error : any) => { 62 | expect(error).toBe(undefined); 63 | }); 64 | }); 65 | 66 | test('cancelPayment without paymentToken', async () => { 67 | const ssm = new AWS.SSM(); 68 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 69 | 70 | const apiUrl = (await ssm.getParameter({ 71 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 72 | }).promise()).Parameter.Value; 73 | const tableName = (await ssm.getParameter({ 74 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 75 | }).promise()).Parameter.Value; 76 | 77 | await axios.post(apiUrl+"/cancelPayment", { 78 | }).then((response: any) => { 79 | expect(response).toBe(undefined); 80 | }, (error : any) => { 81 | expect(error.response.status).toBe(400); 82 | expect(typeof error.response.data.message).toBe("string"); 83 | expect(error.response.data.message).toContain("paymentToken"); 84 | }); 85 | }); -------------------------------------------------------------------------------- /payment-3p/tests/integ/check.test.ts: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const axios = require('axios'); 3 | import { v4 as uuidv4 } from 'uuid'; 4 | const env = process.env.ENVIRONMENT || "dev"; 5 | 6 | test('check', async () => { 7 | const ssm = new AWS.SSM(); 8 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const apiUrl = (await ssm.getParameter({ 11 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 12 | }).promise()).Parameter.Value; 13 | const tableName = (await ssm.getParameter({ 14 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 15 | }).promise()).Parameter.Value; 16 | 17 | const item = { 18 | paymentToken: uuidv4(), 19 | amount: 3000 20 | }; 21 | 22 | await dynamodb.put({ 23 | TableName: tableName, 24 | Item: item 25 | }).promise(); 26 | 27 | const response = await axios.post(apiUrl+"/check", item); 28 | 29 | expect(response.status).toBe(200); 30 | expect(typeof response.data.ok).toBe("boolean"); 31 | expect(response.data.ok).toBe(true); 32 | 33 | await dynamodb.delete({ 34 | TableName: tableName, 35 | Key: { paymentToken: item.paymentToken } 36 | }).promise(); 37 | }); 38 | 39 | test('check without paymentToken', async () => { 40 | const ssm = new AWS.SSM(); 41 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 42 | 43 | const apiUrl = (await ssm.getParameter({ 44 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 45 | }).promise()).Parameter.Value; 46 | const tableName = (await ssm.getParameter({ 47 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 48 | }).promise()).Parameter.Value; 49 | 50 | const item = { 51 | paymentToken: uuidv4(), 52 | amount: 3000 53 | }; 54 | 55 | await axios.post(apiUrl+"/check", { 56 | amount: item.amount 57 | }).then((response: any) => { 58 | expect(response).toBe(undefined); 59 | }, (error : any) => { 60 | expect(error.response.status).toBe(400); 61 | expect(typeof error.response.data.message).toBe("string"); 62 | expect(error.response.data.message).toContain("paymentToken"); 63 | }); 64 | }); 65 | 66 | test('check without amount', async () => { 67 | const ssm = new AWS.SSM(); 68 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 69 | 70 | const apiUrl = (await ssm.getParameter({ 71 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 72 | }).promise()).Parameter.Value; 73 | const tableName = (await ssm.getParameter({ 74 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 75 | }).promise()).Parameter.Value; 76 | 77 | const item = { 78 | paymentToken: uuidv4(), 79 | amount: 3000 80 | }; 81 | 82 | await axios.post(apiUrl+"/check", { 83 | paymentToken: item.paymentToken 84 | }).then((response: any) => { 85 | expect(response).toBe(undefined); 86 | }, (error : any) => { 87 | expect(error.response.status).toBe(400); 88 | expect(typeof error.response.data.message).toBe("string"); 89 | expect(error.response.data.message).toContain("amount"); 90 | }); 91 | }); -------------------------------------------------------------------------------- /payment-3p/tests/integ/preauth.test.ts: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const axios = require('axios'); 3 | const env = process.env.ENVIRONMENT || "dev"; 4 | 5 | test('preauth', async () => { 6 | const ssm = new AWS.SSM(); 7 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 8 | 9 | const apiUrl = (await ssm.getParameter({ 10 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 11 | }).promise()).Parameter.Value; 12 | const tableName = (await ssm.getParameter({ 13 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 14 | }).promise()).Parameter.Value; 15 | 16 | const response = await axios.post(apiUrl+"/preauth", { 17 | cardNumber: "1234567890123456", 18 | amount: 30000 19 | }); 20 | expect(response.status).toBe(200); 21 | expect(typeof response.data.paymentToken).toBe("string"); 22 | expect(response.data.message).toBe(undefined); 23 | 24 | const paymentToken = response.data.paymentToken; 25 | const ddbResponse = await dynamodb.get({ 26 | TableName: tableName, 27 | Key: { paymentToken } 28 | }).promise(); 29 | 30 | expect(ddbResponse.Item).not.toBe(undefined); 31 | expect(ddbResponse.Item.paymentToken).toBe(paymentToken); 32 | expect(ddbResponse.Item.amount).toBe(30000); 33 | 34 | await dynamodb.delete({ 35 | TableName: tableName, 36 | Key: { paymentToken } 37 | }).promise(); 38 | }); 39 | 40 | test('preauth without cardNumber', async () => { 41 | const ssm = new AWS.SSM(); 42 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 43 | 44 | const apiUrl = (await ssm.getParameter({ 45 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 46 | }).promise()).Parameter.Value; 47 | const tableName = (await ssm.getParameter({ 48 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 49 | }).promise()).Parameter.Value; 50 | 51 | await axios.post(apiUrl+"/preauth", { 52 | amount: 30000 53 | }).then((response: any) => { 54 | expect(response).toBe(undefined); 55 | }, (error : any) => { 56 | expect(error.response.status).toBe(400); 57 | expect(typeof error.response.data.message).toBe("string"); 58 | expect(error.response.data.message).toContain("cardNumber"); 59 | }); 60 | }); 61 | 62 | test('preauth without amount', async () => { 63 | const ssm = new AWS.SSM(); 64 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 65 | 66 | const apiUrl = (await ssm.getParameter({ 67 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 68 | }).promise()).Parameter.Value; 69 | const tableName = (await ssm.getParameter({ 70 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 71 | }).promise()).Parameter.Value; 72 | 73 | await axios.post(apiUrl+"/preauth", { 74 | cardNumber: "1234567890123456" 75 | }).then((response: any) => { 76 | expect(response).toBe(undefined); 77 | }, (error : any) => { 78 | expect(error.response.status).toBe(400); 79 | expect(typeof error.response.data.message).toBe("string"); 80 | expect(error.response.data.message).toContain("amount"); 81 | }); 82 | }); -------------------------------------------------------------------------------- /payment-3p/tests/integ/processPayment.test.ts: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const axios = require('axios'); 3 | import { v4 as uuidv4 } from 'uuid'; 4 | const env = process.env.ENVIRONMENT || "dev"; 5 | 6 | test('processPayment', async () => { 7 | const ssm = new AWS.SSM(); 8 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const apiUrl = (await ssm.getParameter({ 11 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 12 | }).promise()).Parameter.Value; 13 | const tableName = (await ssm.getParameter({ 14 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 15 | }).promise()).Parameter.Value; 16 | 17 | const item = { 18 | paymentToken: uuidv4(), 19 | amount: 3000 20 | }; 21 | 22 | await dynamodb.put({ 23 | TableName: tableName, 24 | Item: item 25 | }).promise(); 26 | 27 | await axios.post(apiUrl+"/processPayment", { 28 | paymentToken: item.paymentToken 29 | }).then((response: any) => { 30 | expect(response.status).toBe(200); 31 | expect(typeof response.data.ok).toBe("boolean"); 32 | expect(response.data.ok).toBe(true); 33 | }, (error : any) => { 34 | expect(error).toBe(undefined); 35 | }); 36 | 37 | const ddbResponse = await dynamodb.get({ 38 | TableName: tableName, 39 | Key: { paymentToken: item.paymentToken } 40 | }).promise(); 41 | expect(ddbResponse.Item).toBe(undefined); 42 | }); 43 | 44 | test('processPayment without item', async () => { 45 | const ssm = new AWS.SSM(); 46 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 47 | 48 | const apiUrl = (await ssm.getParameter({ 49 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 50 | }).promise()).Parameter.Value; 51 | const tableName = (await ssm.getParameter({ 52 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 53 | }).promise()).Parameter.Value; 54 | 55 | await axios.post(apiUrl+"/processPayment", { 56 | paymentToken: "TOKEN" 57 | }).then((response: any) => { 58 | expect(response.status).toBe(200); 59 | expect(typeof response.data.ok).toBe("boolean"); 60 | expect(response.data.ok).toBe(false); 61 | }, (error : any) => { 62 | expect(error).toBe(undefined); 63 | }); 64 | }); 65 | 66 | test('processPayment without paymentToken', async () => { 67 | const ssm = new AWS.SSM(); 68 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 69 | 70 | const apiUrl = (await ssm.getParameter({ 71 | Name: "/ecommerce/"+env+"/payment-3p/api/url" 72 | }).promise()).Parameter.Value; 73 | const tableName = (await ssm.getParameter({ 74 | Name: "/ecommerce/"+env+"/payment-3p/table/name" 75 | }).promise()).Parameter.Value; 76 | 77 | await axios.post(apiUrl+"/processPayment", { 78 | }).then((response: any) => { 79 | expect(response).toBe(undefined); 80 | }, (error : any) => { 81 | expect(error.response.status).toBe(400); 82 | expect(typeof error.response.data.message).toBe("string"); 83 | expect(error.response.data.message).toContain("paymentToken"); 84 | }); 85 | }); -------------------------------------------------------------------------------- /payment-3p/tests/lint/payment-3p.test.ts: -------------------------------------------------------------------------------- 1 | import { expect as expectCDK, haveResourceLike } from '@aws-cdk/assert'; 2 | import * as cdk from '@aws-cdk/core'; 3 | import Payment3P = require('../../lib/payment-3p-stack'); 4 | 5 | // TODO: 6 | // - Test parameters 7 | // - Test if Lambda functions have a corresponding LogGroup 8 | 9 | test("Has Api", () => { 10 | const app = new cdk.App(); 11 | const stack = new Payment3P.Payment3PStack(app, "MyTestStack"); 12 | 13 | expectCDK(stack).to(haveResourceLike("AWS::Serverless::Api")); 14 | }); 15 | 16 | test("Has Functions", () => { 17 | const app = new cdk.App(); 18 | const stack = new Payment3P.Payment3PStack(app, "MyTestStack"); 19 | 20 | expectCDK(stack).to(haveResourceLike("AWS::Serverless::Function", { 21 | "CodeUri": "src/check/" 22 | })); 23 | expectCDK(stack).to(haveResourceLike("AWS::Serverless::Function", { 24 | "CodeUri": "src/preauth/" 25 | })); 26 | expectCDK(stack).to(haveResourceLike("AWS::Serverless::Function", { 27 | "CodeUri": "src/processPayment/" 28 | })); 29 | expectCDK(stack).to(haveResourceLike("AWS::Serverless::Function", { 30 | "CodeUri": "src/updateAmount/" 31 | })); 32 | }); 33 | 34 | test("Has Table", () => { 35 | const app = new cdk.App(); 36 | const stack = new Payment3P.Payment3PStack(app, "MyTestStack"); 37 | 38 | expectCDK(stack).to(haveResourceLike("AWS::DynamoDB::Table", {})); 39 | }); -------------------------------------------------------------------------------- /payment-3p/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false, 20 | "typeRoots": ["./node_modules/@types"] 21 | }, 22 | "exclude": ["cdk.out"] 23 | } 24 | -------------------------------------------------------------------------------- /payment-3p/unit.jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: [ 4 | "src/**/*.ts", 5 | "!**/node_modules/**", 6 | "!**/vendor/**" 7 | ], 8 | coverageThreshold: { 9 | "global": { 10 | "branches": 90, 11 | "functions": 90, 12 | "statements": 90 13 | } 14 | }, 15 | reporters: [ 16 | "default", 17 | ["jest-junit", {outputDirectory: "../reports/", outputName: `payment-3p-unit.xml`}] 18 | ], 19 | testMatch: [ '/tests/unit/**/*.test.ts'], 20 | transform: { 21 | "^.+\\.tsx?$": "ts-jest" 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /payment/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /payment/README.md: -------------------------------------------------------------------------------- 1 | Payment service 2 | =============== 3 | 4 |

5 | Payment architecture diagram 6 |

7 | 8 | ## API 9 | 10 | See [resources/openapi.yaml](resources/openapi.yaml) for a list of available API paths. 11 | 12 | ## Monitoring and KPIs 13 | 14 | On the business level, the main key performance indicators (KPIs) are the number of payments processed and the corresponding amount. The service should also track the number of cancelled payments or updated payments and the corresponding loss. 15 | 16 | From an operational point of view, errors in lambda functions and API Gateway are important as well as the latency of the Validate function which is called synchronously from the order creation. 17 | 18 | Then, latency & duration of all other functions are also tracked and visible in the dashboard. 19 | 20 |

21 | Payment monitoring dashboard 22 |

23 | 24 | ## Events 25 | 26 | _This service does not publish events._ 27 | 28 | ## SSM Parameters 29 | 30 | This service defines the following SSM parameters: 31 | 32 | * `/ecommerce/{Environment}/payment/api/url`: URL for the API Gateway 33 | * `/ecommerce/{Environment}/payment/api/arn`: ARN for the API Gateway 34 | -------------------------------------------------------------------------------- /payment/images/monitoring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/payment/images/monitoring.png -------------------------------------------------------------------------------- /payment/images/payment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/payment/images/payment.png -------------------------------------------------------------------------------- /payment/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: payment 2 | dependencies: 3 | - payment-3p 4 | - platform 5 | parameters: 6 | EventBusName: /ecommerce/{Environment}/platform/event-bus/name 7 | Payment3PApiUrl: /ecommerce/{Environment}/payment-3p/api/url -------------------------------------------------------------------------------- /payment/resources/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: 4 | Fn::Sub: "${AWS::StackName}-api" 5 | version: 1.0.0 6 | description: Payment service API definition 7 | license: 8 | name: MIT-0 9 | url: https://github.com/aws/mit-0 10 | 11 | paths: 12 | /backend/validate: 13 | post: 14 | description: | 15 | Validates a paymentToken. 16 | 17 | __Remark__: This is an internal API that requires valid IAM credentials 18 | and signature. 19 | operationId: backendValidate 20 | requestBody: 21 | required: true 22 | content: 23 | application/json: 24 | schema: 25 | type: object 26 | required: 27 | - paymentToken 28 | - total 29 | properties: 30 | paymentToken: 31 | type: string 32 | example: "63fa7809-a708-461f-99e8-13c48bbb5dbb" 33 | total: 34 | type: integer 35 | example: 12345 36 | responses: 37 | "200": 38 | description: OK 39 | content: 40 | application/json: 41 | schema: 42 | type: object 43 | required: 44 | - ok 45 | properties: 46 | ok: 47 | type: boolean 48 | example: true 49 | default: 50 | description: Error 51 | content: 52 | application/json: 53 | schema: 54 | $ref: "../../shared/resources/schemas.yaml#/Message" 55 | x-amazon-apigateway-auth: 56 | type: AWS_IAM 57 | x-amazon-apigateway-integration: 58 | httpMethod: "POST" 59 | type: aws_proxy 60 | uri: 61 | Fn::Sub: "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ValidateFunction.Arn}/invocations" 62 | -------------------------------------------------------------------------------- /payment/src/on_completed/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | OnCompleted Function 3 | """ 4 | 5 | 6 | import os 7 | import boto3 8 | import requests 9 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 10 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 11 | from aws_lambda_powertools import Metrics # pylint: disable=import-error 12 | from aws_lambda_powertools.metrics import MetricUnit # pylint: disable=import-error 13 | 14 | API_URL = os.environ["API_URL"] 15 | ENVIRONMENT = os.environ["ENVIRONMENT"] 16 | TABLE_NAME = os.environ["TABLE_NAME"] 17 | 18 | 19 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 20 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 21 | logger = Logger() # pylint: disable=invalid-name 22 | tracer = Tracer() # pylint: disable=invalid-name 23 | metrics = Metrics(namespace="ecommerce.payment") # pylint: disable=invalid-name 24 | 25 | @tracer.capture_method 26 | def get_payment_token(order_id: str) -> str: 27 | """ 28 | Retrieve the paymentToken from DynamoDB 29 | """ 30 | 31 | response = table.get_item(Key={ 32 | "orderId": order_id 33 | }) 34 | 35 | return response["Item"]["paymentToken"] 36 | 37 | 38 | @tracer.capture_method 39 | def delete_payment_token(order_id: str) -> None: 40 | """ 41 | Delete the paymentToken in DynamoDB 42 | """ 43 | 44 | table.delete_item(Key={ 45 | "orderId": order_id, 46 | }) 47 | 48 | 49 | @tracer.capture_method 50 | def process_payment(payment_token: str) -> None: 51 | """ 52 | Process the payment against the 3rd party payment service 53 | """ 54 | 55 | response = requests.post(API_URL+"/processPayment", json={ 56 | "paymentToken": payment_token 57 | }) 58 | 59 | if not response.json().get("ok", False): 60 | raise Exception("Failed to process payment: {}".format(response.json().get("message", "No error message"))) 61 | 62 | 63 | @metrics.log_metrics(raise_on_empty_metrics=False) 64 | @logger.inject_lambda_context 65 | @tracer.capture_lambda_handler 66 | def handler(event, _): 67 | """ 68 | Lambda handler 69 | """ 70 | 71 | order_id = event["detail"]["orderId"] 72 | 73 | logger.info({ 74 | "message": "Received completed order {}".format(order_id), 75 | "orderId": order_id 76 | }) 77 | logger.debug({ 78 | "message": "Received completed order {}".format(order_id), 79 | "event": event 80 | }) 81 | 82 | payment_token = get_payment_token(order_id) 83 | process_payment(payment_token) 84 | delete_payment_token(order_id) 85 | 86 | # Add custom metrics 87 | metrics.add_dimension(name="environment", value=ENVIRONMENT) 88 | metrics.add_metric(name="paymentProcessed", unit=MetricUnit.Count, value=1) 89 | -------------------------------------------------------------------------------- /payment/src/on_completed/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | requests 4 | ../shared/src/ecom/ 5 | -------------------------------------------------------------------------------- /payment/src/on_created/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | OnCreated Function 3 | """ 4 | 5 | 6 | import os 7 | import boto3 8 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 9 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 10 | from aws_lambda_powertools import Metrics # pylint: disable=import-error 11 | from aws_lambda_powertools.metrics import MetricUnit # pylint: disable=import-error 12 | 13 | ENVIRONMENT = os.environ["ENVIRONMENT"] 14 | TABLE_NAME = os.environ["TABLE_NAME"] 15 | 16 | 17 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 18 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 19 | logger = Logger() # pylint: disable=invalid-name 20 | tracer = Tracer() # pylint: disable=invalid-name 21 | metrics = Metrics(namespace="ecommerce.payment") # pylint: disable=invalid-name 22 | 23 | @tracer.capture_method 24 | def save_payment_token(order_id: str, payment_token: str) -> None: 25 | """ 26 | Save the paymentToken in DynamoDB 27 | """ 28 | 29 | table.put_item(Item={ 30 | "orderId": order_id, 31 | "paymentToken": payment_token 32 | }) 33 | 34 | @metrics.log_metrics(raise_on_empty_metrics=False) 35 | @logger.inject_lambda_context 36 | @tracer.capture_lambda_handler 37 | def handler(event, _): 38 | """ 39 | Lambda handler 40 | """ 41 | 42 | order_id = event["detail"]["orderId"] 43 | payment_token = event["detail"]["paymentToken"] 44 | 45 | logger.info({ 46 | "message": "Received new order {}".format(order_id), 47 | "orderId": order_id, 48 | "paymentToken": payment_token 49 | }) 50 | 51 | save_payment_token(order_id, payment_token) 52 | 53 | # Add custom metrics 54 | metrics.add_dimension(name="environment", value=ENVIRONMENT) 55 | metrics.add_metric(name="paymentCreated", unit=MetricUnit.Count, value=1) 56 | -------------------------------------------------------------------------------- /payment/src/on_created/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /payment/src/on_failed/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | OnFailed Function 3 | """ 4 | 5 | 6 | import os 7 | import boto3 8 | import requests 9 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 10 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 11 | from aws_lambda_powertools import Metrics # pylint: disable=import-error 12 | from aws_lambda_powertools.metrics import MetricUnit # pylint: disable=import-error 13 | 14 | API_URL = os.environ["API_URL"] 15 | ENVIRONMENT = os.environ["ENVIRONMENT"] 16 | TABLE_NAME = os.environ["TABLE_NAME"] 17 | 18 | 19 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 20 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 21 | logger = Logger() # pylint: disable=invalid-name 22 | tracer = Tracer() # pylint: disable=invalid-name 23 | metrics = Metrics(namespace="ecommerce.payment") # pylint: disable=invalid-name 24 | 25 | 26 | @tracer.capture_method 27 | def get_payment_token(order_id: str) -> str: 28 | """ 29 | Retrieve the paymentToken from DynamoDB 30 | """ 31 | 32 | response = table.get_item(Key={ 33 | "orderId": order_id 34 | }) 35 | 36 | return response["Item"]["paymentToken"] 37 | 38 | 39 | @tracer.capture_method 40 | def delete_payment_token(order_id: str) -> None: 41 | """ 42 | Delete the paymentToken in DynamoDB 43 | """ 44 | 45 | table.delete_item(Key={ 46 | "orderId": order_id, 47 | }) 48 | 49 | 50 | @tracer.capture_method 51 | def cancel_payment(payment_token: str) -> None: 52 | """ 53 | Cancel the payment request 54 | """ 55 | 56 | response = requests.post(API_URL+"/cancelPayment", json={ 57 | "paymentToken": payment_token 58 | }) 59 | 60 | if not response.json().get("ok", False): 61 | raise Exception("Failed to process payment: {}".format(response.json().get("message", "No error message"))) 62 | 63 | 64 | @metrics.log_metrics(raise_on_empty_metrics=False) 65 | @logger.inject_lambda_context 66 | @tracer.capture_lambda_handler 67 | def handler(event, _): 68 | """ 69 | Lambda handler 70 | """ 71 | 72 | order_id = event["detail"]["orderId"] 73 | 74 | logger.info({ 75 | "message": "Received failed order {}".format(order_id), 76 | "orderId": order_id 77 | }) 78 | 79 | payment_token = get_payment_token(order_id) 80 | cancel_payment(payment_token) 81 | delete_payment_token(order_id) 82 | 83 | # Add custom metrics 84 | amount_lost = event["detail"]["total"] 85 | 86 | metrics.add_dimension(name="environment", value=ENVIRONMENT) 87 | metrics.add_metric(name="paymentCancelled", unit=MetricUnit.Count, value=1) 88 | metrics.add_metric(name="amountLost", unit=MetricUnit.Count, value=amount_lost) 89 | -------------------------------------------------------------------------------- /payment/src/on_failed/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | requests 4 | ../shared/src/ecom/ 5 | -------------------------------------------------------------------------------- /payment/src/on_modified/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | OnModified Function 3 | """ 4 | 5 | 6 | import os 7 | import boto3 8 | import requests 9 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 10 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 11 | from aws_lambda_powertools import Metrics # pylint: disable=import-error 12 | from aws_lambda_powertools.metrics import MetricUnit # pylint: disable=import-error 13 | 14 | 15 | API_URL = os.environ["API_URL"] 16 | ENVIRONMENT = os.environ["ENVIRONMENT"] 17 | TABLE_NAME = os.environ["TABLE_NAME"] 18 | 19 | 20 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 21 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 22 | logger = Logger() # pylint: disable=invalid-name 23 | tracer = Tracer() # pylint: disable=invalid-name 24 | metrics = Metrics(namespace="ecommerce.payment") # pylint: disable=invalid-name 25 | 26 | 27 | @tracer.capture_method 28 | def get_payment_token(order_id: str) -> str: 29 | """ 30 | Retrieve the paymentToken from DynamoDB 31 | """ 32 | 33 | response = table.get_item(Key={ 34 | "orderId": order_id 35 | }) 36 | 37 | return response["Item"]["paymentToken"] 38 | 39 | 40 | @tracer.capture_method 41 | def update_payment_amount(payment_token: str, amount: int) -> None: 42 | """ 43 | Update the payment amount 44 | """ 45 | 46 | response = requests.post(API_URL+"/updateAmount", json={ 47 | "paymentToken": payment_token, 48 | "amount": amount 49 | }) 50 | 51 | body = response.json() 52 | if "message" in body: 53 | raise Exception("Error updating amount: {}".format(body["message"])) 54 | 55 | 56 | @metrics.log_metrics(raise_on_empty_metrics=False) 57 | @logger.inject_lambda_context 58 | @tracer.capture_lambda_handler 59 | def handler(event, _): 60 | """ 61 | Lambda handler 62 | """ 63 | 64 | order_id = event["detail"]["new"]["orderId"] 65 | new_total = event["detail"]["new"]["total"] 66 | old_total = event["detail"]["old"]["total"] 67 | 68 | logger.info({ 69 | "message": "Received modification of order {}".format(order_id), 70 | "orderId": order_id, 71 | "old_amount": old_total, 72 | "new_amount": new_total 73 | }) 74 | 75 | payment_token = get_payment_token(order_id) 76 | update_payment_amount(payment_token, new_total) 77 | 78 | # Add custom metrics 79 | metrics.add_dimension(name="environment", value=ENVIRONMENT) 80 | difference = new_total - old_total 81 | if difference < 0: 82 | metric = "amountLost" 83 | else: 84 | metric = "amountWon" 85 | metrics.add_metric(name=metric, unit=MetricUnit.Count, value=abs(difference)) 86 | -------------------------------------------------------------------------------- /payment/src/on_modified/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | requests 4 | ../shared/src/ecom/ 5 | -------------------------------------------------------------------------------- /payment/src/validate/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | ValidateFunction 3 | """ 4 | 5 | import json 6 | import os 7 | import requests 8 | from aws_lambda_powertools.tracing import Tracer #pylint: disable=import-error 9 | from aws_lambda_powertools.logging.logger import Logger #pylint: disable=import-error 10 | from ecom.apigateway import iam_user_id, response # pylint: disable=import-error 11 | 12 | 13 | API_URL = os.environ["API_URL"] 14 | ENVIRONMENT = os.environ["ENVIRONMENT"] 15 | 16 | 17 | logger = Logger() # pylint: disable=invalid-name 18 | tracer = Tracer() # pylint: disable=invalid-name 19 | 20 | 21 | @tracer.capture_method 22 | def validate_payment_token(payment_token: str, total: int) -> bool: 23 | """ 24 | Validate a payment token for a given total 25 | """ 26 | 27 | # Send the request to the 3p service 28 | res = requests.post(API_URL+"/check", json={ 29 | "paymentToken": payment_token, 30 | "amount": total 31 | }) 32 | 33 | body = res.json() 34 | if "ok" not in body: 35 | logger.error({ 36 | "message": "Missing 'ok' in 3rd party response body", 37 | "body": body, 38 | "paymentToken": payment_token 39 | }) 40 | return body.get("ok", False) 41 | 42 | 43 | @logger.inject_lambda_context 44 | @tracer.capture_lambda_handler 45 | def handler(event, _): 46 | """ 47 | Lambda function handler 48 | """ 49 | 50 | user_id = iam_user_id(event) 51 | if user_id is None: 52 | logger.warning({"message": "User ARN not found in event"}) 53 | return response("Unauthorized", 401) 54 | 55 | # Extract the body 56 | try: 57 | body = json.loads(event["body"]) 58 | except Exception as exc: # pylint: disable=broad-except 59 | logger.warning("Exception caught: %s", exc) 60 | return response("Failed to parse JSON body", 400) 61 | 62 | for key in ["paymentToken", "total"]: 63 | if key not in body: 64 | logger.warning({ 65 | "message": "Missing '{}' in request body.".format(key), 66 | "body": body 67 | }) 68 | return response("Missing '{}' in request body.".format(key), 400) 69 | 70 | valid = validate_payment_token(body["paymentToken"], body["total"]) 71 | 72 | return response({ 73 | "ok": valid 74 | }) 75 | -------------------------------------------------------------------------------- /payment/src/validate/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | requests 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /payment/tests/unit/test_on_created.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pytest 3 | from fixtures import context, lambda_module # pylint: disable=import-error 4 | from helpers import mock_table # pylint: disable=import-error,no-name-in-module 5 | 6 | 7 | lambda_module = pytest.fixture(scope="module", params=[{ 8 | "function_dir": "on_created", 9 | "module_name": "main", 10 | "environ": { 11 | "ENVIRONMENT": "test", 12 | "TABLE_NAME": "TABLE_NAME", 13 | "POWERTOOLS_TRACE_DISABLED": "true" 14 | } 15 | }])(lambda_module) 16 | context = pytest.fixture(context) 17 | 18 | 19 | @pytest.fixture 20 | def order_id(): 21 | return str(uuid.uuid4()) 22 | 23 | @pytest.fixture 24 | def payment_token(): 25 | return str(uuid.uuid4()) 26 | 27 | 28 | def test_save_payment_token(lambda_module, order_id, payment_token): 29 | """ 30 | Test save_payment_token() 31 | """ 32 | 33 | table = mock_table( 34 | lambda_module.table, 35 | action="put_item", 36 | keys=["orderId"], 37 | items={"orderId": order_id, "paymentToken": payment_token} 38 | ) 39 | 40 | lambda_module.save_payment_token(order_id, payment_token) 41 | 42 | table.assert_no_pending_responses() 43 | table.deactivate() 44 | 45 | 46 | def test_handler(monkeypatch, lambda_module, context, order_id, payment_token): 47 | """ 48 | Test handler() 49 | """ 50 | 51 | event = { 52 | "source": "ecommerce.orders", 53 | "detail-type": "OrderCreated", 54 | "resources": [order_id], 55 | "detail": { 56 | "orderId": order_id, 57 | "paymentToken": payment_token 58 | } 59 | } 60 | 61 | def save_payment_token(o: str, p: str): 62 | assert o == order_id 63 | assert p == payment_token 64 | 65 | monkeypatch.setattr(lambda_module, "save_payment_token", save_payment_token) 66 | 67 | lambda_module.handler(event, context) -------------------------------------------------------------------------------- /pipeline/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build cloudformation ${SERVICE} 13 | .PHONY: build 14 | 15 | check-deps: 16 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 17 | 18 | clean: 19 | @${ROOT}/tools/clean ${SERVICE} 20 | 21 | deploy: 22 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 23 | 24 | lint: 25 | @${ROOT}/tools/lint cloudformation ${SERVICE} 26 | @${ROOT}/tools/lint openapi ${SERVICE} 27 | 28 | package: 29 | @${ROOT}/tools/package cloudformation ${SERVICE} 30 | 31 | teardown: 32 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 33 | 34 | tests-integ: 35 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 36 | 37 | tests-unit: 38 | @echo "Skipping unit tests" -------------------------------------------------------------------------------- /pipeline/README.md: -------------------------------------------------------------------------------- 1 | Ecommerce Platform Pipeline 2 | =========================== 3 | 4 | ![Architecture diagram for the pipeline](images/pipeline.png) 5 | 6 | Whenever there is a commit pushed to the main branch of the [CodeCommit repository](https://aws.amazon.com/codecommit/), this triggers a [CodeBuild project](https://aws.amazon.com/codebuild/) that will detect which services have changed since the last project execution, run lint and unit tests and send artifacts to an S3 bucket. See [resources/buildspec-build.yaml](resources/buildspec-build.yaml) for the build specification. 7 | 8 | Each service has its own [pipeline powered by CodePipeline](https://aws.amazon.com/codepipeline/). When a new version of the artifacts for that service is uploaded to S3, this will trigger the pipeline. 9 | 10 | The pipeline will first fetch the sources from CodeCommit, deploy the stack to a testing environment and run integration tests against that stack. See [resources/buildspec-tests.yaml](resources/buildspec-tests.yaml) for the build specification. -------------------------------------------------------------------------------- /pipeline/images/pipeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/pipeline/images/pipeline.png -------------------------------------------------------------------------------- /pipeline/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: pipeline 2 | dependencies: 3 | - "*" 4 | flags: 5 | environment: false 6 | skip-tests: true -------------------------------------------------------------------------------- /pipeline/resources/buildspec-build.yaml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: 12 7 | python: 3.8 8 | pre_build: 9 | commands: 10 | - apt-get update -y 11 | - apt-get install -y jq 12 | - /usr/bin/env python3.8 -m pip install --upgrade pip 13 | - make requirements npm-install 14 | build: 15 | commands: 16 | - COMMIT_ID=$(aws ssm get-parameter --name $COMMIT_PARAMETER | jq -r '.Parameter.Value') 17 | - "echo COMMIT_ID: $COMMIT_ID" 18 | - "echo SERVICES: $(tools/services --env-only --changed-since $COMMIT_ID)" 19 | # Tests for all services must work before moving to the next step 20 | - | 21 | for SERVICE in $(tools/services --env-only --changed-since $COMMIT_ID); do 22 | make ci-$SERVICE package-$SERVICE artifacts-$SERVICE || exit 1 23 | done 24 | 25 | # Upload template artifacts to 'templates/$SERVICE.yaml' 26 | # This will trigger the pipelines per service 27 | - | 28 | for SERVICE in $(tools/services --env-only --changed-since $COMMIT_ID); do 29 | aws s3 cp $SERVICE/build/artifacts.zip s3://$S3_BUCKET/templates/$SERVICE.zip || exit 1 30 | done 31 | # Update the parameter 32 | - aws ssm put-parameter --name $COMMIT_PARAMETER --type String --value $CODEBUILD_RESOLVED_SOURCE_VERSION --overwrite 33 | 34 | reports: 35 | build: 36 | files: 37 | - '**/*' 38 | base-directory: 'reports' -------------------------------------------------------------------------------- /pipeline/resources/buildspec-staging.yaml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: 12 7 | python: 3.8 8 | pre_build: 9 | commands: 10 | - apt-get update -y 11 | - apt-get install -y jq 12 | - /usr/bin/env python3.8 -m pip install --upgrade pip 13 | - make requirements npm-install 14 | build: 15 | commands: 16 | - make tests-e2e 17 | 18 | reports: 19 | staging: 20 | files: 21 | - '**/*' 22 | base-directory: 'reports' -------------------------------------------------------------------------------- /pipeline/resources/buildspec-tests.yaml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: 12 7 | python: 3.8 8 | pre_build: 9 | commands: 10 | - apt-get update -y 11 | - apt-get install -y jq 12 | - /usr/bin/env python3.8 -m pip install --upgrade pip 13 | - make requirements npm-install 14 | build: 15 | commands: 16 | - make tests-integ-$SERVICE_NAME 17 | 18 | reports: 19 | tests: 20 | files: 21 | - '**/*' 22 | base-directory: 'reports' -------------------------------------------------------------------------------- /pipeline/resources/service-pipeline-environment.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | 4 | 5 | Parameters: 6 | ServiceName: 7 | Type: String 8 | Description: Service name used for deployment 9 | Environment: 10 | Type: String 11 | Description: Environment name 12 | 13 | 14 | Resources: 15 | DeployRole: 16 | Type: AWS::IAM::Role 17 | Properties: 18 | AssumeRolePolicyDocument: 19 | Version: "2012-10-17" 20 | Statement: 21 | - Effect: Allow 22 | Principal: 23 | Service: cloudformation.amazonaws.com 24 | Action: sts:AssumeRole 25 | Policies: 26 | - PolicyName: !Sub "${AWS::StackName}-DeployPolicy" 27 | PolicyDocument: 28 | Version: "2012-10-17" 29 | Statement: 30 | # TODO: scope this down to allowed resources 31 | - Effect: Allow 32 | Action: "*" 33 | Resource: "*" 34 | Condition: 35 | ForAllValues:StringEqualsIfExists: 36 | # Restrict to the given environment 37 | aws:ResourceTag/Environment: !Ref Environment 38 | # Restrict to the pipeline region 39 | aws:RequestedRegion: !Ref AWS::Region 40 | # Global services 41 | - Effect: Allow 42 | Action: 43 | - cloudfront:* 44 | - iam:* 45 | - route53:* 46 | Resource: "*" 47 | Condition: 48 | ForAllValues:StringEqualsIfExists: 49 | # Restrict to the given environment 50 | aws:ResourceTag/Environment: !Ref Environment 51 | 52 | 53 | Outputs: 54 | RoleArn: 55 | Description: Deyploment role ARN 56 | Value: !GetAtt DeployRole.Arn -------------------------------------------------------------------------------- /platform/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /platform/README.md: -------------------------------------------------------------------------------- 1 | Platform service 2 | ================ 3 | 4 | ![Platform architecture diagram](images/platform.png) 5 | 6 | ## API 7 | 8 | _This service does not expose a REST API_ 9 | 10 | ## Events 11 | 12 | _This service does not emit any event_ 13 | 14 | ## SSM Parameters 15 | 16 | This service defines the following SSM parameters: 17 | 18 | * `/ecommerce/{Environment}/platform/event-bus/name`: Event Bus Name 19 | * `/ecommerce/{Environment}/platform/event-bus/arn`: Event Bus ARN 20 | * `/ecommerce/{Environment}/platform/listener-api/url`: URL for the WebSocket Listener API 21 | -------------------------------------------------------------------------------- /platform/images/platform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/platform/images/platform.png -------------------------------------------------------------------------------- /platform/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: platform -------------------------------------------------------------------------------- /platform/src/on_connect/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | OnConnect Lambda function 3 | """ 4 | 5 | 6 | import datetime 7 | import os 8 | import boto3 9 | from botocore.waiter import WaiterModel, create_waiter_with_client 10 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 11 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 12 | from ecom.apigateway import response # pylint: disable=import-error 13 | 14 | 15 | ENVIRONMENT = os.environ["ENVIRONMENT"] 16 | EVENT_BUS_NAME, EVENT_RULE_NAME = os.environ["EVENT_RULE_NAME"].split("|") 17 | TABLE_NAME = os.environ["LISTENER_TABLE_NAME"] 18 | WAITER_MAX_ATTEMPTS = 5 19 | WAITER_DELAY = 2 20 | 21 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 22 | eventbridge = boto3.client("events") #pylint: disable=invalid-name 23 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 24 | logger = Logger() # pylint: disable=invalid-name 25 | tracer = Tracer() # pylint: disable=invalid-name 26 | 27 | 28 | @tracer.capture_method 29 | def store_id(connection_id: str): 30 | """ 31 | Store the connectionId in DynamoDB 32 | """ 33 | 34 | ttl = datetime.datetime.now() + datetime.timedelta(days=1) 35 | 36 | table.put_item(Item={ 37 | "id": connection_id, 38 | "ttl": int(ttl.timestamp()) 39 | }) 40 | 41 | 42 | @logger.inject_lambda_context 43 | @tracer.capture_lambda_handler 44 | def handler(event, _): 45 | """ 46 | Lambda handler 47 | """ 48 | 49 | try: 50 | connection_id = event["requestContext"]["connectionId"] 51 | except (KeyError, TypeError): 52 | logger.error({ 53 | "message": "Missing connection ID in event", 54 | "event": event 55 | }) 56 | return response("Missing connection ID", 400) 57 | 58 | logger.debug({ 59 | "message": f"New connection {connection_id}", 60 | "event": event 61 | }) 62 | 63 | store_id(connection_id) 64 | 65 | return response("Connected") 66 | -------------------------------------------------------------------------------- /platform/src/on_connect/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /platform/src/on_disconnect/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | OnDisconnect Lambda function 3 | """ 4 | 5 | 6 | import os 7 | import boto3 8 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 9 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 10 | from ecom.apigateway import response # pylint: disable=import-error 11 | 12 | 13 | ENVIRONMENT = os.environ["ENVIRONMENT"] 14 | EVENT_BUS_NAME, EVENT_RULE_NAME = os.environ["EVENT_RULE_NAME"].split("|") 15 | TABLE_NAME = os.environ["LISTENER_TABLE_NAME"] 16 | 17 | 18 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 19 | eventbridge = boto3.client("events") # pylint: disable=invalid-name 20 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 21 | logger = Logger() # pylint: disable=invalid-name 22 | tracer = Tracer() # pylint: disable=invalid-name 23 | 24 | 25 | @tracer.capture_method 26 | def delete_id(connection_id: str): 27 | """ 28 | Delete the connectionId in DynamoDB 29 | """ 30 | 31 | table.delete_item(Key={ 32 | "id": connection_id 33 | }) 34 | 35 | 36 | @logger.inject_lambda_context 37 | @tracer.capture_lambda_handler 38 | def handler(event, _): 39 | """ 40 | Lambda handler 41 | """ 42 | 43 | try: 44 | connection_id = event["requestContext"]["connectionId"] 45 | except (KeyError, TypeError): 46 | logger.error({ 47 | "message": "Missing connection ID in event", 48 | "event": event 49 | }) 50 | return response("Missing connection ID", 400) 51 | 52 | logger.debug({ 53 | "message": f"Connection {connection_id} closing", 54 | "event": event 55 | }) 56 | 57 | delete_id(connection_id) 58 | 59 | return response("Disconnected") 60 | -------------------------------------------------------------------------------- /platform/src/on_disconnect/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /platform/src/on_events/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | OnEvents Lambda function 3 | """ 4 | 5 | 6 | import json 7 | import os 8 | from typing import List 9 | import boto3 10 | from boto3.dynamodb.conditions import Key 11 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 12 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 13 | 14 | 15 | ENVIRONMENT = os.environ["ENVIRONMENT"] 16 | API_URL = os.environ["LISTENER_API_URL"] 17 | TABLE_NAME = os.environ["LISTENER_TABLE_NAME"] 18 | 19 | 20 | apigwmgmt = boto3.client("apigatewaymanagementapi", endpoint_url=API_URL) # pylint: disable=invalid-name 21 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 22 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 23 | logger = Logger() # pylint: disable=invalid-name 24 | tracer = Tracer() # pylint: disable=invalid-name 25 | 26 | 27 | @tracer.capture_method 28 | def get_connection_ids(service_name: str) -> List[str]: 29 | """ 30 | Retrieve connection IDs for a service name 31 | """ 32 | 33 | res = table.query( 34 | IndexName="listener-service", 35 | KeyConditionExpression=Key("service").eq(service_name), 36 | # Only check for 100 connections 37 | Limit=100 38 | ) 39 | 40 | return [c["id"] for c in res.get("Items", [])] 41 | 42 | 43 | @tracer.capture_method 44 | def send_event(event: dict, connection_ids: List[str]): 45 | """ 46 | Send an event to a list of connection IDs 47 | """ 48 | 49 | for connection_id in connection_ids: 50 | try: 51 | apigwmgmt.post_to_connection( 52 | ConnectionId=connection_id, 53 | Data=json.dumps(event).encode("utf-8") 54 | ) 55 | # The client is disconnected, we can safely ignore and move to the 56 | # next connection ID. 57 | except apigwmgmt.exceptions.GoneException: 58 | continue 59 | 60 | 61 | @logger.inject_lambda_context 62 | @tracer.capture_lambda_handler 63 | def handler(event, _): 64 | """ 65 | Lambda handler 66 | """ 67 | 68 | # Get the service name 69 | service_name = event["source"] 70 | logger.debug({ 71 | "message": "Receive event from {}".format(service_name), 72 | "serviceName": service_name, 73 | "event": event 74 | }) 75 | 76 | # Get connection IDs 77 | connection_ids = get_connection_ids(service_name) 78 | 79 | # Send event to connected users 80 | send_event(event, connection_ids) 81 | -------------------------------------------------------------------------------- /platform/src/on_events/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | -------------------------------------------------------------------------------- /platform/src/register/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Register Lambda function 3 | """ 4 | 5 | 6 | import datetime 7 | import json 8 | import os 9 | import boto3 10 | from aws_lambda_powertools.tracing import Tracer # pylint: disable=import-error 11 | from aws_lambda_powertools.logging.logger import Logger # pylint: disable=import-error 12 | from ecom.apigateway import response # pylint: disable=import-error 13 | 14 | 15 | ENVIRONMENT = os.environ["ENVIRONMENT"] 16 | TABLE_NAME = os.environ["LISTENER_TABLE_NAME"] 17 | 18 | 19 | dynamodb = boto3.resource("dynamodb") # pylint: disable=invalid-name 20 | table = dynamodb.Table(TABLE_NAME) # pylint: disable=invalid-name,no-member 21 | logger = Logger() # pylint: disable=invalid-name 22 | tracer = Tracer() # pylint: disable=invalid-name 23 | 24 | 25 | @tracer.capture_method 26 | def register_service(connection_id: str, service_name: str): 27 | """ 28 | Store the connectionId in DynamoDB 29 | """ 30 | 31 | ttl = datetime.datetime.now() + datetime.timedelta(days=1) 32 | 33 | table.put_item(Item={ 34 | "id": connection_id, 35 | "service": service_name, 36 | "ttl": int(ttl.timestamp()) 37 | }) 38 | 39 | 40 | @logger.inject_lambda_context 41 | @tracer.capture_lambda_handler 42 | def handler(event, _): 43 | """ 44 | Lambda handler 45 | """ 46 | 47 | try: 48 | connection_id = event["requestContext"]["connectionId"] 49 | except (KeyError, TypeError): 50 | logger.error({ 51 | "message": "Missing connection ID in event", 52 | "event": event 53 | }) 54 | return response("Missing connection ID", 400) 55 | 56 | try: 57 | body = json.loads(event["body"]) 58 | except json.decoder.JSONDecodeError: 59 | logger.error({ 60 | "message": "Failed to parse request body", 61 | "event": event 62 | }) 63 | return response("Failed to parse request body", 400) 64 | 65 | try: 66 | body = json.loads(event["body"]) 67 | service_name = body["serviceName"] 68 | except (KeyError, TypeError): 69 | logger.warning({ 70 | "message": "Missing 'serviceName' in request body", 71 | "event": event 72 | }) 73 | return response("Missing 'serviceName' in request body", 400) 74 | 75 | logger.debug({ 76 | "message": f"Register {connection_id} with service '{service_name}'", 77 | "event": event 78 | }) 79 | 80 | register_service(connection_id, service_name) 81 | 82 | return response("Connected") 83 | -------------------------------------------------------------------------------- /platform/src/register/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /platform/tests/integ/test_on_events.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import hashlib 4 | import hmac 5 | import json 6 | import time 7 | import urllib 8 | import uuid 9 | import boto3 10 | import pytest 11 | import websockets # pylint: disable=import-error 12 | 13 | from fixtures import listener #pylint: disable=import-error 14 | from helpers import get_parameter # pylint: disable=no-name-in-module 15 | 16 | 17 | @pytest.fixture(scope="module") 18 | def listener_api_url(): 19 | return get_parameter("/ecommerce/{Environment}/platform/listener-api/url") 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | def event_bus_name(): 24 | return get_parameter("/ecommerce/{Environment}/platform/event-bus/name") 25 | 26 | 27 | def test_listener(listener, event_bus_name): 28 | service_name = "ecommerce.test" 29 | resource = str(uuid.uuid4()) 30 | event_type = "TestEvent" 31 | 32 | events = boto3.client("events") 33 | 34 | listener(service_name, lambda: 35 | events.put_events(Entries=[{ 36 | "Time": datetime.datetime.utcnow(), 37 | "Source": service_name, 38 | "Resources": [resource], 39 | "DetailType": event_type, 40 | "Detail": "{}", 41 | "EventBusName": event_bus_name 42 | }]), 43 | lambda x: ( 44 | x["source"] == service_name and 45 | x["resources"][0] == resource and 46 | x["detail-type"] == event_type 47 | ) 48 | ) 49 | -------------------------------------------------------------------------------- /platform/tests/unit/test_on_connect.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from botocore import stub, exceptions 3 | import pytest 4 | from fixtures import apigateway_event, context, lambda_module # pylint: disable=import-error 5 | from helpers import mock_table # pylint: disable=import-error,no-name-in-module 6 | 7 | 8 | lambda_module = pytest.fixture(scope="module", params=[{ 9 | "function_dir": "on_connect", 10 | "module_name": "main", 11 | "environ": { 12 | "ENVIRONMENT": "test", 13 | "EVENT_RULE_NAME": "EVENT_BUS_NAME|EVENT_RULE_NAME", 14 | "LISTENER_TABLE_NAME": "TABLE_NAME", 15 | "POWERTOOLS_TRACE_DISABLED": "true" 16 | } 17 | }])(lambda_module) 18 | context = pytest.fixture(context) 19 | 20 | 21 | def test_store_id(lambda_module): 22 | """ 23 | Test store_id() 24 | """ 25 | 26 | connection_id = str(uuid.uuid4()) 27 | table = mock_table( 28 | lambda_module.table, "put_item", ["id"], 29 | items={ 30 | "id": connection_id, 31 | "ttl": stub.ANY 32 | } 33 | ) 34 | 35 | lambda_module.store_id(connection_id) 36 | 37 | table.assert_no_pending_responses() 38 | table.deactivate() 39 | 40 | 41 | def test_handler(monkeypatch, lambda_module, context, apigateway_event): 42 | """ 43 | Test handler() 44 | """ 45 | 46 | connection_id = str(uuid.uuid4()) 47 | 48 | event = apigateway_event() 49 | event["requestContext"] = {"connectionId": connection_id} 50 | 51 | calls = { 52 | "store_id": 0 53 | } 54 | 55 | def store_id(connection_id_req: str): 56 | calls["store_id"] += 1 57 | assert connection_id_req == connection_id 58 | monkeypatch.setattr(lambda_module, "store_id", store_id) 59 | 60 | result = lambda_module.handler(event, context) 61 | 62 | assert result["statusCode"] == 200 63 | assert calls["store_id"] == 1 64 | 65 | 66 | def test_handler_no_id(monkeypatch, lambda_module, context, apigateway_event): 67 | """ 68 | Test handler() 69 | """ 70 | 71 | connection_id = str(uuid.uuid4()) 72 | 73 | event = apigateway_event() 74 | 75 | calls = { 76 | "store_id": 0 77 | } 78 | 79 | def store_id(connection_id_req: str): 80 | calls["store_id"] += 1 81 | assert connection_id_req == connection_id 82 | monkeypatch.setattr(lambda_module, "store_id", store_id) 83 | 84 | result = lambda_module.handler(event, context) 85 | 86 | assert result["statusCode"] == 400 87 | assert calls["store_id"] == 0 -------------------------------------------------------------------------------- /platform/tests/unit/test_on_disconnect.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from botocore import stub 3 | import pytest 4 | from fixtures import apigateway_event, context, lambda_module # pylint: disable=import-error 5 | from helpers import mock_table # pylint: disable=import-error,no-name-in-module 6 | 7 | 8 | lambda_module = pytest.fixture(scope="module", params=[{ 9 | "function_dir": "on_disconnect", 10 | "module_name": "main", 11 | "environ": { 12 | "ENVIRONMENT": "test", 13 | "EVENT_RULE_NAME": "EVENT_BUS_NAME|EVENT_RULE_NAME", 14 | "LISTENER_TABLE_NAME": "TABLE_NAME", 15 | "POWERTOOLS_TRACE_DISABLED": "true" 16 | } 17 | }])(lambda_module) 18 | context = pytest.fixture(context) 19 | 20 | 21 | def test_delete_id(lambda_module): 22 | """ 23 | Test delete_id() 24 | """ 25 | 26 | connection_id = str(uuid.uuid4()) 27 | table = mock_table( 28 | lambda_module.table, "delete_item", ["id"], 29 | items={"id": connection_id} 30 | ) 31 | 32 | lambda_module.delete_id(connection_id) 33 | 34 | table.assert_no_pending_responses() 35 | table.deactivate() 36 | 37 | 38 | def test_handler(monkeypatch, lambda_module, context, apigateway_event): 39 | """ 40 | Test handler() 41 | """ 42 | 43 | connection_id = str(uuid.uuid4()) 44 | 45 | event = apigateway_event() 46 | event["requestContext"] = {"connectionId": connection_id} 47 | 48 | calls = { 49 | "delete_id": 0 50 | } 51 | 52 | def delete_id(connection_id_req: str): 53 | calls["delete_id"] += 1 54 | assert connection_id_req == connection_id 55 | monkeypatch.setattr(lambda_module, "delete_id", delete_id) 56 | 57 | result = lambda_module.handler(event, context) 58 | 59 | assert result["statusCode"] == 200 60 | assert calls["delete_id"] == 1 61 | 62 | 63 | def test_handler_no_id(monkeypatch, lambda_module, context, apigateway_event): 64 | """ 65 | Test handler() 66 | """ 67 | 68 | connection_id = str(uuid.uuid4()) 69 | 70 | event = apigateway_event() 71 | 72 | calls = { 73 | "delete_id": 0 74 | } 75 | 76 | def delete_id(connection_id_req: str): 77 | calls["delete_id"] += 1 78 | assert connection_id_req == connection_id 79 | monkeypatch.setattr(lambda_module, "delete_id", delete_id) 80 | 81 | result = lambda_module.handler(event, context) 82 | 83 | assert result["statusCode"] == 400 84 | assert calls["delete_id"] == 0 -------------------------------------------------------------------------------- /products/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /products/README.md: -------------------------------------------------------------------------------- 1 | Products service 2 | ================ 3 | 4 | ![Products architecture diagram](images/products.png) 5 | 6 | ## API 7 | 8 | See [resources/openapi.yaml](resources/openapi.yaml) for a list of available API paths. 9 | 10 | ## Events 11 | 12 | See [resources/events.yaml](resources/events.yaml) for a list of available events. 13 | 14 | ## SSM Parameters 15 | 16 | This service defines the following SSM parameters: 17 | 18 | * `/ecommerce/{Environment}/products/api/arn`: ARN for the API Gateway 19 | * `/ecommerce/{Environment}/products/api/url`: URL for the API Gateway 20 | * `/ecommerce/{Environment}/products/table/name`: DynamoDB table containing the products -------------------------------------------------------------------------------- /products/images/products.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/products/images/products.png -------------------------------------------------------------------------------- /products/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: products 2 | dependencies: 3 | - platform 4 | permissions: 5 | - service: orders 6 | api: 7 | /validate: [post] 8 | parameters: 9 | EventBusArn: /ecommerce/{Environment}/platform/event-bus/arn 10 | EventBusName: /ecommerce/{Environment}/platform/event-bus/name -------------------------------------------------------------------------------- /products/resources/events.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: Products events 4 | version: 1.0.0 5 | license: 6 | name: MIT-0 7 | 8 | paths: {} 9 | 10 | components: 11 | schemas: 12 | ProductCreated: 13 | x-amazon-events-source: ecommerce.products 14 | x-amazon-events-detail-type: ProductCreated 15 | description: Event emitted when a product is created. 16 | allOf: 17 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 18 | - type: object 19 | properties: 20 | detail: 21 | $ref: "../../shared/resources/schemas.yaml#/Product" 22 | 23 | ProductModified: 24 | x-amazon-events-source: ecommerce.products 25 | x-amazon-events-detail-type: ProductModified 26 | description: Event emitted when a product is modified. 27 | allOf: 28 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 29 | - type: object 30 | properties: 31 | detail: 32 | type: object 33 | properties: 34 | old: 35 | $ref: "../../shared/resources/schemas.yaml#/Product" 36 | new: 37 | $ref: "../../shared/resources/schemas.yaml#/Product" 38 | 39 | ProductDeleted: 40 | x-amazon-events-sources: ecommerce.products 41 | x-amazon-events-detail-type: ProductDeleted 42 | description: Event emitted when a product is deleted. 43 | allOf: 44 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 45 | - type: object 46 | properties: 47 | detail: 48 | $ref: "../../shared/resources/schemas.yaml#/Product" 49 | -------------------------------------------------------------------------------- /products/resources/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: 4 | Fn::Sub: "${AWS::StackName}-api" 5 | version: 1.0.0 6 | description: Products service API definition 7 | license: 8 | name: MIT-0 9 | url: https://github.com/aws/mit-0 10 | 11 | paths: 12 | /backend/validate: 13 | post: 14 | description: | 15 | Validates an array of products. 16 | 17 | __Remark__: This is an internal API that requires valid IAM credentials 18 | and signature. 19 | operationId: backendValidateProducts 20 | requestBody: 21 | required: true 22 | content: 23 | application/json: 24 | schema: 25 | $ref: "../../shared/resources/schemas.yaml#/Products" 26 | responses: 27 | "200": 28 | description: OK 29 | content: 30 | application/json: 31 | schema: 32 | allOf: 33 | - type: object 34 | properties: 35 | # By not using the 'Products' schema, it makes products optional 36 | products: 37 | $ref: "../../shared/resources/schemas.yaml#/Product" 38 | - $ref: "../../shared/resources/schemas.yaml#/Message" 39 | default: 40 | description: Error 41 | content: 42 | application/json: 43 | schema: 44 | $ref: "../../shared/resources/schemas.yaml#/Message" 45 | x-amazon-apigateway-auth: 46 | type: AWS_IAM 47 | x-amazon-apigateway-integration: 48 | httpMethod: "POST" 49 | type: aws_proxy 50 | uri: 51 | Fn::Sub: "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ValidateFunction.Arn}/invocations" 52 | -------------------------------------------------------------------------------- /products/src/table_update/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | TableUpdateFunction 3 | """ 4 | 5 | 6 | import os 7 | from typing import List 8 | import boto3 9 | from boto3.dynamodb.types import TypeDeserializer 10 | from aws_lambda_powertools.tracing import Tracer 11 | from aws_lambda_powertools.logging.logger import Logger 12 | from ecom.eventbridge import ddb_to_event # pylint: disable=import-error 13 | 14 | 15 | ENVIRONMENT = os.environ["ENVIRONMENT"] 16 | EVENT_BUS_NAME = os.environ["EVENT_BUS_NAME"] 17 | 18 | 19 | eventbridge = boto3.client("events") # pylint: disable=invalid-name 20 | type_deserializer = TypeDeserializer() # pylint: disable=invalid-name 21 | logger = Logger() # pylint: disable=invalid-name 22 | tracer = Tracer() # pylint: disable=invalid-name 23 | 24 | 25 | @tracer.capture_method 26 | def send_events(events: List[dict]): 27 | """ 28 | Send events to EventBridge 29 | """ 30 | 31 | logger.info("Sending %d events to EventBridge", len(events)) 32 | # EventBridge only supports batches of up to 10 events 33 | for i in range(0, len(events), 10): 34 | eventbridge.put_events(Entries=events[i:i+10]) 35 | 36 | 37 | @logger.inject_lambda_context 38 | @tracer.capture_lambda_handler 39 | def handler(event, _): 40 | """ 41 | Lambda function handler for Products Table stream 42 | """ 43 | 44 | logger.debug({ 45 | "message": "Input event", 46 | "event": event 47 | }) 48 | 49 | logger.debug({ 50 | "message": "Records received", 51 | "records": event.get("Records", []) 52 | }) 53 | 54 | events = [ 55 | ddb_to_event(record, EVENT_BUS_NAME, "ecommerce.products", "Product", "productId") 56 | for record in event.get("Records", []) 57 | ] 58 | 59 | logger.info("Received %d event(s)", len(events)) 60 | logger.debug({ 61 | "message": "Events processed from records", 62 | "events": events 63 | }) 64 | 65 | send_events(events) 66 | -------------------------------------------------------------------------------- /products/src/table_update/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /products/src/validate/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /products/tests/integ/test_events.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import uuid 4 | import pytest 5 | import boto3 6 | from fixtures import listener # pylint: disable=import-error 7 | from helpers import get_parameter # pylint: disable=import-error,no-name-in-module 8 | 9 | 10 | @pytest.fixture 11 | def table_name(): 12 | """ 13 | DynamoDB table name 14 | """ 15 | 16 | return get_parameter("/ecommerce/{Environment}/products/table/name") 17 | 18 | 19 | @pytest.fixture 20 | def product(): 21 | return { 22 | "productId": str(uuid.uuid4()), 23 | "name": "New product", 24 | "package": { 25 | "width": 200, 26 | "length": 100, 27 | "height": 50, 28 | "weight": 1000 29 | }, 30 | "price": 500 31 | } 32 | 33 | 34 | def test_table_update(table_name, listener, product): 35 | """ 36 | Test that the TableUpdate function reacts to changes to DynamoDB and sends 37 | events to EventBridge 38 | """ 39 | # Add a new item 40 | table = boto3.resource("dynamodb").Table(table_name) # pylint: disable=no-member 41 | 42 | # Listen for messages on EventBridge 43 | listener( 44 | "ecommerce.products", 45 | lambda: table.put_item(Item=product), 46 | lambda m: product["productId"] in m["resources"] and m["detail-type"] == "ProductCreated" 47 | ) 48 | 49 | # Listen for messages on EventBridge 50 | listener( 51 | "ecommerce.products", 52 | lambda: table.delete_item(Key={"productId": product["productId"]}), 53 | lambda m: product["productId"] in m["resources"] and m["detail-type"] == "ProductDeleted" 54 | ) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | awscli>=1.21.2 2 | aws-requests-auth==0.4.3 3 | cfn-lint==0.54.3 4 | jsonschema==3.2.0 5 | locust==1.0.3 6 | pylint==2.11.1 7 | pytest==5.4.3 8 | pytest-cov==2.8.1 9 | PyYAML==5.4.1 10 | requests==2.26.0 11 | requests-mock==1.9.3 12 | websockets==9.1 13 | yq==2.12.2 14 | -------------------------------------------------------------------------------- /shared/environments/schema.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Environments file 3 | 4 | additionalProperties: 5 | type: object 6 | description: Represents a single environment 7 | properties: 8 | parameters: 9 | type: object 10 | description: | 11 | CloudFormation Parameters and their values for a specific environment. 12 | additionalProperties: 13 | type: string 14 | tags: 15 | type: object 16 | description: | 17 | Tags for CloudFormation Template Configuration file 18 | additionalProperties: 19 | type: string 20 | flags: 21 | type: object 22 | properties: 23 | can-tests-integ: 24 | type: boolean 25 | default: true 26 | can-tests-e2e: 27 | type: boolean 28 | default: true 29 | is-prod: 30 | type: boolean 31 | default: false -------------------------------------------------------------------------------- /shared/lint/speccy.yaml: -------------------------------------------------------------------------------- 1 | lint: 2 | rules: 3 | - strict 4 | skip: 5 | - info-contact 6 | - operation-tags 7 | - openapi-tags 8 | - path-keys-no-trailing-slash -------------------------------------------------------------------------------- /shared/makefiles/cfn-nocode.mk: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build cloudformation ${SERVICE} 13 | .PHONY: build 14 | 15 | check-deps: 16 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 17 | 18 | clean: 19 | @${ROOT}/tools/clean ${SERVICE} 20 | 21 | deploy: 22 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 23 | 24 | lint: 25 | @${ROOT}/tools/lint cloudformation ${SERVICE} 26 | @${ROOT}/tools/lint openapi ${SERVICE} 27 | 28 | package: 29 | @${ROOT}/tools/package cloudformation ${SERVICE} 30 | 31 | teardown: 32 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 33 | 34 | tests-integ: 35 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 36 | 37 | tests-unit: 38 | @echo "Skipping unit tests" -------------------------------------------------------------------------------- /shared/makefiles/cfn-python3.mk: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /shared/makefiles/empty.mk: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | $(error "Target $@ is not implemented.") 8 | 9 | build: 10 | $(error "Target $@ is not implemented.") 11 | .PHONY: build 12 | 13 | check-deps: 14 | $(error "Target $@ is not implemented.") 15 | 16 | clean: 17 | $(error "Target $@ is not implemented.") 18 | 19 | deploy: 20 | $(error "Target $@ is not implemented.") 21 | 22 | lint: 23 | $(error "Target $@ is not implemented.") 24 | 25 | package: 26 | $(error "Target $@ is not implemented.") 27 | 28 | teardown: 29 | $(error "Target $@ is not implemented.") 30 | 31 | tests-integ: 32 | $(error "Target $@ is not implemented.") 33 | 34 | tests-unit: 35 | $(error "Target $@ is not implemented.") -------------------------------------------------------------------------------- /shared/metadata/schema.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Metadata file 3 | 4 | additionalProperties: false 5 | required: 6 | - name 7 | properties: 8 | name: 9 | description: Service name 10 | type: string 11 | 12 | permissions: 13 | type: array 14 | items: 15 | type: object 16 | additionalProperties: false 17 | required: 18 | - service 19 | properties: 20 | service: 21 | description: Service name 22 | type: string 23 | api: 24 | type: object 25 | additionalProperties: false 26 | patternProperties: 27 | ^(/[a-zA-Z0-9\-_\.]*)+$: 28 | type: array 29 | description: API path 30 | items: 31 | description: Method 32 | enum: 33 | - get 34 | - post 35 | - put 36 | - delete 37 | - options 38 | - any 39 | events: 40 | type: array 41 | items: 42 | description: Detail-type of the event 43 | type: string 44 | 45 | dependencies: 46 | type: array 47 | items: 48 | type: string 49 | 50 | parameters: 51 | type: object 52 | additionalProperties: 53 | type: string 54 | 55 | flags: 56 | type: object 57 | properties: 58 | environment: 59 | type: boolean 60 | description: Whether or not this resource supports environments 61 | default: true 62 | skip-tests: 63 | type: boolean 64 | description: If true, always return a success instead of running tests 65 | default: false -------------------------------------------------------------------------------- /shared/src/ecom/ecom/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lambda function helpers for AWS Serverless Ecommerce Platform 3 | 4 | To use this, add "shared/src/ecom/" in your requirements.txt for your Lambda 5 | function. 6 | """ 7 | 8 | from . import apigateway, eventbridge, helpers -------------------------------------------------------------------------------- /shared/src/ecom/ecom/apigateway.py: -------------------------------------------------------------------------------- 1 | """ 2 | API Gateway helpers for Lambda functions 3 | """ 4 | 5 | 6 | import json 7 | from typing import Dict, Optional, Union 8 | from .helpers import Encoder 9 | 10 | 11 | __all__ = [ 12 | "cognito_user_id", "iam_user_id", "response" 13 | ] 14 | 15 | 16 | def cognito_user_id(event: dict) -> Optional[str]: 17 | """ 18 | Returns the User ID (sub) from cognito or None 19 | """ 20 | 21 | try: 22 | return event["requestContext"]["authorizer"]["claims"]["sub"] 23 | except (TypeError, KeyError): 24 | return None 25 | 26 | 27 | def iam_user_id(event: dict) -> Optional[str]: 28 | """ 29 | Returns the User ID (ARN) from IAM or None 30 | """ 31 | 32 | try: 33 | return event["requestContext"]["identity"]["userArn"] 34 | except (TypeError, KeyError): 35 | return None 36 | 37 | 38 | def response( 39 | msg: Union[dict, str], 40 | status_code: int = 200, 41 | allow_origin: str = "*", 42 | allow_headers: str = "Content-Type,X-Amz-Date,Authorization,X-Api-Key,x-requested-with", 43 | allow_methods: str = "GET,POST,PUT,DELETE,OPTIONS" 44 | ) -> Dict[str, Union[int, str]]: 45 | """ 46 | Returns a response for API Gateway 47 | """ 48 | 49 | if isinstance(msg, str): 50 | msg = {"message": msg} 51 | 52 | return { 53 | "statusCode": status_code, 54 | "headers": { 55 | "Access-Control-Allow-Headers": allow_headers, 56 | "Access-Control-Allow-Origin": allow_origin, 57 | "Access-Control-Allow-Methods": allow_methods 58 | }, 59 | "body": json.dumps(msg, cls=Encoder) 60 | } 61 | -------------------------------------------------------------------------------- /shared/src/ecom/ecom/eventbridge.py: -------------------------------------------------------------------------------- 1 | """ 2 | EventBridge helpers for Lambda functions 3 | """ 4 | 5 | 6 | from datetime import datetime 7 | import json 8 | import os 9 | from boto3.dynamodb.types import TypeDeserializer 10 | from .helpers import Encoder 11 | 12 | 13 | __all__ = ["ddb_to_event"] 14 | deserialize = TypeDeserializer().deserialize 15 | 16 | 17 | def ddb_to_event( 18 | ddb_record: dict, 19 | event_bus_name: str, 20 | source: str, 21 | object_type: str, 22 | resource_key: str 23 | ) -> dict: 24 | """ 25 | Transforms a DynamoDB Streams record into an EventBridge event 26 | 27 | For this function to works, you need to have a StreamViewType of 28 | NEW_AND_OLD_IMAGES. 29 | """ 30 | 31 | event = { 32 | "Time": datetime.now(), 33 | "Source": source, 34 | "Resources": [ 35 | str(deserialize(ddb_record["dynamodb"]["Keys"][resource_key])) 36 | ], 37 | "EventBusName": event_bus_name 38 | } 39 | 40 | # Inject X-Ray trace ID 41 | trace_id = os.environ.get("_X_AMZN_TRACE_ID", None) 42 | if trace_id: 43 | event["TraceHeader"] = trace_id 44 | 45 | # Created event 46 | if ddb_record["eventName"].upper() == "INSERT": 47 | event["DetailType"] = "{}Created".format(object_type) 48 | event["Detail"] = json.dumps({ 49 | k: deserialize(v) 50 | for k, v 51 | in ddb_record["dynamodb"]["NewImage"].items() 52 | }, cls=Encoder) 53 | 54 | # Deleted event 55 | elif ddb_record["eventName"].upper() == "REMOVE": 56 | event["DetailType"] = "{}Deleted".format(object_type) 57 | event["Detail"] = json.dumps({ 58 | k: deserialize(v) 59 | for k, v 60 | in ddb_record["dynamodb"]["OldImage"].items() 61 | }, cls=Encoder) 62 | 63 | elif ddb_record["eventName"].upper() == "MODIFY": 64 | new = { 65 | k: deserialize(v) 66 | for k, v 67 | in ddb_record["dynamodb"]["NewImage"].items() 68 | } 69 | old = { 70 | k: deserialize(v) 71 | for k, v 72 | in ddb_record["dynamodb"]["OldImage"].items() 73 | } 74 | 75 | # Old keys not in NewImage 76 | changed = [k for k in old.keys() if k not in new.keys()] 77 | for k in new.keys(): 78 | # New keys not in OldImage 79 | if k not in old.keys(): 80 | changed.append(k) 81 | # New keys that are not equal to old values 82 | elif new[k] != old[k]: 83 | changed.append(k) 84 | 85 | event["DetailType"] = "{}Modified".format(object_type) 86 | event["Detail"] = json.dumps({ 87 | "new": new, 88 | "old": old, 89 | "changed": changed 90 | }, cls=Encoder) 91 | 92 | else: 93 | raise ValueError("Wrong eventName value for DynamoDB event: {}".format(ddb_record["eventName"])) 94 | 95 | return event -------------------------------------------------------------------------------- /shared/src/ecom/ecom/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers for Lambda functions 3 | """ 4 | 5 | 6 | from datetime import datetime, date 7 | from decimal import Decimal 8 | import json 9 | 10 | 11 | __all__ = ["Encoder"] 12 | 13 | 14 | class Encoder(json.JSONEncoder): 15 | """ 16 | Helper class to convert a DynamoDB item to JSON 17 | """ 18 | 19 | def default(self, o): # pylint: disable=method-hidden 20 | if isinstance(o, datetime) or isinstance(o, date): 21 | return o.isoformat() 22 | if isinstance(o, Decimal): 23 | if abs(o) % 1 > 0: 24 | return float(o) 25 | return int(o) 26 | return super(Encoder, self).default(o) -------------------------------------------------------------------------------- /shared/src/ecom/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | -------------------------------------------------------------------------------- /shared/src/ecom/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | from setuptools import find_packages, setup 5 | 6 | 7 | setup( 8 | author="Amazon Web Services", 9 | install_requires=["boto3"], 10 | license="MIT-0", 11 | name="ecom", 12 | packages=find_packages(), 13 | setup_requires=["pytest-runner"], 14 | test_suite="tests", 15 | tests_require=["pytest"], 16 | version="0.1.2" 17 | ) -------------------------------------------------------------------------------- /shared/templates/dlq.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | 3 | 4 | Parameters: 5 | AlarmAction: 6 | Type: String 7 | Default: "" 8 | Threshold: 9 | Type: Number 10 | Description: Number of messages sent before triggering the alarm 11 | Default: 1 12 | 13 | 14 | Conditions: 15 | HasAction: !Not [!Equals [!Ref AlarmAction, ""]] 16 | 17 | 18 | Resources: 19 | DeadLetterQueue: 20 | Type: AWS::SQS::Queue 21 | 22 | DeadLetterQueueAlarm: 23 | Type: AWS::CloudWatch::Alarm 24 | Properties: 25 | AlarmActions: !If [ HasAction, [!Ref AlarmAction], !Ref AWS::NoValue ] 26 | ComparisonOperator: GreaterThanOrEqualToThreshold 27 | Dimensions: 28 | - Name: QueueName 29 | Value: !GetAtt DeadLetterQueue.QueueName 30 | EvaluationPeriods: 1 31 | MetricName: NumberOfMessagesSent 32 | Namespace: AWS/SQS 33 | Period: 300 34 | Statistic: Sum 35 | Threshold: !Ref Threshold 36 | TreatMissingData: notBreaching 37 | Unit: Count 38 | 39 | 40 | Outputs: 41 | QueueArn: 42 | Value: !GetAtt DeadLetterQueue.Arn 43 | 44 | QueueName: 45 | Value: !GetAtt DeadLetterQueue.QueueName 46 | 47 | QueueURL: 48 | Value: !Ref DeadLetterQueue -------------------------------------------------------------------------------- /shared/tests/integ/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import boto3 4 | 5 | 6 | def compare_dict(a: dict, b: dict): 7 | """ 8 | Compare two dicts 9 | 10 | This compares only based on the keys in 'a'. Therefore, you should put the 11 | test event as the first parameter. 12 | """ 13 | 14 | for key, value in a.items(): 15 | assert key in b 16 | 17 | if key not in b: 18 | continue 19 | 20 | if isinstance(value, dict): 21 | compare_dict(value, b[key]) 22 | else: 23 | assert value == b[key] 24 | 25 | 26 | def get_parameter(param_name: str): 27 | """ 28 | Retrieve an SSM parameter 29 | """ 30 | 31 | ssm = boto3.client("ssm") 32 | 33 | return ssm.get_parameter( 34 | Name=param_name.format(Environment=os.environ["ECOM_ENVIRONMENT"]) 35 | )["Parameter"]["Value"] -------------------------------------------------------------------------------- /shared/tests/unit/coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | # Omit dependencies 6 | omit = 7 | */build/src/*/six.py 8 | */build/src/*/zipp.py 9 | */build/src/*/*/* 10 | show_missing = True 11 | fail_under = 90 -------------------------------------------------------------------------------- /tools/artifacts: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | build_dir=$service_dir/build 11 | 12 | display_usage () { 13 | echo "Usage: $0 TYPE SERVICE" 14 | } 15 | 16 | # Check if there are at least 2 arguments 17 | if [ $# -lt 2 ]; then 18 | display_usage 19 | exit 1 20 | fi 21 | 22 | # Check if the service exists 23 | if [ ! -f $service_dir/metadata.yaml ]; then 24 | echo "Service $SERVICE does not exist" 25 | exit 1 26 | fi 27 | 28 | # Check for quiet mode 29 | if [ ! -z $QUIET ]; then 30 | export OUTPUT_FILE=$(mktemp) 31 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 32 | fi 33 | 34 | cleanup () { 35 | CODE=$? 36 | if [ ! -z $QUIET ]; then 37 | if [ ! $CODE -eq 0 ]; then 38 | cat $OUTPUT_FILE >&5 39 | fi 40 | rm $OUTPUT_FILE 41 | fi 42 | } 43 | trap cleanup EXIT 44 | 45 | artifacts_cloudformation () { 46 | if [ ! -d $build_dir ]; then 47 | echo "$build_dir does not exist." 48 | exit 1 49 | fi 50 | 51 | template_file=$build_dir/template.out 52 | if [ ! -f $template_file ]; then 53 | echo "$template_file does not exist." 54 | exit 1 55 | fi 56 | 57 | # Create a temporary folder 58 | tmpdir=$(mktemp -d) 59 | 60 | # Copy artifacts 61 | cp -pv $template_file $tmpdir/template.yaml 62 | for artifact_file in $build_dir/artifacts/*; do 63 | cp -pv $artifact_file $tmpdir/$(basename $artifact_file) 64 | done 65 | 66 | # Create zip file 67 | pushd $tmpdir 68 | zip $build_dir/artifacts.zip * 69 | popd 70 | 71 | # Delete the temporary folder 72 | rm -r $tmpdir 73 | } 74 | 75 | type artifacts_$TYPE | grep -q "function" &>/dev/null || { 76 | echo "Unsupported type: $TYPE" 77 | echo 78 | display_usage 79 | exit 1 80 | } 81 | artifacts_$TYPE -------------------------------------------------------------------------------- /tools/check-deps: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | 11 | display_usage () { 12 | echo "Usage: $0 TYPE SERVICE" 13 | } 14 | 15 | # Check if there are at least 2 arguments 16 | if [ $# -lt 2 ]; then 17 | display_usage 18 | exit 1 19 | fi 20 | 21 | # Check if the service exists 22 | if [ ! -f $service_dir/metadata.yaml ]; then 23 | echo "Service $SERVICE does not exist" 24 | exit 1 25 | fi 26 | 27 | # Check for quiet mode 28 | if [ ! -z $QUIET ]; then 29 | export OUTPUT_FILE=$(mktemp) 30 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 31 | fi 32 | 33 | cleanup () { 34 | CODE=$? 35 | if [ ! -z $QUIET ]; then 36 | if [ ! $CODE -eq 0 ]; then 37 | cat $OUTPUT_FILE >&5 38 | fi 39 | rm $OUTPUT_FILE 40 | fi 41 | } 42 | trap cleanup EXIT 43 | 44 | check-deps_cloudformation () { 45 | # Gather dependencies 46 | dependencies=$(yq -r ' .dependencies[]? ' $service_dir/metadata.yaml) 47 | # If it contains '*', this means all other services must be deployed 48 | echo "$dependencies" | grep -q \* && { 49 | dependencies="$($ROOT/tools/services --exclude ${SERVICE})" 50 | } 51 | echo dependencies=$dependencies 52 | 53 | # Check if all dependencies are deployed 54 | for dependency in $dependencies; do 55 | stack_name=${DOMAIN:-ecommerce}-${ENVIRONMENT:-dev}-${dependency} 56 | aws cloudformation describe-stacks --stack-name $stack_name | \ 57 | jq -r ' .Stacks | 58 | map( 59 | .StackStatus 60 | | test("(CREATE_FAILED|ROLLBACK_IN_PROGRESS|ROLLBACK_COMPLETE|ROLLBACK_FAILED|DELETE_IN_PROGRESS|DELETE_COMPLETE|DELETE_FAILED)") 61 | | not 62 | ) ' | \ 63 | grep -q "true" && { 64 | FOUND=true 65 | } 66 | 67 | # Checking in case the service doesn't support environments 68 | if [ -z $FOUND ]; then 69 | echo "Stack $stack_name not found" 70 | stack_name=${DOMAIN:-ecommerce}-${dependency} 71 | aws cloudformation describe-stacks --stack-name $stack_name | \ 72 | jq -r ' .Stacks | 73 | map( 74 | .StackStatus 75 | | test("(CREATE_FAILED|ROLLBACK_IN_PROGRESS|ROLLBACK_COMPLETE|ROLLBACK_FAILED|DELETE_IN_PROGRESS|DELETE_COMPLETE|DELETE_FAILED)") 76 | | not 77 | ) ' | \ 78 | grep -q "true" && { 79 | FOUND=true 80 | } 81 | fi 82 | 83 | if [ -z $FOUND ]; then 84 | echo "Stack $stack_name not found" 85 | exit 1 86 | fi 87 | done 88 | } 89 | 90 | type check-deps_$TYPE | grep -q "function" &>/dev/null || { 91 | echo "Unsupported type: $TYPE" 92 | echo 93 | display_usage 94 | exit 1 95 | } 96 | check-deps_$TYPE -------------------------------------------------------------------------------- /tools/clean: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | SERVICE=$1 7 | 8 | service_dir=$ROOT/$SERVICE 9 | build_dir=$service_dir/build 10 | 11 | display_usage () { 12 | echo "Usage: $0 TYPE SERVICE" 13 | } 14 | 15 | # Check if there are at least 2 arguments 16 | if [ $# -lt 1 ]; then 17 | display_usage 18 | exit 1 19 | fi 20 | 21 | # Check if the service exists 22 | if [ ! -f $service_dir/metadata.yaml ]; then 23 | echo "Service $SERVICE does not exist" 24 | exit 1 25 | fi 26 | 27 | # Check for quiet mode 28 | if [ ! -z $QUIET ]; then 29 | export OUTPUT_FILE=$(mktemp) 30 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 31 | fi 32 | 33 | cleanup () { 34 | CODE=$? 35 | if [ ! -z $QUIET ]; then 36 | if [ ! $CODE -eq 0 ]; then 37 | cat $OUTPUT_FILE >&5 38 | fi 39 | rm $OUTPUT_FILE 40 | fi 41 | } 42 | trap cleanup EXIT 43 | 44 | clean () { 45 | if [ -z $build_dir ]; then 46 | echo "\$build_dir not set. Aborting" 47 | exit 1 48 | fi 49 | if [ -d $build_dir ]; then 50 | echo "Removing $build_dir" 51 | rm -r $build_dir 52 | else 53 | echo "$build_dir does not exist. Skipping" 54 | fi 55 | } 56 | 57 | clean -------------------------------------------------------------------------------- /tools/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | build_dir=$service_dir/build 11 | 12 | display_usage () { 13 | echo "Usage: $0 TYPE SERVICE" 14 | } 15 | 16 | # Check if there are at least 2 arguments 17 | if [ $# -lt 2 ]; then 18 | display_usage 19 | exit 1 20 | fi 21 | 22 | # Check if the service exists 23 | if [ ! -f $service_dir/metadata.yaml ]; then 24 | echo "Service $SERVICE does not exist" 25 | exit 1 26 | fi 27 | 28 | # Check for quiet mode 29 | if [ ! -z $QUIET ]; then 30 | export OUTPUT_FILE=$(mktemp) 31 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 32 | fi 33 | 34 | cleanup () { 35 | CODE=$? 36 | if [ ! -z $QUIET ]; then 37 | if [ ! $CODE -eq 0 ]; then 38 | cat $OUTPUT_FILE >&5 39 | fi 40 | rm $OUTPUT_FILE 41 | fi 42 | } 43 | trap cleanup EXIT 44 | 45 | deploy_cloudformation () { 46 | if [ -f $build_dir/template.out ]; then 47 | environment=${ENVIRONMENT:-dev} 48 | stack_name=${DOMAIN:-ecommerce}-${environment}-${SERVICE} 49 | artifacts_file=$build_dir/artifacts/config.${environment}.json 50 | 51 | # Update variable for services that don't support environments 52 | yq -r ' .flags.environment | if . == null then true else . end ' $service_dir/metadata.yaml | grep -q true && { 53 | HAS_ENV=true 54 | } 55 | if [ -z $HAS_ENV ]; then 56 | stack_name=${DOMAIN:-ecommerce}-$SERVICE 57 | environment=prod 58 | artifacts_file=$build_dir/artifacts/config.prod.json 59 | fi 60 | 61 | # Parse parameters and tags 62 | parameters=$(jq -r ' .Parameters | to_entries[] | [.key, .value] | "\(.[0])=\(.[1])"' $artifacts_file) 63 | tags=$(jq -r ' .Tags | to_entries[] | [.key, .value] | "\(.[0])=\(.[1])"' $artifacts_file) 64 | 65 | # Display variables for debugging 66 | echo stack_name: $stack_name 67 | echo environment: $environment 68 | echo parameters: $parameters 69 | echo tags: $tags 70 | 71 | # Deploy to AWS 72 | aws cloudformation deploy \ 73 | --stack-name $stack_name \ 74 | --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ 75 | --parameter-overrides $parameters \ 76 | --tags $tags \ 77 | --no-fail-on-empty-changeset \ 78 | --template-file $build_dir/template.out 79 | fi 80 | } 81 | 82 | type deploy_$TYPE | grep -q "function" &>/dev/null || { 83 | echo "Unsupported type: $TYPE" 84 | echo 85 | display_usage 86 | exit 1 87 | } 88 | deploy_$TYPE -------------------------------------------------------------------------------- /tools/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | 11 | display_usage () { 12 | echo "Usage: $0 TYPE SERVICE" 13 | } 14 | 15 | # Check if there are at least 2 arguments 16 | if [ $# -lt 2 ]; then 17 | display_usage 18 | exit 1 19 | fi 20 | 21 | # Check if the service exists 22 | if [ ! -f $service_dir/metadata.yaml ]; then 23 | echo "Service $SERVICE does not exist" 24 | exit 1 25 | fi 26 | 27 | # Check for quiet mode 28 | if [ ! -z $QUIET ]; then 29 | export OUTPUT_FILE=$(mktemp) 30 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 31 | fi 32 | 33 | cleanup () { 34 | CODE=$? 35 | if [ ! -z $QUIET ]; then 36 | if [ ! $CODE -eq 0 ]; then 37 | cat $OUTPUT_FILE >&5 38 | fi 39 | rm $OUTPUT_FILE 40 | fi 41 | } 42 | trap cleanup EXIT 43 | 44 | # Lint the CloudFormation template 45 | lint_cloudformation () { 46 | # Setup which checks to ignore 47 | ignore_checks="--ignore-checks W2001" 48 | # Ignore E9000, which checks if there is an 'Environment' parameter in the template 49 | yq -r ' .flags.environment | if . == null then true else . end ' $service_dir/metadata.yaml | grep -q true || { 50 | ignore_checks="$ignore_checks --ignore-checks E9000" 51 | } 52 | 53 | if [ -f $service_dir/template.yaml ]; then 54 | cfn-lint $service_dir/template.yaml \ 55 | --append-rules $ROOT/shared/lint/rules/ \ 56 | $ignore_checks 57 | fi 58 | } 59 | 60 | # Lint python source code 61 | lint_python3 () { 62 | if [ -d $service_dir/src/ ]; then 63 | pylint --rcfile $ROOT/shared/lint/pylintrc $service_dir/src/*/*.py || { 64 | EXIT_CODE=$? 65 | if [ $EXIT_CODE -eq 1 -o $EXIT_CODE -eq 2 -o $EXIT_CODE -eq 32 ]; then 66 | exit 1 67 | fi 68 | } 69 | fi 70 | } 71 | 72 | lint_openapi () { 73 | if [ -f $service_dir/resources/openapi.yaml ]; then 74 | speccy --config $ROOT/shared/lint/speccy.yaml \ 75 | $service_dir/resources/openapi.yaml 76 | fi 77 | } 78 | 79 | type lint_$TYPE | grep -q "function" &>/dev/null || { 80 | echo "Unsupported type: $TYPE" 81 | echo 82 | display_usage 83 | exit 1 84 | } 85 | lint_$TYPE -------------------------------------------------------------------------------- /tools/package: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | build_dir=$service_dir/build 11 | 12 | display_usage () { 13 | echo "Usage: $0 TYPE SERVICE" 14 | } 15 | 16 | # Check if there are at least 2 arguments 17 | if [ $# -lt 2 ]; then 18 | display_usage 19 | exit 1 20 | fi 21 | 22 | # Check if the service exists 23 | if [ ! -f $service_dir/metadata.yaml ]; then 24 | echo "Service $SERVICE does not exist" 25 | exit 1 26 | fi 27 | 28 | # Check for quiet mode 29 | if [ ! -z $QUIET ]; then 30 | export OUTPUT_FILE=$(mktemp) 31 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 32 | fi 33 | 34 | cleanup () { 35 | CODE=$? 36 | if [ ! -z $QUIET ]; then 37 | if [ ! $CODE -eq 0 ]; then 38 | cat $OUTPUT_FILE >&5 39 | fi 40 | rm $OUTPUT_FILE 41 | fi 42 | } 43 | trap cleanup EXIT 44 | 45 | package_cloudformation () { 46 | if [ -f $service_dir/build/template.yaml ]; then 47 | # Construct the S3 Bucket name 48 | aws_region=$(python3 -c 'import boto3; print(boto3.Session().region_name)') 49 | aws_account_id=$(aws sts get-caller-identity | jq .Account -r) 50 | s3_bucket="ecommerce-artifacts-${aws_account_id}-${aws_region}" 51 | # Create the S3 bucket if it doesn't exist 52 | aws s3api head-bucket --bucket $s3_bucket || { 53 | aws s3 mb s3://$s3_bucket --region ${aws_region} 54 | } 55 | 56 | pushd $build_dir 57 | aws cloudformation package \ 58 | --s3-bucket $s3_bucket \ 59 | --s3-prefix "artifacts" \ 60 | --template-file template.yaml \ 61 | --output-template-file template.out || { 62 | # Delay failure so we can popd 63 | FAILED=yes 64 | } 65 | popd 66 | if [ ! -z $FAILED ]; then 67 | exit 1 68 | fi 69 | fi 70 | } 71 | 72 | type package_$TYPE | grep -q "function" &>/dev/null || { 73 | echo "Unsupported type: $TYPE" 74 | echo 75 | display_usage 76 | exit 1 77 | } 78 | package_$TYPE -------------------------------------------------------------------------------- /tools/teardown: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | build_dir=$service_dir/build 11 | 12 | display_usage () { 13 | echo "Usage: $0 TYPE SERVICE" 14 | } 15 | 16 | # Check if there are at least 2 arguments 17 | if [ $# -lt 2 ]; then 18 | display_usage 19 | exit 1 20 | fi 21 | 22 | # Check if the service exists 23 | if [ ! -f $service_dir/metadata.yaml ]; then 24 | echo "Service $SERVICE does not exist" 25 | exit 1 26 | fi 27 | 28 | # Check for quiet mode 29 | if [ ! -z $QUIET ]; then 30 | export OUTPUT_FILE=$(mktemp) 31 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 32 | fi 33 | 34 | cleanup () { 35 | CODE=$? 36 | if [ ! -z $QUIET ]; then 37 | if [ ! $CODE -eq 0 ]; then 38 | cat $OUTPUT_FILE >&5 39 | fi 40 | rm $OUTPUT_FILE 41 | fi 42 | } 43 | trap cleanup EXIT 44 | 45 | teardown_cloudformation () { 46 | stack_name=${DOMAIN:-ecommerce}-${ENVIRONMENT:-dev}-${SERVICE} 47 | 48 | # Update stack name for services that don't support environments 49 | yq -r ' .flags.environment | if . == null then true else . end ' $service_dir/metadata.yaml | grep -q true || { 50 | stack_name=${DOMAIN:-ecommerce}-$SERVICE 51 | } 52 | 53 | # Display variables for debugging 54 | echo stack_name: $stack_name 55 | 56 | aws cloudformation delete-stack --stack-name $stack_name 57 | aws cloudformation wait stack-delete-complete --stack-name $stack_name 58 | } 59 | 60 | type teardown_$TYPE | grep -q "function" &>/dev/null || { 61 | echo "Unsupported type: $TYPE" 62 | echo 63 | display_usage 64 | exit 1 65 | } 66 | teardown_$TYPE -------------------------------------------------------------------------------- /tools/tests-e2e: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | 7 | tests_dir=${ROOT}/shared/tests/e2e 8 | reports_dir=${REPORTS_DIR:-$ROOT/reports} 9 | 10 | display_usage () { 11 | echo "Usage: $0 TYPE SERVICE" 12 | } 13 | 14 | 15 | # Check for quiet mode 16 | if [ ! -z $QUIET ]; then 17 | export OUTPUT_FILE=$(mktemp) 18 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 19 | fi 20 | 21 | cleanup () { 22 | CODE=$? 23 | if [ ! -z $QUIET ]; then 24 | if [ ! $CODE -eq 0 ]; then 25 | cat $OUTPUT_FILE >&5 26 | fi 27 | rm $OUTPUT_FILE 28 | fi 29 | } 30 | trap cleanup EXIT 31 | 32 | # End-to-end tests 33 | tests_e2e () { 34 | # Skip tests on environment where we shouldn't run tests 35 | yq -r ' .'${ENVIRONMENT}'.flags["can-tests-e2e"] | if . == null then true else . end ' $ROOT/environments.yaml | grep -q true || { 36 | echo "The environment $ENVIRONMENT does not support tests. Skipping" 37 | exit 0 38 | } 39 | 40 | if [ -z $PYTHONPATH ]; then 41 | PYTHONPATH=$ROOT/shared/tests/integ 42 | else 43 | PYTHONPATH=$PYTHONPATH:$ROOT/shared/tests/integ 44 | fi 45 | 46 | ECOM_ENVIRONMENT=${ENVIRONMENT} \ 47 | PYTHONPATH=${PYTHONPATH} \ 48 | pytest $tests_dir \ 49 | --override-ini junit_family=xunit2 \ 50 | --junitxml $reports_dir/e2e.xml 51 | } 52 | 53 | tests_e2e -------------------------------------------------------------------------------- /tools/tests-integ: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | tests_dir=$service_dir/tests/integ 11 | reports_dir=${REPORTS_DIR:-$ROOT/reports} 12 | 13 | display_usage () { 14 | echo "Usage: $0 TYPE SERVICE" 15 | } 16 | 17 | # Check if there are at least 2 arguments 18 | if [ $# -lt 2 ]; then 19 | display_usage 20 | exit 1 21 | fi 22 | 23 | # Check if the service exists 24 | if [ ! -f $service_dir/metadata.yaml ]; then 25 | echo "Service $SERVICE does not exist" 26 | exit 1 27 | fi 28 | 29 | # Check for quiet mode 30 | if [ ! -z $QUIET ]; then 31 | export OUTPUT_FILE=$(mktemp) 32 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 33 | fi 34 | 35 | cleanup () { 36 | CODE=$? 37 | if [ ! -z $QUIET ]; then 38 | if [ ! $CODE -eq 0 ]; then 39 | cat $OUTPUT_FILE >&5 40 | fi 41 | rm $OUTPUT_FILE 42 | fi 43 | } 44 | trap cleanup EXIT 45 | 46 | # Integration tests for cloudformation 47 | tests_cloudformation () { 48 | # Checks if this service skips tests 49 | yq -r ' .flags["skip-tests"] | if . == null then false else . end ' $service_dir/metadata.yaml | grep -q true && { 50 | echo "Skipping integration tests for $SERVICE" 51 | exit 0 52 | } 53 | 54 | # Skip tests on environment where we shouldn't run tests 55 | yq -r ' .'${ENVIRONMENT}'.flags["can-tests-integ"] | if . == null then true else . end ' $ROOT/environments.yaml | grep -q true || { 56 | echo "The environment $ENVIRONMENT does not support tests. Skipping" 57 | exit 0 58 | } 59 | 60 | # If there are no integration tests folder, we skip the unit tests 61 | if [ ! -d $tests_dir ]; then 62 | echo "Missing integration tests folder in $SERVICE. Skipping" 63 | exit 1 64 | fi 65 | 66 | if [ -z $PYTHONPATH ]; then 67 | PYTHONPATH=$ROOT/shared/tests/integ 68 | else 69 | PYTHONPATH=$PYTHONPATH:$ROOT/shared/tests/integ 70 | fi 71 | 72 | ECOM_ENVIRONMENT=${ENVIRONMENT} \ 73 | PYTHONPATH=${PYTHONPATH} \ 74 | pytest $tests_dir \ 75 | --override-ini junit_family=xunit2 \ 76 | --junitxml $reports_dir/${SERVICE}-integ.xml 77 | } 78 | 79 | type tests_$TYPE | grep -q "function" &>/dev/null || { 80 | echo "Unsupported type: $TYPE" 81 | echo 82 | display_usage 83 | exit 1 84 | } 85 | tests_$TYPE -------------------------------------------------------------------------------- /tools/tests-perf: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | 7 | tests_dir=${ROOT}/shared/tests/perf 8 | 9 | display_usage () { 10 | echo "Usage: $0 TYPE SERVICE" 11 | } 12 | 13 | 14 | # Check for quiet mode 15 | if [ ! -z $QUIET ]; then 16 | export OUTPUT_FILE=$(mktemp) 17 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 18 | fi 19 | 20 | cleanup () { 21 | CODE=$? 22 | if [ ! -z $QUIET ]; then 23 | if [ ! $CODE -eq 0 ]; then 24 | cat $OUTPUT_FILE >&5 25 | fi 26 | rm $OUTPUT_FILE 27 | fi 28 | } 29 | trap cleanup EXIT 30 | 31 | # Performance tests 32 | tests_perf () { 33 | # Skip tests on environment where we shouldn't run tests 34 | yq -r ' .'${ENVIRONMENT}'.flags["can-tests-e2e"] | if . == null then true else . end ' $ROOT/environments.yaml | grep -q true || { 35 | echo "The environment $ENVIRONMENT does not support tests. Skipping" 36 | exit 0 37 | } 38 | 39 | ECOM_ENVIRONMENT=${ENVIRONMENT} \ 40 | locust -f $tests_dir/perf_happy_path.py \ 41 | --headless -u 500 -r 50 --run-time 1h 42 | } 43 | 44 | tests_perf -------------------------------------------------------------------------------- /tools/tests-unit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ROOT=${ROOT:-$(pwd)} 6 | TYPE=$1 7 | SERVICE=$2 8 | 9 | service_dir=$ROOT/$SERVICE 10 | tests_dir=$service_dir/tests/unit 11 | build_dir=$service_dir/build 12 | reports_dir=${REPORTS_DIR:-$ROOT/reports} 13 | 14 | display_usage () { 15 | echo "Usage: $0 TYPE SERVICE" 16 | } 17 | 18 | # Check if there are at least 2 arguments 19 | if [ $# -lt 2 ]; then 20 | display_usage 21 | exit 1 22 | fi 23 | 24 | # Check if the service exists 25 | if [ ! -f $service_dir/metadata.yaml ]; then 26 | echo "Service $SERVICE does not exist" 27 | exit 1 28 | fi 29 | 30 | # Check for quiet mode 31 | if [ ! -z $QUIET ]; then 32 | export OUTPUT_FILE=$(mktemp) 33 | exec 5>&1 6>&2 1>$OUTPUT_FILE 2>&1 34 | fi 35 | 36 | cleanup () { 37 | CODE=$? 38 | if [ ! -z $QUIET ]; then 39 | if [ ! $CODE -eq 0 ]; then 40 | cat $OUTPUT_FILE >&5 41 | fi 42 | rm $OUTPUT_FILE 43 | fi 44 | } 45 | trap cleanup EXIT 46 | 47 | # Unit tests for python3 48 | tests_python3 () { 49 | # Check if this service skip tests 50 | yq -r ' .flags["skip-tests"] | if . == null then false else . end ' $service_dir/metadata.yaml | grep -q true && { 51 | echo "Skipping unit tests for $SERVICE" 52 | exit 0 53 | } 54 | 55 | # If there are no unit tests folder, we skip the unit tests 56 | if [ ! -d $tests_dir ]; then 57 | echo "Missing unit tests folder in $SERVICE. Skipping" 58 | exit 1 59 | fi 60 | 61 | # We need a build folder to perform tests 62 | if [ ! -d $build_dir ]; then 63 | echo "Missing build folder in $SERVICE" 64 | exit 1 65 | fi 66 | 67 | if [ -z $PYTHONPATH ]; then 68 | PYTHONPATH=$ROOT/shared/tests/unit 69 | else 70 | PYTHONPATH=$PYTHONPATH:$ROOT/shared/tests/unit 71 | fi 72 | 73 | ECOM_BUILD_DIR=$build_dir \ 74 | PYTHONPATH=$PYTHONPATH \ 75 | AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID \ 76 | AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY \ 77 | AWS_DEFAULT_REGION=eu-west-1 \ 78 | pytest $tests_dir \ 79 | --override-ini junit_family=xunit2 \ 80 | --cov $build_dir \ 81 | --cov-config $ROOT/shared/tests/unit/coveragerc \ 82 | --junitxml $reports_dir/${SERVICE}-unit.xml 83 | } 84 | 85 | type tests_$TYPE | grep -q "function" &>/dev/null || { 86 | echo "Unsupported type: $TYPE" 87 | echo 88 | display_usage 89 | exit 1 90 | } 91 | tests_$TYPE -------------------------------------------------------------------------------- /users/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /users/README.md: -------------------------------------------------------------------------------- 1 | Users service 2 | ============= 3 | 4 | ![Users architecture diagram](images/users.png) 5 | 6 | ## API 7 | 8 | This service does not define any API. 9 | 10 | ## Events 11 | 12 | See [resources/events.yaml](resources/events.yaml) for a list of available events. 13 | 14 | ## SSM Parameters 15 | 16 | This service defines the following SSM parameters: 17 | 18 | * `/ecommerce/${Environment}/users/user-pool/id`: ID of the underlying Cognito User Pool 19 | * `/ecommerce/${Environment}/users/user-pool/arn`: ARN of the underlying Cognito User Pool -------------------------------------------------------------------------------- /users/images/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/users/images/users.png -------------------------------------------------------------------------------- /users/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: users 2 | dependencies: 3 | - platform 4 | parameters: 5 | EventBusArn: /ecommerce/{Environment}/platform/event-bus/arn 6 | EventBusName: /ecommerce/{Environment}/platform/event-bus/name -------------------------------------------------------------------------------- /users/resources/events.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: Users events 4 | version: 1.0.0 5 | license: 6 | name: MIT-0 7 | 8 | paths: {} 9 | 10 | components: 11 | schemas: 12 | UserCreated: 13 | x-amazon-events-source: ecommerce.users 14 | x-amazon-events-detail-type: UserCreated 15 | description: Event emitted when a new user is created. 16 | allOf: 17 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 18 | - type: object 19 | properties: 20 | detail: 21 | $ref: "../../shared/resources/schemas.yaml#/User" -------------------------------------------------------------------------------- /users/src/sign_up/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | SignUpFunction 3 | """ 4 | 5 | 6 | import datetime 7 | import json 8 | import os 9 | from aws_lambda_powertools.tracing import Tracer 10 | from aws_lambda_powertools.logging.logger import Logger 11 | import boto3 12 | 13 | 14 | ENVIRONMENT = os.environ["ENVIRONMENT"] 15 | EVENT_BUS_NAME = os.environ["EVENT_BUS_NAME"] 16 | 17 | 18 | eventbridge = boto3.client("events") # pylint: disable=invalid-name 19 | logger = Logger() # pylint: disable=invalid-name 20 | tracer = Tracer() # pylint: disable=invalid-name 21 | 22 | 23 | @tracer.capture_method 24 | def process_request(input_: dict) -> dict: 25 | """ 26 | Transform the input request into an EventBridge event 27 | """ 28 | 29 | output = { 30 | "Time": datetime.datetime.now(), 31 | "Source": "ecommerce.users", 32 | "Resources": [input_["userName"]], 33 | "DetailType": "UserCreated", 34 | "Detail": json.dumps({ 35 | "userId": input_["userName"], 36 | "email": input_["request"]["userAttributes"]["email"] 37 | }), 38 | "EventBusName": EVENT_BUS_NAME 39 | } 40 | 41 | return output 42 | 43 | 44 | @tracer.capture_method 45 | def send_event(event: dict): 46 | """ 47 | Send event to EventBridge 48 | """ 49 | 50 | eventbridge.put_events(Entries=[event]) 51 | 52 | 53 | @logger.inject_lambda_context 54 | @tracer.capture_lambda_handler 55 | def handler(event, _): 56 | """ 57 | Lambda handler 58 | """ 59 | 60 | # Input event: 61 | # https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html#cognito-user-pools-lambda-trigger-event-parameter-shared 62 | logger.debug({ 63 | "message": "Input event", 64 | "event": event 65 | }) 66 | 67 | # Never confirm users 68 | event["response"] = { 69 | "autoConfirmUser": False, 70 | "autoVerifyPhone": False, 71 | "autoVerifyEmail": False 72 | } 73 | 74 | # Only care about the ConfirmSignUp action 75 | # At the moment, the only other PostConfirmation event is 'PostConfirmation_ConfirmForgotPassword' 76 | if event["triggerSource"] not in ["PreSignUp_SignUp", "PreSignUp_AdminCreateUser"]: 77 | logger.warning({ 78 | "message": "invalid triggerSource", 79 | "triggerSource": event["triggerSource"] 80 | }) 81 | return event 82 | 83 | # Prepare the event 84 | eb_event = process_request(event) 85 | 86 | # Send the event to EventBridge 87 | send_event(eb_event) 88 | 89 | # Always return the event at the end 90 | return event 91 | -------------------------------------------------------------------------------- /users/src/sign_up/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | -------------------------------------------------------------------------------- /users/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | 4 | 5 | Parameters: 6 | Environment: 7 | Type: String 8 | Default: dev 9 | Description: Environment name 10 | LogLevel: 11 | Type: String 12 | Default: INFO 13 | RetentionInDays: 14 | Type: Number 15 | Default: 30 16 | Description: CloudWatch Logs retention period for Lambda functions 17 | EventBusArn: 18 | Type: AWS::SSM::Parameter::Value 19 | Description: EventBridge Event Bus ARN 20 | EventBusName: 21 | Type: AWS::SSM::Parameter::Value 22 | Description: EventBridge Event Bus Name 23 | 24 | 25 | Globals: 26 | Function: 27 | Runtime: python3.9 28 | Architectures: 29 | - arm64 30 | Timeout: 30 31 | Tracing: Active 32 | Environment: 33 | Variables: 34 | ENVIRONMENT: !Ref Environment 35 | EVENT_BUS_NAME: !Ref EventBusName 36 | POWERTOOLS_SERVICE_NAME: users 37 | POWERTOOLS_TRACE_DISABLED: "false" 38 | LOG_LEVEL: !Ref LogLevel 39 | Layers: 40 | - !Sub "arn:aws:lambda:${AWS::Region}:580247275435:layer:LambdaInsightsExtension-Arm64:1" 41 | 42 | 43 | Resources: 44 | ############# 45 | # USER POOL # 46 | ############# 47 | UserPool: 48 | Type: AWS::Cognito::UserPool 49 | Properties: 50 | AutoVerifiedAttributes: [email] 51 | UsernameAttributes: [email] 52 | 53 | UserPoolIdParameter: 54 | Type: AWS::SSM::Parameter 55 | Properties: 56 | Name: !Sub /ecommerce/${Environment}/users/user-pool/id 57 | Type: String 58 | Value: !Ref UserPool 59 | 60 | UserPoolArnParameter: 61 | Type: AWS::SSM::Parameter 62 | Properties: 63 | Name: !Sub /ecommerce/${Environment}/users/user-pool/arn 64 | Type: String 65 | Value: !GetAtt UserPool.Arn 66 | 67 | AdminGroup: 68 | Type: AWS::Cognito::UserPoolGroup 69 | Properties: 70 | GroupName: admin 71 | UserPoolId: !Ref UserPool 72 | 73 | ############# 74 | # FUNCTIONS # 75 | ############# 76 | SignUpFunction: 77 | Type: AWS::Serverless::Function 78 | Properties: 79 | CodeUri: src/sign_up/ 80 | Handler: main.handler 81 | Events: 82 | CognitoSignUp: 83 | Type: Cognito 84 | Properties: 85 | UserPool: !Ref UserPool 86 | Trigger: PreSignUp 87 | Policies: 88 | - arn:aws:iam::aws:policy/CloudWatchLambdaInsightsExecutionRolePolicy 89 | - Version: "2012-10-17" 90 | Statement: 91 | - Effect: Allow 92 | Action: 93 | - events:PutEvents 94 | Resource: !Ref EventBusArn 95 | Condition: 96 | StringEquals: 97 | events:source: "ecommerce.users" 98 | 99 | SignUpLogGroup: 100 | Type: AWS::Logs::LogGroup 101 | Properties: 102 | LogGroupName: !Sub "/aws/lambda/${SignUpFunction}" 103 | RetentionInDays: !Ref RetentionInDays 104 | -------------------------------------------------------------------------------- /users/tests/integ/test_events.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import os 4 | import random 5 | import string 6 | import boto3 7 | import pytest 8 | from fixtures import listener # pylint: disable=import-error 9 | 10 | 11 | ssm = boto3.client("ssm") 12 | 13 | 14 | COGNITO_USER_POOL = ssm.get_parameter( 15 | Name="/ecommerce/{}/users/user-pool/id".format(os.environ["ECOM_ENVIRONMENT"]) 16 | )["Parameter"]["Value"] 17 | 18 | 19 | cognito = boto3.client("cognito-idp") 20 | 21 | 22 | def test_sign_up(listener): 23 | """ 24 | Test that the SignUp function reacts to new users in Cognito User Pools and 25 | sends an event to EventBridge 26 | """ 27 | 28 | data = {} 29 | 30 | def gen_func(): 31 | email = "".join(random.choices(string.ascii_lowercase, k=20))+"@example.local" 32 | password = "".join( 33 | random.choices(string.ascii_uppercase, k=10) + 34 | random.choices(string.ascii_lowercase, k=10) + 35 | random.choices(string.digits, k=5) + 36 | random.choices(string.punctuation, k=3) 37 | ) 38 | 39 | # Create a new user 40 | response = cognito.admin_create_user( 41 | UserPoolId=COGNITO_USER_POOL, 42 | Username=email, 43 | UserAttributes=[{ 44 | "Name": "email", 45 | "Value": email 46 | }], 47 | # Do not send an email as this is a fake address 48 | MessageAction="SUPPRESS" 49 | ) 50 | data["user_id"] = response["User"]["Username"] 51 | 52 | def test_func(m): 53 | return data["user_id"] in m["resources"] and m["detail-type"] == "UserCreated" 54 | 55 | # Listen for messages on EventBridge 56 | listener("ecommerce.users", gen_func, test_func) 57 | 58 | cognito.admin_delete_user( 59 | UserPoolId=COGNITO_USER_POOL, 60 | Username=data["user_id"] 61 | ) -------------------------------------------------------------------------------- /warehouse/Makefile: -------------------------------------------------------------------------------- 1 | export DOMAIN ?= ecommerce 2 | export ENVIRONMENT ?= dev 3 | export ROOT ?= $(shell dirname ${CURDIR}) 4 | export SERVICE ?= $(shell basename ${CURDIR}) 5 | 6 | artifacts: 7 | @${ROOT}/tools/artifacts cloudformation ${SERVICE} 8 | 9 | build: 10 | @${ROOT}/tools/build resources ${SERVICE} 11 | @${ROOT}/tools/build openapi ${SERVICE} 12 | @${ROOT}/tools/build python3 ${SERVICE} 13 | @${ROOT}/tools/build cloudformation ${SERVICE} 14 | .PHONY: build 15 | 16 | check-deps: 17 | @${ROOT}/tools/check-deps cloudformation ${SERVICE} 18 | 19 | clean: 20 | @${ROOT}/tools/clean ${SERVICE} 21 | 22 | deploy: 23 | @${ROOT}/tools/deploy cloudformation ${SERVICE} 24 | 25 | lint: 26 | @${ROOT}/tools/lint cloudformation ${SERVICE} 27 | @${ROOT}/tools/lint python3 ${SERVICE} 28 | @${ROOT}/tools/lint openapi ${SERVICE} 29 | 30 | package: 31 | @${ROOT}/tools/package cloudformation ${SERVICE} 32 | 33 | teardown: 34 | @${ROOT}/tools/teardown cloudformation ${SERVICE} 35 | 36 | tests-integ: 37 | @${ROOT}/tools/tests-integ cloudformation ${SERVICE} 38 | 39 | tests-unit: 40 | @${ROOT}/tools/tests-unit python3 ${SERVICE} -------------------------------------------------------------------------------- /warehouse/README.md: -------------------------------------------------------------------------------- 1 | Warehouse service 2 | ================= 3 | 4 |

5 | Warehouse architecture diagram 6 |

7 | 8 | The __warehouse__ service handles packaging incoming orders into packages ready for delivery. 9 | 10 | ## API 11 | 12 | _None at the moment._ 13 | 14 | ## Events 15 | 16 | See [resources/events.yaml](resources/events.yaml) for a list of available events. 17 | 18 | ## SSM Parameters 19 | 20 | * `/ecommerce/{Environment}/warehouse/api/url`: URL for the API Gateway -------------------------------------------------------------------------------- /warehouse/images/warehouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ecommerce-platform/3b9ef36dd3e338c8ab697c2bcb1b672e064e303c/warehouse/images/warehouse.png -------------------------------------------------------------------------------- /warehouse/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: warehouse 2 | dependencies: 3 | - platform 4 | - users 5 | parameters: 6 | EventBusArn: /ecommerce/{Environment}/platform/event-bus/arn 7 | EventBusName: /ecommerce/{Environment}/platform/event-bus/name -------------------------------------------------------------------------------- /warehouse/resources/events.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: Warehouse events 4 | version: 1.0.0 5 | license: 6 | name: MIT-0 7 | 8 | paths: {} 9 | 10 | components: 11 | schemas: 12 | PackageCreated: 13 | x-amazon-event-source: ecommerce.warehouse 14 | x-amazon-events-detail-type: PackageCreated 15 | description: Event emitted when an order is packaged and ready to be shipped 16 | allOf: 17 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 18 | - type: object 19 | properties: 20 | detail: 21 | type: object 22 | required: 23 | - orderId 24 | - products 25 | properties: 26 | orderId: 27 | type: string 28 | format: uuid 29 | description: Identifier for the order relative to the package 30 | example: b2d0c356-f92b-4629-a87f-786331c2842f 31 | products: 32 | type: array 33 | items: 34 | $ref: "../../shared/resources/schemas.yaml#/Product" 35 | 36 | PackagingFailed: 37 | x-amazon-event-source: ecommerce.warehouse 38 | x-amazon-events-detail-type: PackagingFailed 39 | description: | 40 | Event emitted when the warehouse service failed to create a package for an order. 41 | 42 | This could be due to multiple reasons, but often because all the products are not available 43 | in stock. 44 | allOf: 45 | - $ref: "../../shared/resources/schemas.yaml#/EventBridgeHeader" 46 | - type: object 47 | properties: 48 | detail: 49 | type: object 50 | required: 51 | - orderId 52 | properties: 53 | orderId: 54 | type: string 55 | format: uuid 56 | description: Identifier for the order relative to the packaging request 57 | example: b2d0c356-f92b-4629-a87f-786331c2842f -------------------------------------------------------------------------------- /warehouse/src/on_order_events/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /warehouse/src/table_update/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools==1.16.1 2 | boto3 3 | ../shared/src/ecom/ 4 | -------------------------------------------------------------------------------- /warehouse/tests/integ/test_events.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime 3 | import json 4 | import uuid 5 | import boto3 6 | import pytest 7 | from fixtures import listener # pylint: disable=import-error 8 | from helpers import get_parameter # pylint: disable=import-error 9 | 10 | 11 | METADATA_KEY = "__metadata" 12 | 13 | 14 | @pytest.fixture(scope="module") 15 | def table_name(): 16 | """ 17 | DynamoDB table name 18 | """ 19 | 20 | return get_parameter("/ecommerce/{Environment}/warehouse/table/name") 21 | 22 | 23 | @pytest.fixture(scope="module") 24 | def order(): 25 | now = datetime.datetime.now() 26 | return { 27 | "orderId": str(uuid.uuid4()), 28 | "userId": str(uuid.uuid4()), 29 | "createdDate": now.isoformat(), 30 | "modifiedDate": now.isoformat(), 31 | "products": [{ 32 | "productId": str(uuid.uuid4()), 33 | "name": "PRODUCT_NAME", 34 | "package": { 35 | "width": 200, 36 | "length": 100, 37 | "height": 50, 38 | "weight": 1000 39 | }, 40 | "price": 500, 41 | "quantity": 3 42 | }] 43 | } 44 | 45 | 46 | @pytest.fixture(scope="module") 47 | def metadata(order): 48 | return { 49 | "status": "NEW", 50 | "orderId": order["orderId"], 51 | "productId": METADATA_KEY, 52 | "modifiedDate": order["modifiedDate"] 53 | } 54 | 55 | @pytest.fixture(scope="module") 56 | def products(order): 57 | products = copy.deepcopy(order["products"]) 58 | for product in products: 59 | product["orderId"] = order["orderId"] 60 | return products 61 | 62 | 63 | def test_table_update(table_name, metadata, products, listener): 64 | """ 65 | Test that the TableUpdate function reacts to changes to DynamoDB and sends 66 | events to EventBridge 67 | """ 68 | 69 | metadata = copy.deepcopy(metadata) 70 | 71 | # Create packaging request in DynamoDB 72 | table = boto3.resource("dynamodb").Table(table_name) # pylint: disable=no-member 73 | 74 | with table.batch_writer() as batch: 75 | for product in products: 76 | batch.put_item(Item=product) 77 | batch.put_item(Item=metadata) 78 | 79 | # Mark the packaging as completed 80 | metadata["status"] = "COMPLETED" 81 | 82 | 83 | # Listen for messages on EventBridge 84 | listener( 85 | "ecommerce.warehouse", 86 | lambda: table.put_item(Item=metadata), 87 | lambda m: metadata["orderId"] in m["resources"] and m["detail-type"] == "PackageCreated" 88 | ) 89 | 90 | # Clean up the table 91 | with table.batch_writer() as batch: 92 | table.delete_item(Key={ 93 | "orderId": metadata["orderId"], 94 | "productId": metadata["productId"] 95 | }) 96 | for product in products: 97 | table.delete_item(Key={ 98 | "orderId": product["orderId"], 99 | "productId": product["productId"] 100 | }) --------------------------------------------------------------------------------