├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 | 
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 | 
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 | 
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 | 
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 |
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 | })
--------------------------------------------------------------------------------