Repository: aws-samples/aws-serverless-shopping-cart Branch: master Commit: 66a863f1b7a2 Files: 59 Total size: 108.5 KB Directory structure: gitextract_sass801v/ ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── amplify/ │ └── .gitkeep ├── amplify-ci/ │ └── amplify-template.yaml ├── amplify.yml ├── backend/ │ ├── .gitignore │ ├── Makefile │ ├── auth.yaml │ ├── layers/ │ │ ├── requirements.txt │ │ └── shared.py │ ├── product-mock-service/ │ │ ├── __init__.py │ │ ├── get_product.py │ │ ├── get_products.py │ │ ├── product_list.json │ │ └── requirements.txt │ ├── product-mock.yaml │ ├── shopping-cart-service/ │ │ ├── __init__.py │ │ ├── add_to_cart.py │ │ ├── checkout_cart.py │ │ ├── db_stream_handler.py │ │ ├── delete_from_cart.py │ │ ├── get_cart_total.py │ │ ├── list_cart.py │ │ ├── migrate_cart.py │ │ ├── requirements.txt │ │ ├── tests/ │ │ │ ├── __init__.py │ │ │ └── test_example.py │ │ ├── update_cart.py │ │ └── utils.py │ └── shoppingcart-service.yaml └── frontend/ ├── .gitignore ├── Makefile ├── babel.config.js ├── package.json ├── public/ │ └── index.html ├── scripts/ │ └── fetchconfig.js ├── src/ │ ├── App.vue │ ├── aws-exports.js │ ├── backend/ │ │ └── api.js │ ├── components/ │ │ ├── CartButton.vue │ │ ├── CartDrawer.vue │ │ ├── CartQuantityEditor.vue │ │ ├── LoadingOverlay.vue │ │ └── Product.vue │ ├── main.js │ ├── plugins/ │ │ └── vuetify.js │ ├── router.js │ ├── store/ │ │ ├── actions.js │ │ ├── getters.js │ │ ├── mutations.js │ │ └── store.js │ └── views/ │ ├── Auth.vue │ ├── Home.vue │ └── Payment.vue └── vue.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .env.local .idea ================================================ FILE: CODE_OF_CONDUCT.md ================================================ ## Code of Conduct 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. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Guidelines 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. 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. ## Reporting Bugs/Feature Requests We welcome you to use the GitHub issue tracker to report bugs or suggest features. 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: * A reproducible test case or series of steps * The version of our code being used * Any modifications you've made relevant to the bug * Anything unusual about your environment or deployment ## Contributing via Pull Requests Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 1. You are working against the latest source on the *master* branch. 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. To send us a pull request, please: 1. Fork the repository. 2. 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. 3. Ensure local tests pass. 4. Commit to your fork using clear commit messages. 5. Send us a pull request, answering any default questions in the pull request interface. 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 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/). ## Finding contributions to work on 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. ## Code of Conduct 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. ## Security issue notifications 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. ## Licensing See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. ================================================ FILE: LICENSE ================================================ Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ all: backend frontend-build TEMPLATES = auth product-mock shoppingcart-service REGION := $(shell python3 -c 'import boto3; print(boto3.Session().region_name)') ifndef S3_BUCKET ACCOUNT_ID := $(shell aws sts get-caller-identity --query Account --output text) S3_BUCKET = aws-serverless-shopping-cart-src-$(ACCOUNT_ID)-$(REGION) endif backend: create-bucket $(MAKE) -C backend TEMPLATE=auth S3_BUCKET=$(S3_BUCKET) $(MAKE) -C backend TEMPLATE=product-mock S3_BUCKET=$(S3_BUCKET) $(MAKE) -C backend TEMPLATE=shoppingcart-service S3_BUCKET=$(S3_BUCKET) backend-delete: $(MAKE) -C backend delete TEMPLATE=auth $(MAKE) -C backend delete TEMPLATE=product-mock $(MAKE) -C backend delete TEMPLATE=shoppingcart-service backend-tests: $(MAKE) -C backend tests create-bucket: @echo "Checking if S3 bucket exists s3://$(S3_BUCKET)" @aws s3api head-bucket --bucket $(S3_BUCKET) || (echo "bucket does not exist at s3://$(S3_BUCKET), creating it..." ; aws s3 mb s3://$(S3_BUCKET) --region $(REGION)) amplify-deploy: aws cloudformation deploy \ --template-file ./amplify-ci/amplify-template.yaml \ --capabilities CAPABILITY_IAM \ --parameter-overrides \ OauthToken=$(GITHUB_OAUTH_TOKEN) \ Repository=$(GITHUB_REPO) \ BranchName=$(GITHUB_BRANCH) \ SrcS3Bucket=$(S3_BUCKET) \ --stack-name CartApp frontend-serve: $(MAKE) -C frontend serve frontend-build: $(MAKE) -C frontend build .PHONY: all backend backend-delete backend-tests create-bucket amplify-deploy frontend-serve frontend-build ================================================ FILE: README.md ================================================ # Serverless Shopping Cart Microservice This application is a sample application to demonstrate how you could implement a shopping cart microservice using serverless technologies on AWS. The backend is built as a REST API interface, making use of [Amazon API Gateway](https://aws.amazon.com/api-gateway/), [AWS Lambda](https://aws.amazon.com/lambda/), [Amazon Cognito](https://aws.amazon.com/cognito/), and [Amazon DynamoDB](https://aws.amazon.com/dynamodb/). The frontend is a Vue.js application using the [AWS Amplify](https://aws-amplify.github.io/) SDK for authentication and communication with the API. To assist in demonstrating the functionality, a bare bones mock "products" service has also been included. Since the authentication parts are likely to be shared between components, there is a separate template for it. The front-end doesn't make any real payment integration at this time. ## Architecture & Design ![Architecture Diagram](./images/architecture.png) ## Design Notes Before building the application, I set some requirements on how the cart should behave: - Users should be able to add items to the cart without logging in (an "anonymous cart"), and that cart should persist across browser restarts etc. - When logging in, if there were products in an anonymous cart, they should be added to the user's cart from any previous logged in sessions. - When logging out, the anonymous cart should not have products in it any longer. - Items in an anonymous cart should be removed after a period of time, and items in a logged in cart should persist for a longer period. - Admin users to be able to get an aggregated view of the total number of each product in users' carts at any time. ### Cart Migration When an item is added to the cart, an item is written in DynamoDB with an identifier which matches a randomly generated (uuid) cookie which is set in the browser. This allows a user to add items to cart and come back to the page later without losing the items they have added. When the user logs in, these items will be removed, and replaced with items with a user id as the pk. If the user already had that product in their cart from a previous logged in session, the quantities would be summed. Because we don't need the deletion of old items to happen immediately as part of a synchronous workflow, we put messages onto an SQS queue, which triggers a worker function to delete the messages. To expire items from users' shopping carts, DynamoDB's native functionality is used where a TTL is written along with the item, after which the item should be removed. In this implementation, the TTL defaults to 1 day for anonymous carts, and 7 days for logged in carts. ### Aggregated View of Products in Carts It would be possible to scan our entire DynamoDB table and sum up the quantities of all the products, but this will be expensive past a certain scale. Instead, we can calculate the total as a running process, and keep track of the total amount. When an item is added, deleted or updated in DynamoDB, an event is put onto DynamoDB Streams, which in turn triggers a Lambda function. This function calculates the change in total quantity for each product in users' carts, and writes the quantity back to DynamoDB. The Lambda function is configured so that it will run after either 60 seconds pass, or 100 new events are on the stream. This would enable an admin user to get real time data about the popular products, which could in turn help anticipate inventory. In this implementation, the API is exposed without authentication to demonstrate the functionality. ## Api Design ### Shopping Cart Service GET `/cart` Retrieves the shopping cart for a user who is either anonymous or logged in. POST `/cart` Accepts a product id and quantity as json. Adds specified quantity of an item to cart. `/cart/migrate` Called after logging in - migrates items in an anonymous user's cart to belong to their logged in user. If you already have a cart on your logged in user, your "anonymous cart" will be merged with it when you log in. `/cart/checkout` Currently just empties cart. PUT `/cart/{product-id}` Accepts a product id and quantity as json. Updates quantity of given item to provided quantity. GET `/cart/{product-id}/total` Returns the total amount of a given product across all carts. This API is not used by the frontend but can be manually called to test. ### Product Mock Service GET `/product` Returns details for all products. `/product/{product_id}` Returns details for a single product. ## Running the Example ### Requirements python >= 3.8.0 boto3 SAM CLI, >= version 0.50.0 AWS CLI yarn ### Setup steps Fork the github repo, then clone your fork locally: `git clone https://github.com//aws-serverless-shopping-cart && cd aws-serverless-shopping-cart` If you wish to use a named profile for your AWS credentials, you can set the environment variable `AWS_PROFILE` before running the below commands. For a profile named "development": `export AWS_PROFILE=development`. You now have 2 options - you can deploy the backend and run the frontend locally, or you can deploy the whole project using the AWS Amplify console. ## Option 1 - Deploy backend and run frontend locally ### Deploy the Backend An S3 bucket will be automatically created for you which will be used for deploying source code to AWS. If you wish to use an existing bucket instead, you can manually set the `S3_BUCKET` environment variable to the name of your bucket. Build and deploy the resources: ``` bash make backend # Creates S3 bucket if not existing already, then deploys CloudFormation stacks for authentication, a product mock service and the shopping cart service. ``` ### Run the Frontend Locally Start the frontend locally: ``` bash make frontend-serve # Retrieves backend config from ssm parameter store to a .env file, then starts service. ``` Once the service is running, you can access the frontend on http://localhost:8080/ and start adding items to your cart. You can create an account by clicking on "Sign In" then "Create Account". Be sure to use a valid email address as you'll need to retrieve the verification code. **Note:** CORS headers on the backend service default to allowing http://localhost:8080/. You will see CORS errors if you access the frontend using the ip (http://127.0.0.1:8080/), or using a port other than 8080. ### Clean Up Delete the CloudFormation stacks created by this project: ``` bash make backend-delete ``` ## Option 2 - Automatically deploy backend and frontend using Amplify Console [![One-click deployment](https://oneclick.amplifyapp.com/button.svg)](https://console.aws.amazon.com/amplify/home#/deploy?repo=https://github.com/aws-samples/aws-serverless-shopping-cart) 1) Use **1-click deployment** button above, and continue by clicking "Connect to Github" 2) If you don't have an IAM Service Role with admin permissions, select "Create new role". Otherwise proceed to step 5) 3) Select "Amplify" from the drop-down, and select "Amplify - Backend Deployment", then click "Next". 4) Click "Next" again, then give the role a name and click "Create role" 5) In the Amplify console and select the role you created, then click "Save and deploy" 6) Amplify Console will fork this repository into your GitHub account and deploy it for you 7) You should now be able to see your app being deployed in the [Amplify Console](https://console.aws.amazon.com/amplify/home) 8) Within your new app in Amplify Console, wait for deployment to complete (this should take approximately 12 minutes for the first deploy) ### Clean Up Delete the CloudFormation stacks created by this project. There are 3 of them, with names starting with "aws-serverless-shopping-cart-". ## License This library is licensed under the MIT-0 License. See the [LICENSE](LICENSE) file. ================================================ FILE: amplify/.gitkeep ================================================ ================================================ FILE: amplify-ci/amplify-template.yaml ================================================ AWSTemplateFormatVersion: 2010-09-09 Parameters: Repository: Type: String Description: GitHub Repository URL BranchName: Type: String Description: Github branch name Default: master OauthToken: Type: String Description: GitHub Personal Access Token NoEcho: true SrcS3Bucket: Type: String Description: S3 Bucket for source code storage Resources: AmplifyRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - amplify.amazonaws.com Action: - sts:AssumeRole Policies: - PolicyName: Amplify PolicyDocument: Version: 2012-10-17 Statement: - Effect: "Allow" Action: - "s3:PutObject" - "s3:GetObject" - "s3:CreateMultipartUpload" - "s3:ListBucket" - "s3:CreateBucket" Resource: - !Sub "arn:aws:s3:::${SrcS3Bucket}" - !Sub "arn:aws:s3:::${SrcS3Bucket}/*" - Effect: "Allow" Action: - "cloudformation:Describe*" - "cloudformation:Create*" - "cloudformation:Execute*" Resource: - "*" - Effect: Allow Action: - lambda:* Resource: - !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:*" - !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:*" - Effect: Allow Action: - lambda:GetLayerVersion Resource: - !Sub "arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython*" - Effect: Allow Action: - lambda:*EventSourceMapping Resource: - "*" - Effect: "Allow" Action: - "cloudformation:GetTemplateSummary" Resource: "*" - Effect: "Allow" Action: - "iam:GetRole" - "iam:Create*" - "iam:Delete*" - "iam:PassRole" - "iam:*RolePolicy" Resource: - "*" - Effect: "Allow" Action: - "apigateway:*" Resource: - "*" - Effect: "Allow" Action: - "ssm:*" Resource: - "*" - Effect: "Allow" Action: - "dynamodb:List*" - "dynamodb:Describe*" - "dynamodb:TagResource" - "dynamodb:UnTagResource" - "dynamodb:Update*" - "dynamodb:Create*" - "dynamodb:DeleteTable" Resource: - "*" - Effect: "Allow" Action: - "sqs:*" Resource: - "*" - Effect: "Allow" Action: - "cognito-idp:*" Resource: - "*" - Effect: "Allow" Action: - "sts:GetCallerIdentity" Resource: - "*" AmplifyApp: Type: AWS::Amplify::App Properties: Name: CartApp Repository: !Ref Repository Description: AWS serverless shopping cart OauthToken: !Ref OauthToken CustomRules: - Source: Target: / Status: 200 EnvironmentVariables: - Name: S3_BUCKET Value: !Ref SrcS3Bucket Tags: - Key: Name Value: ShoppingCart IAMServiceRole: !GetAtt AmplifyRole.Arn AmplifyBranch: Type: AWS::Amplify::Branch Properties: BranchName: !Ref BranchName AppId: !GetAtt AmplifyApp.AppId Description: Master Branch EnableAutoBuild: true Tags: - Key: Name Value: shoppingcart-master - Key: Branch Value: master Outputs: DefaultDomain: Value: !GetAtt AmplifyApp.DefaultDomain MasterBranchUrl: Value: !Join [ ".", [ !GetAtt AmplifyBranch.BranchName, !GetAtt AmplifyApp.DefaultDomain ]] ================================================ FILE: amplify.yml ================================================ version: 1 env: variables: ORIGIN: https://${AWS_BRANCH//\//-}.${AWS_APP_ID}.amplifyapp.com STACKNAME: amplify-aws-serverless-shopping-cart backend: phases: preBuild: commands: - curl "https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip" -L -o "aws-sam-cli.zip" - unzip aws-sam-cli.zip -d sam-installation - ./sam-installation/install - pip3 install -U boto3 build: commands: - make backend frontend: phases: # IMPORTANT - Please verify your build commands build: commands: - make frontend-build artifacts: # IMPORTANT - Please verify your build output directory baseDirectory: /frontend/dist files: - '**/*' cache: paths: [] ================================================ FILE: backend/.gitignore ================================================ # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode ### Linux ### *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### OSX ### *.DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff: .idea/**/workspace.xml .idea/**/tasks.xml .idea/dictionaries # Sensitive or high-churn files: .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.xml .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml # Gradle: .idea/**/gradle.xml .idea/**/libraries # CMake cmake-build-debug/ # Mongo Explorer plugin: .idea/**/mongoSettings.xml ## File-based project format: *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # Ruby plugin and RubyMine /.rakeTasks # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties ### PyCharm Patch ### # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 # *.iml # modules.xml # .idea/misc.xml # *.ipr # Sonarlint plugin .idea/sonarlint ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache .pytest_cache/ nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule.* # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ ### VisualStudioCode ### .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json .history ### Windows ### # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk # Build folder */build/* # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # celery beat schedule file celerybeat-schedule # mypy .dmypy.json dmypy.json # Pyre type checker .pyre/ /packaged-*.yml .idea .idea/* ================================================ FILE: backend/Makefile ================================================ all: build deploy ORIGIN ?= http://localhost:8080 STACKNAME ?= aws-serverless-shopping-cart build: @echo "Building template $(TEMPLATE).yaml..." @sam build -t $(TEMPLATE).yaml deploy: # s3 bucket still needed for layer @echo "Deploying stack $(STACKNAME)-$(TEMPLATE)..." @sam deploy --capabilities CAPABILITY_NAMED_IAM --stack-name $(STACKNAME)-$(TEMPLATE) --s3-bucket $(S3_BUCKET) --parameter-overrides AllowedOrigin=$(ORIGIN) --no-fail-on-empty-changeset tests: py.test -v delete: @aws cloudformation delete-stack --stack-name $(STACKNAME)-$(TEMPLATE) @echo "Waiting for stack $(STACKNAME)-$(TEMPLATE) to be deleted..." @aws cloudformation wait stack-delete-complete --stack-name $(STACKNAME)-$(TEMPLATE) .PHONY: build package deploy delete ================================================ FILE: backend/auth.yaml ================================================ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > auth-resources SAM Template for auth resources Globals: Function: Timeout: 3 Resources: CognitoUserPool: Type: AWS::Cognito::UserPool Properties: UserPoolName: !Sub ${AWS::StackName}-UserPool AutoVerifiedAttributes: - email UserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: my-app GenerateSecret: false UserPoolId: !Ref CognitoUserPool ExplicitAuthFlows: - ADMIN_NO_SRP_AUTH UserPoolSSM: Type: AWS::SSM::Parameter Properties: Type: String Name: /serverless-shopping-cart-demo/auth/user-pool-id Value: !Ref CognitoUserPool UserPoolARNSSM: Type: AWS::SSM::Parameter Properties: Type: String Name: /serverless-shopping-cart-demo/auth/user-pool-arn Value: !GetAtt CognitoUserPool.Arn UserPoolAppClientSSM: Type: AWS::SSM::Parameter Properties: Type: String Name: /serverless-shopping-cart-demo/auth/user-pool-client-id Value: !Ref UserPoolClient Outputs: CognitoUserPoolId: Description: "Cognito User Pool ID" Value: !Ref CognitoUserPool CognitoAppClientId: Description: "Cognito App Client ID" Value: !Ref UserPoolClient ================================================ FILE: backend/layers/requirements.txt ================================================ requests==2.22.0 cognitojwt==1.1.0 boto3==1.10.34 ================================================ FILE: backend/layers/shared.py ================================================ import calendar import datetime import os import uuid from decimal import Decimal from http.cookies import SimpleCookie from aws_lambda_powertools import Tracer import cognitojwt tracer = Tracer() HEADERS = { "Access-Control-Allow-Origin": os.environ.get("ALLOWED_ORIGIN"), "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Methods": "OPTIONS,POST,GET", "Access-Control-Allow-Credentials": True, } class NotFoundException(Exception): pass @tracer.capture_method def handle_decimal_type(obj): """ json serializer which works with Decimal types returned from DynamoDB. """ if isinstance(obj, Decimal): if float(obj).is_integer(): return int(obj) else: return float(obj) raise TypeError @tracer.capture_method def generate_ttl(days=1): """ Generate epoch timestamp for number days in future """ future = datetime.datetime.utcnow() + datetime.timedelta(days=days) return calendar.timegm(future.utctimetuple()) @tracer.capture_method def get_user_sub(jwt_token): """ Validate JWT claims & retrieve user identifier """ try: verified_claims = cognitojwt.decode( jwt_token, os.environ["AWS_REGION"], os.environ["USERPOOL_ID"] ) except (cognitojwt.CognitoJWTException, ValueError): verified_claims = {} return verified_claims.get("sub") @tracer.capture_method def get_cart_id(event_headers): """ Retrieve cart_id from cookies if it exists, otherwise set and return it """ cookie = SimpleCookie() try: cookie.load(event_headers["cookie"]) cart_cookie = cookie["cartId"].value generated = False except KeyError: cart_cookie = str(uuid.uuid4()) generated = True return cart_cookie, generated @tracer.capture_method def get_headers(cart_id): """ Get the headers to add to response data """ headers = HEADERS cookie = SimpleCookie() cookie["cartId"] = cart_id cookie["cartId"]["max-age"] = (60 * 60) * 24 # 1 day cookie["cartId"]["secure"] = True cookie["cartId"]["httponly"] = True cookie["cartId"]["samesite"] = "None" cookie["cartId"]["path"] = "/" headers["Set-Cookie"] = cookie["cartId"].OutputString() return headers ================================================ FILE: backend/product-mock-service/__init__.py ================================================ ================================================ FILE: backend/product-mock-service/get_product.py ================================================ import json import os from aws_lambda_powertools import Logger, Tracer logger = Logger() tracer = Tracer() with open("product_list.json", "r") as product_list: product_list = json.load(product_list) HEADERS = { "Access-Control-Allow-Origin": os.environ.get("ALLOWED_ORIGIN"), "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Methods": "OPTIONS,POST,GET", } @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Return single product based on path parameter. """ path_params = event["pathParameters"] product_id = path_params.get("product_id") logger.debug("Retriving product_id: %s", product_id) product = next( (item for item in product_list if item["productId"] == product_id), None ) return { "statusCode": 200, "headers": HEADERS, "body": json.dumps({"product": product}), } ================================================ FILE: backend/product-mock-service/get_products.py ================================================ import json import os from aws_lambda_powertools import Logger, Tracer logger = Logger() tracer = Tracer() with open('product_list.json', 'r') as product_list: product_list = json.load(product_list) HEADERS = { "Access-Control-Allow-Origin": os.environ.get("ALLOWED_ORIGIN"), "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Methods": "OPTIONS,POST,GET", } @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Return list of all products. """ logger.debug("Fetching product list") return { "statusCode": 200, "headers": HEADERS, "body": json.dumps({"products": product_list}), } ================================================ FILE: backend/product-mock-service/product_list.json ================================================ [ { "category": "fruit", "createdDate": "2017-04-17T01:14:03 -02:00", "description": "Culpa non veniam deserunt dolor irure elit cupidatat culpa consequat nulla irure aliqua.", "modifiedDate": "2019-03-13T12:18:27 -01:00", "name": "packaged strawberries", "package": { "height": 948, "length": 455, "weight": 54, "width": 905 }, "pictures": [ "http://placehold.it/32x32" ], "price": 716, "productId": "4c1fadaa-213a-4ea8-aa32-58c217604e3c", "tags": [ "mollit", "ad", "eiusmod", "irure", "tempor" ] }, { "category": "sweets", "createdDate": "2017-04-06T06:21:36 -02:00", "description": "Dolore ipsum eiusmod dolore aliquip laborum laborum aute ipsum commodo id irure duis ipsum.", "modifiedDate": "2019-09-21T12:08:48 -02:00", "name": "candied prunes", "package": { "height": 329, "length": 179, "weight": 293, "width": 741 }, "pictures": [ "http://placehold.it/32x32" ], "price": 35, "productId": "d2580eff-d105-45a5-9b21-ba61995bc6da", "tags": [ "laboris", "dolor", "in", "labore", "duis" ] }, { "category": "fruit", "createdDate": "2017-03-17T03:06:53 -01:00", "description": "Reprehenderit aliquip consequat quis excepteur et et esse exercitation adipisicing dolore nulla consequat.", "modifiedDate": "2019-11-25T12:32:49 -01:00", "name": "fresh prunes", "package": { "height": 736, "length": 567, "weight": 41, "width": 487 }, "pictures": [ "http://placehold.it/32x32" ], "price": 2, "productId": "a6dd7187-40b6-4cb5-b73c-aecd655c6d9a", "tags": [ "nisi", "quis", "sint", "adipisicing", "pariatur" ] }, { "category": "vegetable", "createdDate": "2018-07-17T02:14:55 -02:00", "description": "Minim qui elit dolor est commodo excepteur ea voluptate eu dolor culpa magna.", "modifiedDate": "2019-09-05T03:36:34 -02:00", "name": "packaged tomatoes", "package": { "height": 4, "length": 756, "weight": 607, "width": 129 }, "pictures": [ "http://placehold.it/32x32" ], "price": 97, "productId": "c0fbcc6b-7a70-41ac-aac4-f8fd237dc62e", "tags": [ "dolor", "officia", "fugiat", "officia", "voluptate" ] }, { "category": "vegetable", "createdDate": "2017-07-25T12:00:11 -02:00", "description": "Labore dolore velit mollit aute qui magna elit excepteur officia cupidatat ea ea aliqua.", "modifiedDate": "2019-10-04T06:32:14 -02:00", "name": "fresh tomatoes", "package": { "height": 881, "length": 252, "weight": 66, "width": 431 }, "pictures": [ "http://placehold.it/32x32" ], "price": 144, "productId": "cb40c919-033a-47d6-8d00-1d73e2df20fe", "tags": [ "nostrud", "elit", "Lorem", "occaecat", "duis" ] }, { "category": "vegetable", "createdDate": "2017-01-07T05:28:03 -01:00", "description": "Ad eiusmod cupidatat duis dolor mollit labore mollit eu.", "modifiedDate": "2019-04-03T10:36:25 -02:00", "name": "fresh lettuce", "package": { "height": 813, "length": 932, "weight": 457, "width": 436 }, "pictures": [ "http://placehold.it/32x32" ], "price": 51, "productId": "12929eb9-3eb7-4217-99e4-1a39c39217b6", "tags": [ "ad", "ipsum", "est", "eiusmod", "duis" ] }, { "category": "meat", "createdDate": "2018-12-03T12:33:44 -01:00", "description": "Amet cupidatat anim ipsum pariatur sit eu.", "modifiedDate": "2019-04-17T06:31:47 -02:00", "name": "packaged steak", "package": { "height": 707, "length": 417, "weight": 491, "width": 549 }, "pictures": [ "http://placehold.it/32x32" ], "price": 894, "productId": "9fd9ef32-493f-4188-99f5-3aa809aa4fa9", "tags": [ "fugiat", "velit", "non", "magna", "laboris" ] }, { "category": "vegetable", "createdDate": "2017-04-27T06:48:08 -02:00", "description": "Labore est aliqua laborum ea laboris voluptate cillum aute duis occaecat.", "modifiedDate": "2019-11-01T10:23:57 -01:00", "name": "fresh lettuce", "package": { "height": 21, "length": 311, "weight": 817, "width": 964 }, "pictures": [ "http://placehold.it/32x32" ], "price": 452, "productId": "20db6331-1084-48ff-8c4f-c1d98a6a1aa4", "tags": [ "laborum", "in", "aliquip", "sint", "quis" ] }, { "category": "sweet", "createdDate": "2017-11-24T04:01:33 -01:00", "description": "Fugiat sunt in eu eu occaecat.", "modifiedDate": "2019-05-19T05:53:56 -02:00", "name": "half-eaten cake", "package": { "height": 337, "length": 375, "weight": 336, "width": 1 }, "pictures": [ "http://placehold.it/32x32" ], "price": 322, "productId": "8c843a54-27d7-477c-81b3-c21db12ed1c9", "tags": [ "officia", "proident", "officia", "commodo", "nisi" ] }, { "category": "dairy", "createdDate": "2018-05-29T11:46:28 -02:00", "description": "Aliqua officia magna do ipsum laboris anim magna nulla sit labore nulla qui duis.", "modifiedDate": "2019-05-29T05:33:49 -02:00", "name": "leftover cheese", "package": { "height": 267, "length": 977, "weight": 85, "width": 821 }, "pictures": [ "http://placehold.it/32x32" ], "price": 163, "productId": "8d2024c0-6c05-4691-a0ff-dd52959bd1df", "tags": [ "excepteur", "ipsum", "nulla", "nisi", "velit" ] }, { "category": "bakery", "createdDate": "2018-09-22T05:22:38 -02:00", "description": "Ullamco commodo cupidatat reprehenderit eu sunt.", "modifiedDate": "2019-03-11T06:10:38 -01:00", "name": "fresh croissants", "package": { "height": 122, "length": 23, "weight": 146, "width": 694 }, "pictures": [ "http://placehold.it/32x32" ], "price": 634, "productId": "867ecb2b-ef08-446e-8360-b63f60969e3d", "tags": [ "labore", "dolor", "aliquip", "nulla", "aute" ] }, { "category": "meat", "createdDate": "2018-09-12T07:24:46 -02:00", "description": "Eu ullamco irure qui labore qui duis mollit eiusmod adipisicing fugiat adipisicing nostrud ut non.", "modifiedDate": "2019-10-28T01:25:50 -01:00", "name": "packaged ham", "package": { "height": 902, "length": 278, "weight": 775, "width": 31 }, "pictures": [ "http://placehold.it/32x32" ], "price": 77, "productId": "684011fc-ecfd-4557-a6df-9fc977365826", "tags": [ "voluptate", "laborum", "exercitation", "anim", "anim" ] }, { "category": "bakery", "createdDate": "2017-06-12T09:15:36 -02:00", "description": "Eu culpa nulla est et anim sint amet.", "modifiedDate": "2019-08-22T04:22:39 -02:00", "name": "fresh bread", "package": { "height": 551, "length": 976, "weight": 47, "width": 846 }, "pictures": [ "http://placehold.it/32x32" ], "price": 805, "productId": "b027697d-a070-4c8f-8b9a-b8c80b2eb0ba", "tags": [ "nostrud", "in", "duis", "laboris", "minim" ] }, { "category": "sweet", "createdDate": "2018-09-06T06:03:43 -02:00", "description": "Mollit proident aliquip consectetur irure qui veniam laboris aliqua proident id fugiat esse nulla.", "modifiedDate": "2019-10-16T10:53:33 -02:00", "name": "candied strawberries", "package": { "height": 55, "length": 32, "weight": 661, "width": 694 }, "pictures": [ "http://placehold.it/32x32" ], "price": 283, "productId": "7e0dbfa9-a672-4987-a26c-f601d177463a", "tags": [ "minim", "irure", "in", "duis", "labore" ] }, { "category": "bakery", "createdDate": "2017-07-23T12:27:34 -02:00", "description": "Ex non proident et eiusmod et elit est exercitation anim qui ullamco elit.", "modifiedDate": "2019-09-04T08:25:44 -02:00", "name": "fresh pie", "package": { "height": 718, "length": 59, "weight": 18, "width": 962 }, "pictures": [ "http://placehold.it/32x32" ], "price": 646, "productId": "d1d527b8-9cef-4e97-a873-22236f3ee289", "tags": [ "in", "ea", "excepteur", "id", "dolore" ] }, { "category": "vegetable", "createdDate": "2018-11-08T04:08:28 -01:00", "description": "Pariatur deserunt nostrud cupidatat ut officia voluptate adipisicing mollit sunt cillum quis magna dolore aute.", "modifiedDate": "2019-10-11T10:28:49 -02:00", "name": "packaged lettuce", "package": { "height": 81, "length": 57, "weight": 653, "width": 367 }, "pictures": [ "http://placehold.it/32x32" ], "price": 197, "productId": "11663d33-e54d-49da-ba6f-44d016ecde7e", "tags": [ "incididunt", "in", "adipisicing", "eu", "tempor" ] }, { "category": "meat", "createdDate": "2018-09-28T04:01:24 -02:00", "description": "Dolore nulla laboris incididunt laborum.", "modifiedDate": "2019-08-05T01:06:02 -02:00", "name": "leftover ham", "package": { "height": 246, "length": 639, "weight": 354, "width": 953 }, "pictures": [ "http://placehold.it/32x32" ], "price": 728, "productId": "e173d669-b449-4226-af2e-128142abdd30", "tags": [ "exercitation", "magna", "ex", "quis", "ad" ] }, { "category": "dairy", "createdDate": "2018-08-23T06:31:47 -02:00", "description": "Pariatur mollit voluptate enim qui pariatur deserunt elit.", "modifiedDate": "2019-10-02T10:50:16 -02:00", "name": "fresh milk", "package": { "height": 576, "length": 948, "weight": 535, "width": 646 }, "pictures": [ "http://placehold.it/32x32" ], "price": 164, "productId": "2a5b681c-ec7f-4bd4-a51e-57a5b6591f7f", "tags": [ "labore", "id", "mollit", "occaecat", "elit" ] }, { "category": "vegetable", "createdDate": "2018-02-21T01:55:54 -01:00", "description": "Consectetur laborum ipsum ad laboris.", "modifiedDate": "2019-02-23T08:50:01 -01:00", "name": "half-eaten lettuce", "package": { "height": 348, "length": 119, "weight": 723, "width": 44 }, "pictures": [ "http://placehold.it/32x32" ], "price": 583, "productId": "de979b05-9d71-4c7e-b10f-636332ccb6c1", "tags": [ "id", "velit", "cillum", "irure", "aute" ] }, { "category": "meat", "createdDate": "2017-05-14T03:39:21 -02:00", "description": "Aliqua tempor irure qui consectetur exercitation culpa minim magna laboris ex pariatur elit culpa.", "modifiedDate": "2019-11-24T02:23:27 -01:00", "name": "fresh steak", "package": { "height": 328, "length": 7, "weight": 439, "width": 747 }, "pictures": [ "http://placehold.it/32x32" ], "price": 996, "productId": "aa91060a-3601-4cb8-a2cc-025d09c7a9b7", "tags": [ "qui", "dolore", "culpa", "est", "duis" ] } ] ================================================ FILE: backend/product-mock-service/requirements.txt ================================================ aws-lambda-powertools==1.0.0 ================================================ FILE: backend/product-mock.yaml ================================================ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > product-service SAM Template for mock product-service Parameters: AllowedOrigin: Type: 'String' Globals: Function: Timeout: 5 Tracing: Active AutoPublishAlias: live Runtime: python3.8 MemorySize: 256 Environment: Variables: LOG_LEVEL: "DEBUG" ALLOWED_ORIGIN: !Ref AllowedOrigin POWERTOOLS_SERVICE_NAME: product-mock POWERTOOLS_METRICS_NAMESPACE: ecommerce-app Api: EndpointConfiguration: REGIONAL TracingEnabled: true OpenApiVersion: '2.0' Cors: AllowMethods: "'OPTIONS,POST,GET'" AllowHeaders: "'Content-Type'" AllowOrigin: !Sub "'${AllowedOrigin}'" Resources: GetProductFunction: Type: AWS::Serverless::Function Properties: CodeUri: product-mock-service/ Handler: get_product.lambda_handler Events: ListCart: Type: Api Properties: Path: /product/{product_id} Method: get GetProductsFunction: Type: AWS::Serverless::Function Properties: CodeUri: product-mock-service/ Handler: get_products.lambda_handler Events: ListCart: Type: Api Properties: Path: /product Method: get GetProductApiUrl: Type: AWS::SSM::Parameter Properties: Type: String Name: /serverless-shopping-cart-demo/products/products-api-url Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod" Outputs: ProductApi: Description: "API Gateway endpoint URL for Prod stage for Product Mock Service" Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod" ================================================ FILE: backend/shopping-cart-service/__init__.py ================================================ ================================================ FILE: backend/shopping-cart-service/add_to_cart.py ================================================ import json import os import boto3 from aws_lambda_powertools import Logger, Metrics, Tracer from shared import ( NotFoundException, generate_ttl, get_cart_id, get_headers, get_user_sub, ) from utils import get_product_from_external_service logger = Logger() tracer = Tracer() metrics = Metrics() dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(os.environ["TABLE_NAME"]) product_service_url = os.environ["PRODUCT_SERVICE_URL"] @metrics.log_metrics(capture_cold_start_metric=True) @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Add a the provided quantity of a product to a cart. Where an item already exists in the cart, the quantities will be summed. """ try: request_payload = json.loads(event["body"]) except KeyError: return { "statusCode": 400, "headers": get_headers(), "body": json.dumps({"message": "No Request payload"}), } product_id = request_payload["productId"] quantity = request_payload.get("quantity", 1) cart_id, _ = get_cart_id(event["headers"]) # Because this method can be called anonymously, we need to check there's a logged in user user_sub = None jwt_token = event["headers"].get("Authorization") if jwt_token: user_sub = get_user_sub(jwt_token) try: product = get_product_from_external_service(product_id) logger.info("No product found with product_id: %s", product_id) except NotFoundException: return { "statusCode": 404, "headers": get_headers(cart_id=cart_id), "body": json.dumps({"message": "product not found"}), } if user_sub: logger.info("Authenticated user") pk = f"user#{user_sub}" ttl = generate_ttl( 7 ) # Set a longer ttl for logged in users - we want to keep their cart for longer. else: logger.info("Unauthenticated user") pk = f"cart#{cart_id}" ttl = generate_ttl() if int(quantity) < 0: table.update_item( Key={"pk": pk, "sk": f"product#{product_id}"}, ExpressionAttributeNames={ "#quantity": "quantity", "#expirationTime": "expirationTime", "#productDetail": "productDetail", }, ExpressionAttributeValues={ ":val": quantity, ":ttl": ttl, ":productDetail": product, ":limit": abs(quantity), }, UpdateExpression="ADD #quantity :val SET #expirationTime = :ttl, #productDetail = :productDetail", # Prevent quantity less than 0 ConditionExpression="quantity >= :limit", ) else: table.update_item( Key={"pk": pk, "sk": f"product#{product_id}"}, ExpressionAttributeNames={ "#quantity": "quantity", "#expirationTime": "expirationTime", "#productDetail": "productDetail", }, ExpressionAttributeValues={ ":val": quantity, ":ttl": generate_ttl(), ":productDetail": product, }, UpdateExpression="ADD #quantity :val SET #expirationTime = :ttl, #productDetail = :productDetail", ) metrics.add_metric(name="CartUpdated", unit="Count", value=1) return { "statusCode": 200, "headers": get_headers(cart_id), "body": json.dumps( {"productId": product_id, "message": "product added to cart"} ), } ================================================ FILE: backend/shopping-cart-service/checkout_cart.py ================================================ import json import os import boto3 from aws_lambda_powertools import Logger, Metrics, Tracer from boto3.dynamodb.conditions import Key from shared import get_cart_id, get_headers, handle_decimal_type logger = Logger() tracer = Tracer() metrics = Metrics() dynamodb = boto3.resource("dynamodb") logger.debug("Initializing DDB Table %s", os.environ["TABLE_NAME"]) table = dynamodb.Table(os.environ["TABLE_NAME"]) @metrics.log_metrics(capture_cold_start_metric=True) @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Update cart table to use user identifier instead of anonymous cookie value as a key. This will be called when a user is logged in. """ cart_id, _ = get_cart_id(event["headers"]) try: # Because this method is authorized at API gateway layer, we don't need to validate the JWT claims here user_id = event["requestContext"]["authorizer"]["claims"]["sub"] except KeyError: return { "statusCode": 400, "headers": get_headers(cart_id), "body": json.dumps({"message": "Invalid user"}), } # Get all cart items belonging to the user's identity response = table.query( KeyConditionExpression=Key("pk").eq(f"user#{user_id}") & Key("sk").begins_with("product#"), ConsistentRead=True, # Perform a strongly consistent read here to ensure we get correct and up to date cart ) cart_items = response.get("Items") # batch_writer will be used to update status for cart entries belonging to the user with table.batch_writer() as batch: for item in cart_items: # Delete ordered items batch.delete_item(Key={"pk": item["pk"], "sk": item["sk"]}) metrics.add_metric(name="CartCheckedOut", unit="Count", value=1) logger.info({"action": "CartCheckedOut", "cartItems": cart_items}) return { "statusCode": 200, "headers": get_headers(cart_id), "body": json.dumps( {"products": response.get("Items")}, default=handle_decimal_type ), } ================================================ FILE: backend/shopping-cart-service/db_stream_handler.py ================================================ import os from collections import Counter import boto3 from aws_lambda_powertools import Logger, Tracer from boto3.dynamodb import types logger = Logger() tracer = Tracer() dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(os.environ["TABLE_NAME"]) deserializer = types.TypeDeserializer() @tracer.capture_method def dynamodb_to_python(dynamodb_item): """ Convert from dynamodb low level format to python dict """ return {k: deserializer.deserialize(v) for k, v in dynamodb_item.items()} @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Handle streams from DynamoDB table """ records = event["Records"] quantity_change_counter = Counter() for record in records: keys = dynamodb_to_python(record["dynamodb"]["Keys"]) # NewImage record only exists if the event is INSERT or MODIFY if record["eventName"] in ("INSERT", "MODIFY"): new_image = dynamodb_to_python(record["dynamodb"]["NewImage"]) else: new_image = {} old_image_ddb = record["dynamodb"].get("OldImage") if old_image_ddb: old_image = dynamodb_to_python( record["dynamodb"].get("OldImage") ) # Won't exist in case event is INSERT else: old_image = {} # We want to record the quantity change the change made to the db rather than absolute values if keys["sk"].startswith("product#"): quantity_change_counter.update( { keys["sk"]: new_image.get("quantity", 0) - old_image.get("quantity", 0) } ) for k, v in quantity_change_counter.items(): table.update_item( Key={"pk": k, "sk": "totalquantity"}, ExpressionAttributeNames={"#quantity": "quantity"}, ExpressionAttributeValues={":val": v}, UpdateExpression="ADD #quantity :val", ) return { "statusCode": 200, } ================================================ FILE: backend/shopping-cart-service/delete_from_cart.py ================================================ import json import os import boto3 from aws_lambda_powertools import Logger, Tracer logger = Logger() tracer = Tracer() dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(os.environ["TABLE_NAME"]) @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Handle messages from SQS Queue containing cart items, and delete them from DynamoDB. """ records = event["Records"] logger.info(f"Deleting {len(records)} records") with table.batch_writer() as batch: for item in records: item_body = json.loads(item["body"]) batch.delete_item(Key={"pk": item_body["pk"], "sk": item_body["sk"]}) return { "statusCode": 200, } ================================================ FILE: backend/shopping-cart-service/get_cart_total.py ================================================ import json import os import boto3 from aws_lambda_powertools import Logger, Tracer from shared import handle_decimal_type logger = Logger() tracer = Tracer() dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(os.environ["TABLE_NAME"]) @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ List items in shopping cart. """ product_id = event["pathParameters"]["product_id"] response = table.get_item( Key={"pk": f"product#{product_id}", "sk": "totalquantity"} ) quantity = response["Item"]["quantity"] return { "statusCode": 200, "body": json.dumps( {"product": product_id, "quantity": quantity}, default=handle_decimal_type ), } ================================================ FILE: backend/shopping-cart-service/list_cart.py ================================================ import json import os import boto3 from aws_lambda_powertools import Logger, Tracer from boto3.dynamodb.conditions import Key from shared import get_cart_id, get_headers, get_user_sub, handle_decimal_type logger = Logger() tracer = Tracer() dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(os.environ["TABLE_NAME"]) @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ List items in shopping cart. """ cart_id, generated = get_cart_id(event["headers"]) # Because this method can be called anonymously, we need to check there's a logged in user jwt_token = event["headers"].get("Authorization") if jwt_token: user_sub = get_user_sub(jwt_token) key_string = f"user#{user_sub}" logger.structure_logs(append=True, cart_id=f"user#{user_sub}") else: key_string = f"cart#{cart_id}" logger.structure_logs(append=True, cart_id=f"cart#{cart_id}") # No need to query database if the cart_id was generated rather than passed into the function if generated: logger.info("cart ID was generated in this request, not fetching cart from DB") product_list = [] else: logger.info("Fetching cart from DB") response = table.query( KeyConditionExpression=Key("pk").eq(key_string) & Key("sk").begins_with("product#"), ProjectionExpression="sk,quantity,productDetail", FilterExpression="quantity > :val", # Only return items with more than 0 quantity ExpressionAttributeValues={":val": 0}, ) product_list = response.get("Items", []) for product in product_list: product.update( (k, v.replace("product#", "")) for k, v in product.items() if k == "sk" ) return { "statusCode": 200, "headers": get_headers(cart_id), "body": json.dumps({"products": product_list}, default=handle_decimal_type), } ================================================ FILE: backend/shopping-cart-service/migrate_cart.py ================================================ import json import os import threading import boto3 from aws_lambda_powertools import Logger, Metrics, Tracer from boto3.dynamodb.conditions import Key from shared import generate_ttl, get_cart_id, get_headers, handle_decimal_type logger = Logger() tracer = Tracer() metrics = Metrics() dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(os.environ["TABLE_NAME"]) sqs = boto3.resource("sqs") queue = sqs.Queue(os.environ["DELETE_FROM_CART_SQS_QUEUE"]) @tracer.capture_method def update_item(user_id, item): """ Update an item in the database, adding the quantity of the passed in item to the quantity of any products already existing in the cart. """ table.update_item( Key={"pk": f"user#{user_id}", "sk": item["sk"]}, ExpressionAttributeNames={ "#quantity": "quantity", "#expirationTime": "expirationTime", "#productDetail": "productDetail", }, ExpressionAttributeValues={ ":val": item["quantity"], ":ttl": generate_ttl(days=30), ":productDetail": item["productDetail"], }, UpdateExpression="ADD #quantity :val SET #expirationTime = :ttl, #productDetail = :productDetail", ) @metrics.log_metrics(capture_cold_start_metric=True) @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Update cart table to use user identifier instead of anonymous cookie value as a key. This will be called when a user is logged in. """ cart_id, _ = get_cart_id(event["headers"]) try: # Because this method is authorized at API gateway layer, we don't need to validate the JWT claims here user_id = event["requestContext"]["authorizer"]["claims"]["sub"] logger.info("Migrating cart_id %s to user_id %s", cart_id, user_id) except KeyError: return { "statusCode": 400, "headers": get_headers(cart_id), "body": json.dumps({"message": "Invalid user"}), } # Get all cart items belonging to the user's anonymous identity response = table.query( KeyConditionExpression=Key("pk").eq(f"cart#{cart_id}") & Key("sk").begins_with("product#") ) unauth_cart = response["Items"] # Since there's no batch operation available for updating items, and there's no dependency between them, we can # run them in parallel threads. thread_list = [] for item in unauth_cart: # Store items with user identifier as pk instead of "unauthenticated" cart ID # Using threading library to perform updates in parallel ddb_updateitem_thread = threading.Thread( target=update_item, args=(user_id, item) ) thread_list.append(ddb_updateitem_thread) ddb_updateitem_thread.start() # Delete items with unauthenticated cart ID # Rather than deleting directly, push to SQS queue to handle asynchronously queue.send_message(MessageBody=json.dumps(item, default=handle_decimal_type)) for ddb_thread in thread_list: ddb_thread.join() # Block main thread until all updates finished if unauth_cart: metrics.add_metric(name="CartMigrated", unit="Count", value=1) response = table.query( KeyConditionExpression=Key("pk").eq(f"user#{user_id}") & Key("sk").begins_with("product#"), ProjectionExpression="sk,quantity,productDetail", ConsistentRead=True, # Perform a strongly consistent read here to ensure we get correct values after updates ) product_list = response.get("Items", []) for product in product_list: product.update( (k, v.replace("product#", "")) for k, v in product.items() if k == "sk" ) return { "statusCode": 200, "headers": get_headers(cart_id), "body": json.dumps({"products": product_list}, default=handle_decimal_type), } ================================================ FILE: backend/shopping-cart-service/requirements.txt ================================================ ================================================ FILE: backend/shopping-cart-service/tests/__init__.py ================================================ ================================================ FILE: backend/shopping-cart-service/tests/test_example.py ================================================ import sys import unittest sys.path.append("..") # Add application to path sys.path.append("./layers/") # Add layer to path import shared # noqa: E402 # import from layer class Tests(unittest.TestCase): """ Example included to demonstrate how to run unit tests when using lambda layers. """ def setUp(self): pass def test_headers(self): self.assertEqual(shared.HEADERS.get("Access-Control-Allow-Credentials"), True) if __name__ == "__main__": unittest.main() ================================================ FILE: backend/shopping-cart-service/update_cart.py ================================================ import json import os import boto3 from aws_lambda_powertools import Logger, Metrics, Tracer from shared import ( NotFoundException, generate_ttl, get_cart_id, get_headers, get_user_sub, ) from utils import get_product_from_external_service logger = Logger() tracer = Tracer() metrics = Metrics() dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(os.environ["TABLE_NAME"]) product_service_url = os.environ["PRODUCT_SERVICE_URL"] @metrics.log_metrics(capture_cold_start_metric=True) @logger.inject_lambda_context(log_event=True) @tracer.capture_lambda_handler def lambda_handler(event, context): """ Idempotent update quantity of products in a cart. Quantity provided will overwrite existing quantity for a specific product in cart, rather than adding to it. """ try: request_payload = json.loads(event["body"]) except KeyError: return { "statusCode": 400, "headers": get_headers(), "body": json.dumps({"message": "No Request payload"}), } # retrieve the product_id that was specified in the url product_id = event["pathParameters"]["product_id"] quantity = int(request_payload["quantity"]) cart_id, _ = get_cart_id(event["headers"]) # Because this method can be called anonymously, we need to check if there's a logged in user user_sub = None jwt_token = event["headers"].get("Authorization") if jwt_token: user_sub = get_user_sub(jwt_token) try: product = get_product_from_external_service(product_id) except NotFoundException: logger.info("No product found with product_id: %s", product_id) return { "statusCode": 404, "headers": get_headers(cart_id=cart_id), "body": json.dumps({"message": "product not found"}), } # Prevent storing negative quantities of things if quantity < 0: return { "statusCode": 400, "headers": get_headers(cart_id), "body": json.dumps( { "productId": product_id, "message": "Quantity must not be lower than 0", } ), } # Use logged in user's identifier if it exists, otherwise use the anonymous identifier if user_sub: pk = f"user#{user_sub}" ttl = generate_ttl( 7 ) # Set a longer ttl for logged in users - we want to keep their cart for longer. else: pk = f"cart#{cart_id}" ttl = generate_ttl() table.put_item( Item={ "pk": pk, "sk": f"product#{product_id}", "quantity": quantity, "expirationTime": ttl, "productDetail": product, } ) logger.info("about to add metrics...") metrics.add_metric(name="CartUpdated", unit="Count", value=1) return { "statusCode": 200, "headers": get_headers(cart_id), "body": json.dumps( {"productId": product_id, "quantity": quantity, "message": "cart updated"} ), } ================================================ FILE: backend/shopping-cart-service/utils.py ================================================ import os import requests from aws_lambda_powertools import Logger, Tracer from shared import NotFoundException product_service_url = os.environ["PRODUCT_SERVICE_URL"] logger = Logger() tracer = Tracer() @tracer.capture_method def get_product_from_external_service(product_id): """ Call product API to retrieve product details """ response = requests.get(product_service_url + f"/product/{product_id}") try: response_dict = response.json()["product"] except KeyError: logger.warn("No product found with id %s", product_id) raise NotFoundException return response_dict ================================================ FILE: backend/shoppingcart-service.yaml ================================================ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > shoppingcart-service SAM Template for shoppingcart-service Parameters: UserPoolArn: Type: 'AWS::SSM::Parameter::Value' Default: '/serverless-shopping-cart-demo/auth/user-pool-arn' UserPoolId: Type: 'AWS::SSM::Parameter::Value' Default: '/serverless-shopping-cart-demo/auth/user-pool-id' ProductServiceUrl: Type: 'AWS::SSM::Parameter::Value' Default: '/serverless-shopping-cart-demo/products/products-api-url' AllowedOrigin: Type: 'String' Globals: Function: Timeout: 5 MemorySize: 512 Tracing: Active AutoPublishAlias: live Runtime: python3.8 Layers: - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:3 Environment: Variables: TABLE_NAME: !Ref DynamoDBShoppingCartTable LOG_LEVEL: "INFO" ALLOWED_ORIGIN: !Ref AllowedOrigin POWERTOOLS_SERVICE_NAME: shopping-cart POWERTOOLS_METRICS_NAMESPACE: ecommerce-app Api: EndpointConfiguration: REGIONAL TracingEnabled: true OpenApiVersion: '2.0' Cors: AllowMethods: "'OPTIONS,POST,GET,PUT'" AllowHeaders: "'Content-Type,Authorization'" AllowCredentials: true AllowOrigin: !Sub "'${AllowedOrigin}'" Resources: UtilsLayer: Type: AWS::Serverless::LayerVersion Properties: ContentUri: ./layers/ CompatibleRuntimes: - python3.8 Metadata: BuildMethod: python3.8 CartApi: Type: AWS::Serverless::Api DependsOn: - ApiGWAccount Properties: StageName: Prod MethodSettings: - DataTraceEnabled: True MetricsEnabled: True ResourcePath: "/*" HttpMethod: "*" LoggingLevel: INFO Auth: Authorizers: CognitoAuthorizer: UserPoolArn: !Ref UserPoolArn Identity: # OPTIONAL Header: Authorization # OPTIONAL; Default: 'Authorization' ListCartRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Action: - "sts:AssumeRole" Effect: "Allow" Principal: Service: - "lambda.amazonaws.com" AddToCartRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Action: - "sts:AssumeRole" Effect: "Allow" Principal: Service: - "lambda.amazonaws.com" LambdaLoggingPolicy: Type: "AWS::IAM::Policy" Properties: PolicyName: LambdaXRayPolicy PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: [ "xray:PutTraceSegments", "xray:PutTelemetryRecords", "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource: "*" Roles: - !Ref ListCartRole - !Ref AddToCartRole DynamoDBReadPolicy: Type: "AWS::IAM::Policy" Properties: PolicyName: DynamoDBReadPolicy PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: [ "dynamodb:GetItem", "dynamodb:Scan", "dynamodb:Query", "dynamodb:BatchGetItem", "dynamodb:DescribeTable" ] Resource: - !GetAtt DynamoDBShoppingCartTable.Arn Roles: - !Ref ListCartRole - !Ref AddToCartRole DynamoDBWritePolicy: Type: "AWS::IAM::Policy" Properties: PolicyName: DynamoDBWritePolicy PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: [ "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:ConditionCheckItem", "dynamodb:DeleteItem", "dynamodb:BatchWriteItem" ] Resource: !GetAtt DynamoDBShoppingCartTable.Arn Roles: - !Ref AddToCartRole SQSSendMessagePolicy: Type: "AWS::IAM::Policy" Properties: PolicyName: SQSSendMessagePolicy PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: [ "sqs:SendMessage*" ] Resource: !GetAtt CartDeleteSQSQueue.Arn Roles: - !Ref AddToCartRole ListCartFunction: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: list_cart.lambda_handler Role: !GetAtt ListCartRole.Arn Layers: - !Ref UtilsLayer Environment: Variables: USERPOOL_ID: !Ref UserPoolId Events: ListCart: Type: Api Properties: RestApiId: !Ref CartApi Path: /cart Method: get AddToCartFunction: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: add_to_cart.lambda_handler Role: !GetAtt AddToCartRole.Arn Layers: - !Ref UtilsLayer Environment: Variables: PRODUCT_SERVICE_URL: !Ref ProductServiceUrl USERPOOL_ID: !Ref UserPoolId Events: AddToCart: Type: Api Properties: RestApiId: !Ref CartApi Path: /cart Method: post UpdateCartFunction: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: update_cart.lambda_handler Role: !GetAtt AddToCartRole.Arn Layers: - !Ref UtilsLayer Environment: Variables: PRODUCT_SERVICE_URL: !Ref ProductServiceUrl USERPOOL_ID: !Ref UserPoolId Events: AddToCart: Type: Api Properties: RestApiId: !Ref CartApi Path: /cart/{product_id} Method: put MigrateCartFunction: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: migrate_cart.lambda_handler Timeout: 30 Layers: - !Ref UtilsLayer Environment: Variables: PRODUCT_SERVICE_URL: !Ref ProductServiceUrl USERPOOL_ID: !Ref UserPoolId DELETE_FROM_CART_SQS_QUEUE: !Ref CartDeleteSQSQueue Role: !GetAtt AddToCartRole.Arn Events: AddToCart: Type: Api Properties: RestApiId: !Ref CartApi Path: /cart/migrate Method: post Auth: Authorizer: CognitoAuthorizer CheckoutCartFunction: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: checkout_cart.lambda_handler Timeout: 10 Layers: - !Ref UtilsLayer Environment: Variables: PRODUCT_SERVICE_URL: !Ref ProductServiceUrl USERPOOL_ID: !Ref UserPoolId Role: !GetAtt AddToCartRole.Arn Events: AddToCart: Type: Api Properties: RestApiId: !Ref CartApi Path: /cart/checkout Method: post Auth: Authorizer: CognitoAuthorizer GetCartTotalFunction: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: get_cart_total.lambda_handler Timeout: 10 Layers: - !Ref UtilsLayer Role: !GetAtt ListCartRole.Arn Events: GetCartTotal: Type: Api Properties: RestApiId: !Ref CartApi Path: /cart/{product_id}/total Method: get DeleteFromCartFunction: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: delete_from_cart.lambda_handler ReservedConcurrentExecutions: 25 # Keep the ddb spikes down in case of many deletes at once Policies: - SQSPollerPolicy: QueueName: !GetAtt CartDeleteSQSQueue.QueueName - Statement: - Effect: Allow Action: - "dynamodb:DeleteItem" - "dynamodb:BatchWriteItem" Resource: - !GetAtt DynamoDBShoppingCartTable.Arn Layers: - !Ref UtilsLayer Environment: Variables: USERPOOL_ID: !Ref UserPoolId Events: RetrieveFromSQS: Type: SQS Properties: Queue: !GetAtt CartDeleteSQSQueue.Arn BatchSize: 5 CartDBStreamHandler: Type: AWS::Serverless::Function DependsOn: - LambdaLoggingPolicy Properties: CodeUri: shopping-cart-service/ Handler: db_stream_handler.lambda_handler Layers: - !Ref UtilsLayer Policies: - AWSLambdaDynamoDBExecutionRole - Statement: - Effect: Allow Action: - "dynamodb:UpdateItem" Resource: - !GetAtt DynamoDBShoppingCartTable.Arn Events: Stream: Type: DynamoDB Properties: Stream: !GetAtt DynamoDBShoppingCartTable.StreamArn BatchSize: 100 MaximumBatchingWindowInSeconds: 60 StartingPosition: LATEST DynamoDBShoppingCartTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - AttributeName: pk AttributeType: S - AttributeName: sk AttributeType: S KeySchema: - AttributeName: pk KeyType: HASH - AttributeName: sk KeyType: RANGE BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: 'NEW_AND_OLD_IMAGES' TimeToLiveSpecification: AttributeName: expirationTime Enabled: True APIGWCloudWatchRole: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - apigateway.amazonaws.com Action: 'sts:AssumeRole' Path: / ManagedPolicyArns: - >- arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs ApiGWAccount: Type: 'AWS::ApiGateway::Account' Properties: CloudWatchRoleArn: !GetAtt APIGWCloudWatchRole.Arn CartDeleteSQSQueue: Type: AWS::SQS::Queue Properties: VisibilityTimeout: 20 RedrivePolicy: deadLetterTargetArn: !GetAtt CartDeleteSQSDLQ.Arn maxReceiveCount: 5 CartDeleteSQSDLQ: Type: AWS::SQS::Queue CartApiUrl: Type: AWS::SSM::Parameter Properties: Type: String Name: /serverless-shopping-cart-demo/shopping-cart/cart-api-url Value: !Sub "https://${CartApi}.execute-api.${AWS::Region}.amazonaws.com/Prod" Outputs: CartApi: Description: "API Gateway endpoint URL for Prod stage for Cart Service" Value: !Sub "https://${CartApi}.execute-api.${AWS::Region}.amazonaws.com/Prod" ================================================ FILE: frontend/.gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Vue template # gitignore template for Vue.js projects # # Recommended template: Node.gitignore # TODO: where does this rule come from? docs/_book # TODO: where does this rule come from? test/ ### Node template # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env.test # parcel-bundler cache (https://parceljs.org/) .cache # next.js build output .next # nuxt.js build output .nuxt # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ dist/* .vscode /amplify/* /amplify *.local .env ================================================ FILE: frontend/Makefile ================================================ all: build serve: yarn install yarn fetchConfig local yarn serve build: yarn install yarn fetchConfig yarn build .PHONY: build serve ================================================ FILE: frontend/babel.config.js ================================================ module.exports = { presets: [ '@vue/cli-plugin-babel/preset' ] } ================================================ FILE: frontend/package.json ================================================ { "name": "shoppingcart-service-frontend", "version": "0.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "lint": "vue-cli-service lint", "fetchConfig": "node scripts/fetchconfig.js" }, "dependencies": { "aws-amplify": "^1.2.3", "aws-amplify-vue": "^0.3.3", "core-js": "^3.3.2", "decimal.js": "^10.2.0", "dotenv": "^8.2.0", "v-mask": "^2.0.2", "vue": "^2.6.10", "vue-router": "^3.1.3", "vuelidate": "^0.7.4", "vuetify": "^2.1.7", "vuex": "^3.1.1" }, "devDependencies": { "@vue/cli-plugin-babel": "^4.0.0", "@vue/cli-plugin-eslint": "^4.0.0", "@vue/cli-service": "^4.0.0", "babel-eslint": "^10.0.3", "eslint": "^5.16.0", "eslint-plugin-vue": "^5.0.0", "sass": "^1.19.0", "sass-loader": "^8.0.0", "vue-cli-plugin-vuetify": "^2.0.2", "vue-template-compiler": "^2.6.10", "vuetify-loader": "^1.3.0" }, "resolutions": { "websocket-extensions": "^0.1.4", "axios": "^0.21.2", "ssri": "^8.0.1", "is-svg": "^4.2.2", "glob-parent": "^5.1.2", "set-value": "^4.0.1", "ansi-regex": "^5.0.1", "nth-check": "^2.0.1", "aws-sdk": "^2.814.0", "node-forge": "^1.0.0" }, "eslintConfig": { "root": true, "env": { "node": true }, "extends": [ "plugin:vue/essential", "eslint:recommended" ], "rules": {}, "parserOptions": { "parser": "babel-eslint" } }, "postcss": { "plugins": { "autoprefixer": {} } }, "browserslist": [ "> 1%", "last 2 versions" ] } ================================================ FILE: frontend/public/index.html ================================================ Serverless Shopping Cart Demo
================================================ FILE: frontend/scripts/fetchconfig.js ================================================ process.env.AWS_SDK_LOAD_CONFIG = true; var fs = require('fs'); var args = process.argv.slice(2); var envtype = args[0] ? args[0] : '' var AWS = require('aws-sdk'); var ssm = new AWS.SSM(); const query = { "Path": "/serverless-shopping-cart-demo/", "WithDecryption": false, "Recursive": true } const requiredParams = ["CART_API_URL", "PRODUCTS_API_URL", "USER_POOL_ID", "USER_POOL_CLIENT_ID" ] var params = ssm.getParametersByPath(query).promise() var output = [] function formatParams(data) { for (var param of data) { const paramName = param.Name.toUpperCase().split("/").pop().replace(/-/g, "_") if (requiredParams.includes(paramName)) { output.push("VUE_APP_" + paramName + '=' + param.Value) } } } params .then(data => { formatParams(data.Parameters) output.push("VUE_APP_AWS_REGION=" + AWS.config.region) var fileName if (envtype) { fileName = "./.env." + envtype } else { fileName = "./.env" } fs.writeFile(fileName, output.join('\n'), function (err) { if (err) { return console.log(err); // eslint-disable-line no-console } console.log(`env file ${fileName} populated with config`); // eslint-disable-line no-console }); }) .catch(error => { console.log('error: ' + error) // eslint-disable-line no-console }) ================================================ FILE: frontend/src/App.vue ================================================ ================================================ FILE: frontend/src/aws-exports.js ================================================ const awsmobile = { Auth: { region: process.env.VUE_APP_AWS_REGION, userPoolId: process.env.VUE_APP_USER_POOL_ID, userPoolWebClientId: process.env.VUE_APP_USER_POOL_CLIENT_ID }, API: { endpoints: [{ name: "CartAPI", endpoint: process.env.VUE_APP_CART_API_URL }, { name: "ProductAPI", endpoint: process.env.VUE_APP_PRODUCTS_API_URL, } ] } }; export default awsmobile; ================================================ FILE: frontend/src/backend/api.js ================================================ import { Auth, API } from 'aws-amplify' async function getHeaders(includeAuth) { const headers = { "Content-Type": "application/json" } if (!includeAuth) { return { "Content-Type": "application/json" } } let session = null try { session = await Auth.currentSession() } catch (e) { e == e } if (session) { let authheader = session.getIdToken().jwtToken headers['Authorization'] = authheader } return headers } export async function getCart() { return getHeaders(true).then( headers => API.get("CartAPI", "/cart", { headers: headers, withCredentials: true })) } export async function postCart(obj, quantity = 1) { return getHeaders(true).then( headers => API.post("CartAPI", "/cart", { body: { productId: obj.productId, quantity: quantity, }, headers: headers, withCredentials: true }) ) } export async function putCart(obj, quantity) { return getHeaders(true).then( headers => API.put("CartAPI", "/cart/" + obj.productId, { body: { productId: obj.productId, quantity: quantity, }, headers: headers, withCredentials: true }) ) } export async function getProducts() { return getHeaders().then( headers => API.get("ProductAPI", "/product", { headers: headers }) ) } export async function cartMigrate() { return getHeaders(true).then( headers => API.post("CartAPI", "/cart/migrate", { headers: headers, withCredentials: true }) ) } export async function cartCheckout() { return getHeaders(true).then( headers => API.post("CartAPI", "/cart/checkout", { headers: headers, withCredentials: true }) ) } ================================================ FILE: frontend/src/components/CartButton.vue ================================================ ================================================ FILE: frontend/src/components/CartDrawer.vue ================================================ ================================================ FILE: frontend/src/components/CartQuantityEditor.vue ================================================ ================================================ FILE: frontend/src/components/LoadingOverlay.vue ================================================ ================================================ FILE: frontend/src/components/Product.vue ================================================ ================================================ FILE: frontend/src/main.js ================================================ import Vue from 'vue' import VueRouter from 'vue-router' import Amplify from 'aws-amplify' import Vuelidate from 'vuelidate' import VueMask from 'v-mask' import App from './App' import router from './router' import config from './aws-exports' import vuetify from '@/plugins/vuetify' import store from './store/store' import { components } from 'aws-amplify-vue'; import CartButton from "@/components/CartButton.vue"; import CartDrawer from "@/components/CartDrawer.vue"; import LoadingOverlay from "@/components/LoadingOverlay.vue"; import Product from "@/components/Product.vue"; import CartQuantityEditor from "@/components/CartQuantityEditor.vue" Vue.config.productionTip = false Amplify.configure(config) Vue.use(VueRouter) Vue.use(Vuelidate) Vue.use(VueMask); Vue.component('cart-button', CartButton) Vue.component('cart-drawer', CartDrawer) Vue.component('loading-overlay', LoadingOverlay) Vue.component('product', Product) Vue.component('cart-quantity-editor', CartQuantityEditor) new Vue({ render: h => h(App), router, vuetify, store, components: { ...components } }).$mount('#app') ================================================ FILE: frontend/src/plugins/vuetify.js ================================================ import Vue from 'vue' import Vuetify from 'vuetify/lib' Vue.use(Vuetify) const opts = { theme: { themes: { light: { primary: '#DCE1E9', secondary: '#363732', accent: '#e88b01' }, }, }, } export default new Vuetify(opts) ================================================ FILE: frontend/src/router.js ================================================ import VueRouter from 'vue-router' import Vue from 'vue'; import store from '@/store/store.js' import Home from '@/views/Home.vue' import Payment from '@/views/Payment.vue' import Auth from '@/views/Auth.vue' import { components, AmplifyEventBus } from 'aws-amplify-vue'; import * as AmplifyModules from 'aws-amplify' import { AmplifyPlugin } from 'aws-amplify-vue' Vue.use(AmplifyPlugin, AmplifyModules) getUser().then((user) => { if (user) { router.push({ path: '/' }).catch(() => {}) } }) AmplifyEventBus.$on('authState', async (state) => { if (state === 'signedOut') { store.commit('setUser', null); store.dispatch('fetchCart') router.push({ path: '/' }).catch(() => {}) } else if (state === 'signedIn') { getUser().then(() => { if (store.state.cart.length > 0) { store.dispatch('migrateCart') } else { store.dispatch('fetchCart') } }) router.push({ path: new URLSearchParams(window.location.search).get('redirect')|| '/' }).catch(() => {}) } }); function getUser() { return Vue.prototype.$Amplify.Auth.currentAuthenticatedUser().then((data) => { if (data && data.signInUserSession) { store.commit('setUser', data); return data; } }).catch(() => { store.commit('setUser', null); return null }); } const routes = [{ path: '/', component: Home }, { path: '/auth', name: 'Authenticator', component: Auth }, { path: '/checkout', component: Payment, meta: { requiresAuth: true } } ] const router = new VueRouter({ mode: 'history', routes }) router.beforeResolve(async (to, from, next) => { if (to.matched.some(record => record.meta.requiresAuth)) { let user = await getUser(); if (!user) { return next({ path: '/auth', query: { redirect: to.fullPath, } }); } return next() } return next() }) export default router ================================================ FILE: frontend/src/store/actions.js ================================================ import { postCart, getCart, getProducts, cartMigrate, putCart, cartCheckout } from "@/backend/api.js" import router from '@/router' const setLoading = ({ commit }, payload) => { commit("setLoading", {value: payload.value, message: payload.message}) } const fetchProducts = ({ commit }) => { getProducts().then((response) => { commit("setUpProducts", response.products); }); } const fetchCart = ({ commit }) => { commit("setLoading", {value: true}) getCart() .then((response) => { commit("setUpCart", response.products) commit("setLoading", {value: false}) }) } const addToCart = ({ commit }, obj) => { commit("setProductLoading", { "product": obj, "value": true, "btn": "add" }) postCart(obj) .then((response) => { commit("setCartLoading", 1) commit("addToCart", response.productId); commit("setProductLoading", { "product": obj, "value": false, "btn": "add" }) setTimeout(() => { commit("setCartLoading", -1) }, 500) }).catch(() => { commit("setProductLoading", { "product": obj, "value": false, "btn": "add" }) }); } const removeFromCart = ({ commit }, obj) => { commit("setProductLoading", { "product": obj, "value": true, "btn": "remove" }) postCart(obj, -1) .then((response) => { commit("setCartLoading", 1) commit("removeFromCart", response.productId) commit("setProductLoading", { "product": obj, "value": false, "btn": "remove" }) setTimeout(() => { commit("setCartLoading", -1) }, 500) }).catch(() => { commit("setProductLoading", { "product": obj, "value": false, "btn": "remove" }) }) } const updateCart = ({ commit }, obj) => { putCart(obj.product, obj.quantity) .then((response) => { commit("setCartLoading", 1) commit("updateCart", response) setTimeout(() => { commit("setCartLoading", -1) }, 500) }) } const migrateCart = ({ commit }) => { commit("setLoading", {value: true}) cartMigrate() .then((response) => { commit("setUpCart", response.products) commit("setLoading", {value: false}) }) } const checkoutCart = ({ commit }) => { commit("setLoading", {value: true, message: "This is where we'd handle payment before clearing the cart..."}) cartCheckout() .then(() => { commit("setUpCart", []) setTimeout(function() {commit("setLoading", {value: false})}, 3000) setTimeout(function() {router.push("/")}, 3200) }) } export default { setLoading, fetchCart, fetchProducts, migrateCart, updateCart, removeFromCart, addToCart, checkoutCart } ================================================ FILE: frontend/src/store/getters.js ================================================ import { Decimal } from "decimal.js" const cartSize = (state) => { return state.cart.reduce((total, cartProduct) => { return total + cartProduct.quantity }, 0) } const cartTotalAmount = (state) => { let val = state.cart.reduce((total, cartProduct) => { return new Decimal(total).plus(new Decimal(cartProduct.productDetail.price/100).times(cartProduct.quantity)); }, 0); return new Decimal(val).toFixed(2) } const currentUser = (state) => { return state.user } const getCart = (state) => { return state.cart.filter((prod) => prod.quantity > 0) } export default { cartSize, cartTotalAmount, currentUser, getCart } ================================================ FILE: frontend/src/store/mutations.js ================================================ const setUser = (state, user) => { state.user = user } const setUpProducts = (state, productsPayload) => { productsPayload.forEach((product) => { product.addLoading = false product.removeLoading = false }) state.products = productsPayload; } const setUpCart = (state, cartPayload) => { state.cart = cartPayload; } const addToCart = (state, productId) => { let product = {} product.productDetail = state.products.find((prod) => prod.productId === productId); product.sk = productId let cartProduct = state.cart.find((prod) => prod.sk === productId); if (cartProduct) { cartProduct.quantity++; } else { state.cart.push({ ...product, quantity: 1, }); } } const removeFromCart = (state, productId) => { let product = {} product.productDetail = state.products[productId]; product.sk = productId let cartProduct = state.cart.find((prod) => prod.sk === productId); cartProduct.quantity--; } const deleteFromCart = (state, productId) => { let product = state.products.find((product) => product.productId === productId); let cartProductIndex = state.cart.findIndex((product) => product.productId === productId); product.quantity = state.cart[cartProductIndex].stock; state.cart.splice(cartProductIndex, 1); } const setLoading = (state, payload) => { state.loading = payload.value state.loadingText = payload.message } const setCartLoading = (state, value) => { state.cartLoading += value } const setProductLoading = (state, { product, btn, value }) => { let prod = state.products.find((prod) => prod.productId === product.productId); prod[btn + "Loading"] = value } const updateCart = (state, obj) => { let product = {} product.productDetail = state.products.find((prod) => prod.productId === obj.productId); product.sk = obj.productId let cartProduct = state.cart.find((prod) => prod.sk === obj.productId); if (cartProduct) { cartProduct.quantity = obj.quantity } else { state.cart.push({ ...product, quantity: obj.quantity, }); } } export default { setUser, setUpProducts, setUpCart, addToCart, removeFromCart, deleteFromCart, setLoading, setCartLoading, setProductLoading, updateCart } ================================================ FILE: frontend/src/store/store.js ================================================ import Vue from 'vue' import Vuex from 'vuex' import actions from './actions'; import mutations from './mutations'; import getters from './getters'; Vue.use(Vuex); export default new Vuex.Store({ state: { products: null, cart: [], user: null, loading: false, cartLoading: 0, loadingText: "" }, getters, mutations, actions }) ================================================ FILE: frontend/src/views/Auth.vue ================================================ ================================================ FILE: frontend/src/views/Home.vue ================================================ ================================================ FILE: frontend/src/views/Payment.vue ================================================ ================================================ FILE: frontend/vue.config.js ================================================ module.exports = { "transpileDependencies": [ "vuetify" ], lintOnSave: false }