├── .gitignore ├── LICENSE ├── README.md ├── architecture.png ├── cdk.context.json ├── cdk.json ├── constants.py ├── docs └── decisions │ └── 0001-record-architecture-decisions.md ├── main.py ├── package-lock.json ├── package.json ├── requirements-dev.in ├── requirements-dev.txt ├── requirements.in ├── requirements.txt ├── service ├── __init__.py ├── api │ ├── __init__.py │ ├── app │ │ ├── helpers.py │ │ ├── main.py │ │ ├── repository.py │ │ ├── requirements.in │ │ ├── requirements.txt │ │ └── tests │ │ │ └── test_app.py │ └── compute.py ├── database.py ├── ingress.py ├── monitoring.py └── service_stack.py ├── tests └── test_compute.py └── toolchain ├── __init__.py ├── config ├── .adr-dir ├── .flake8 ├── .isort.cfg ├── .mypy.ini └── .pylintrc ├── deployment_pipeline.py ├── pull_request_build.py ├── scripts ├── install-deps.sh └── run-tests.sh └── toolchain_stack.py /.gitignore: -------------------------------------------------------------------------------- 1 | # AWS CDK 2 | cdk.out/ 3 | 4 | # Byte-compiled / optimized / DLL 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # CDK-Dia 13 | /diagram.* 14 | 15 | # Coverage.py 16 | .coverage 17 | coverage.xml 18 | htmlcov/ 19 | 20 | # Environment 21 | .venv/ 22 | node_modules/ 23 | 24 | # Diagrams.net 25 | *.drawio 26 | 27 | # macOS 28 | .DS_Store 29 | 30 | # mypy 31 | .mypy_cache/ 32 | 33 | # PyCharm 34 | .idea/ 35 | 36 | # pyenv 37 | .python-version 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alex Pulver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # User management backend 2 | A CRUD API to manage users. Main components are the toolchain and the service. The application implements [Application Design Framework (ADF)](https://applicationdesignframework.com/) guidelines for organizing resources configuration and business logic code. 3 | 4 | ![](architecture.png) 5 | \* Diagram generated using https://github.com/pistazie/cdk-dia 6 | 7 | ## Clone code 8 | ```bash 9 | git clone https://github.com/alexpulver/usermanagement-backend 10 | cd usermanagement-backend 11 | ``` 12 | 13 | ## Fork repository 14 | This is **optional** for deploying the service to sandbox environment, but **required** for deploying the toolchain. 15 | 16 | ```bash 17 | git remote set-url origin 18 | ``` 19 | 20 | ## Configure development environment 21 | ```bash 22 | python3.11 -m venv .venv 23 | source .venv/bin/activate 24 | 25 | # [Optional] Use pip-tools to upgrade dependencies 26 | # Pinning pip-tools to 6.4.0 and pip to 21.3.1 due to 27 | # https://github.com/jazzband/pip-tools/issues/1576 28 | pip install pip-tools==6.4.0 29 | pip install pip==21.3.1 30 | 31 | toolchain/scripts/install-deps.sh 32 | toolchain/scripts/run-tests.sh 33 | ``` 34 | 35 | ## [Optional] Upgrade AWS CDK CLI version 36 | If you are planning to upgrade dependencies, first push the upgraded AWS CDK CLI version. See [This CDK CLI is not compatible with the CDK library used by your application](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.pipelines-readme.html#this-cdk-cli-is-not-compatible-with-the-cdk-library-used-by-your-application) for more details. 37 | 38 | The application uses Node Package Manager (npm) and `package.json` configuration file to install AWS CDK CLI locally. To find the latest AWS CDK CLI version: `npm view aws-cdk version`. 39 | 40 | ```bash 41 | vi package.json # Update the "aws-cdk-lib" package version 42 | ``` 43 | 44 | ```bash 45 | toolchain/scripts/install-deps.sh 46 | toolchain/scripts/run-tests.sh 47 | ``` 48 | 49 | ## [Optional] Upgrade dependencies (ordered by constraints) 50 | Consider [AWS CDK CLI](https://docs.aws.amazon.com/cdk/latest/guide/reference.html#versioning) compatibility when upgrading AWS CDK packages version. 51 | 52 | ```bash 53 | pip-compile --upgrade service/api/app/requirements.in 54 | pip-compile --upgrade requirements.in 55 | pip-compile --upgrade requirements-dev.in 56 | ``` 57 | 58 | ```bash 59 | toolchain/scripts/install-deps.sh 60 | toolchain/scripts/run-tests.sh 61 | ``` 62 | 63 | ## [Optional] Cleanup unused packages 64 | ```bash 65 | pip-sync service/api/app/requirements.txt requirements.txt requirements-dev.txt 66 | ``` 67 | 68 | ## Deploy service stack 69 | The `UserManagementBackend-Service-Sandbox` stack uses your default AWS account and Region. 70 | 71 | ```bash 72 | npx cdk deploy UserManagementBackend-Service-Sandbox 73 | ``` 74 | 75 | Example output for `npx cdk deploy UserManagementBackend-Service-Sandbox`: 76 | ```text 77 | ✅ UserManagementBackend-Service-Sandbox 78 | 79 | Outputs: 80 | UserManagementBackend-Service-Sandbox.APIEndpoint = https://bsc9goldsa.execute-api.eu-west-1.amazonaws.com/ 81 | ``` 82 | 83 | ## Deploy toolchain stack 84 | 85 | **Prerequisites** 86 | - Fork the repository, if you haven't done this already 87 | - Create AWS CodeConnections [connection](https://docs.aws.amazon.com/dtconsole/latest/userguide/welcome-connections.html) 88 | for the deployment pipeline 89 | - Authorize AWS CodeBuild access for the pull request build 90 | - Start creating a new project manually 91 | - Select GitHub as Source provider 92 | - Choose **Connect using OAuth** 93 | - Authorize access and cancel the project creation 94 | - Update the `GITHUB_CONNECTION_ARN`, `GITHUB_OWNER`, `GITHUB_REPO`, `GITHUB_TRUNK_BRANCH`, 95 | `TOOLCHAIN_PRODUCTION_ENVIRONMENT`, `SERVICE_PRODUCTION_ENVIRONMENT` constants in [constants.py](constants.py) 96 | - Commit and push the changes: `git commit -a -m 'Source and environments configuration' && git push` 97 | 98 | ```bash 99 | npx cdk deploy UserManagementBackend-Toolchain-Production 100 | ``` 101 | 102 | ## Delete all stacks 103 | **Do not forget to delete the stacks to avoid unexpected charges** 104 | ```bash 105 | npx cdk destroy UserManagementBackend-Toolchain-Production 106 | npx cdk destroy UserManagementBackend-Service-Production 107 | npx cdk destroy UserManagementBackend-Service-Sandbox 108 | ``` 109 | 110 | Delete the AWS CodeConnections connection if it is no longer needed. Follow the instructions 111 | in [Delete a connection](https://docs.aws.amazon.com/dtconsole/latest/userguide/connections-delete.html). 112 | 113 | ## Testing 114 | Below are examples that show the available resources and how to use them. 115 | 116 | ```bash 117 | api_endpoint=$(aws cloudformation describe-stacks \ 118 | --stack-name UserManagementBackend-Service-Sandbox \ 119 | --query 'Stacks[*].Outputs[?OutputKey==`APIEndpoint`].OutputValue' \ 120 | --output text) 121 | 122 | curl \ 123 | -H "Content-Type: application/json" \ 124 | -X POST \ 125 | -d '{"username":"john", "email":"john@example.com"}' \ 126 | "${api_endpoint}/users" 127 | 128 | curl \ 129 | -H "Content-Type: application/json" \ 130 | -X GET \ 131 | "${api_endpoint}/users/john" 132 | 133 | curl \ 134 | -H "Content-Type: application/json" \ 135 | -X PUT \ 136 | -d '{"username":"john", "country":"US", "state":"WA"}' \ 137 | "${api_endpoint}/users" 138 | 139 | curl \ 140 | -H "Content-Type: application/json" \ 141 | -X DELETE \ 142 | "${api_endpoint}/users/john" 143 | ``` 144 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpulver/usermanagement-backend/447e09f389e729901cab21fa47d2f13ea8473eea/architecture.png -------------------------------------------------------------------------------- /cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "acknowledged-issue-numbers": [ 3 | 32775 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "python main.py", 3 | "context": { 4 | "@aws-cdk/aws-iam:minimizePolicies": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import aws_cdk.aws_codebuild as codebuild 4 | import aws_cdk.aws_dynamodb as dynamodb 5 | 6 | 7 | # Use `kw_only=True` to support inheritance with default values. 8 | # See https://stackoverflow.com/a/69822584/1658138 for details. 9 | @dataclass(kw_only=True) 10 | class Environment: 11 | name: str 12 | account: str | None = None 13 | region: str | None = None 14 | 15 | 16 | @dataclass(kw_only=True) 17 | class ServiceEnvironment(Environment): 18 | compute_lambda_reserved_concurrency: int 19 | database_dynamodb_billing_mode: dynamodb.BillingMode 20 | 21 | 22 | APP_NAME = "UserManagementBackend" 23 | APP_DESCRIPTION = "Ongoing project to build realistic application SDLC using AWS CDK" 24 | PYTHON_VERSION = "3.11" 25 | 26 | # pylint: disable=line-too-long 27 | GITHUB_CONNECTION_ARN = "arn:aws:codestar-connections:eu-west-1:807650736403:connection/1f244295-871f-411f-afb1-e6ca987858b6" 28 | GITHUB_OWNER = "alexpulver" 29 | GITHUB_REPO = "usermanagement-backend" 30 | GITHUB_TRUNK_BRANCH = "main" 31 | 32 | CODEBUILD_BUILD_ENVIRONMENT = codebuild.BuildEnvironment( 33 | build_image=codebuild.LinuxBuildImage.AMAZON_LINUX_2_5, 34 | privileged=True, 35 | ) 36 | 37 | TOOLCHAIN_STACK_BASE_NAME = f"{APP_NAME}-Toolchain" 38 | TOOLCHAIN_PRODUCTION_ENVIRONMENT = Environment( 39 | name="Production", account="807650736403", region="eu-west-1" 40 | ) 41 | SERVICE_STACK_BASE_NAME = f"{APP_NAME}-Service" 42 | # The application uses caller's account and Region for the sandbox environment. 43 | SERVICE_SANDBOX_ENVIRONMENT = ServiceEnvironment( 44 | name="Sandbox", 45 | compute_lambda_reserved_concurrency=1, 46 | database_dynamodb_billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, 47 | ) 48 | SERVICE_PRODUCTION_ENVIRONMENT = ServiceEnvironment( 49 | name="Production", 50 | account="807650736403", 51 | region="eu-west-1", 52 | compute_lambda_reserved_concurrency=10, 53 | database_dynamodb_billing_mode=dynamodb.BillingMode.PROVISIONED, 54 | ) 55 | -------------------------------------------------------------------------------- /docs/decisions/0001-record-architecture-decisions.md: -------------------------------------------------------------------------------- 1 | # 1. Record architecture decisions 2 | 3 | Date: 2022-06-12 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | We need to record the architectural decisions made on this project. 12 | 13 | ## Decision 14 | 15 | We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 16 | 17 | ## Consequences 18 | 19 | See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools). 20 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import aws_cdk as cdk 4 | 5 | import constants 6 | from service.service_stack import ServiceStack 7 | from toolchain.toolchain_stack import ToolchainStack 8 | 9 | 10 | def main() -> None: 11 | app = cdk.App() 12 | 13 | ServiceStack( 14 | app, 15 | f"{constants.SERVICE_STACK_BASE_NAME}-{constants.SERVICE_SANDBOX_ENVIRONMENT.name}", 16 | env=cdk.Environment( 17 | account=os.environ["CDK_DEFAULT_ACCOUNT"], 18 | region=os.environ["CDK_DEFAULT_REGION"], 19 | ), 20 | # pylint: disable=line-too-long 21 | compute_lambda_reserved_concurrency=constants.SERVICE_SANDBOX_ENVIRONMENT.compute_lambda_reserved_concurrency, 22 | # pylint: disable=line-too-long 23 | database_dynamodb_billing_mode=constants.SERVICE_SANDBOX_ENVIRONMENT.database_dynamodb_billing_mode, 24 | ) 25 | 26 | ToolchainStack( 27 | app, 28 | f"{constants.TOOLCHAIN_STACK_BASE_NAME}-{constants.TOOLCHAIN_PRODUCTION_ENVIRONMENT.name}", 29 | env=cdk.Environment( 30 | account=constants.TOOLCHAIN_PRODUCTION_ENVIRONMENT.account, 31 | region=constants.TOOLCHAIN_PRODUCTION_ENVIRONMENT.region, 32 | ), 33 | ) 34 | 35 | app.synth() 36 | 37 | 38 | if __name__ == "__main__": 39 | main() 40 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "usermanagement-backend", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "aws-cdk": "2.1000.2" 9 | } 10 | }, 11 | "node_modules/aws-cdk": { 12 | "version": "2.1000.2", 13 | "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1000.2.tgz", 14 | "integrity": "sha512-QsXqJhGWjHNqP7etgE3sHOTiDBXItmSKdFKgsm1qPMBabCMyFfmWZnEeUxfZ4sMaIoxvLpr3sqoWSNeLuUk4sg==", 15 | "dev": true, 16 | "license": "Apache-2.0", 17 | "bin": { 18 | "cdk": "bin/cdk" 19 | }, 20 | "engines": { 21 | "node": ">= 16.0.0" 22 | }, 23 | "optionalDependencies": { 24 | "fsevents": "2.3.2" 25 | } 26 | }, 27 | "node_modules/fsevents": { 28 | "version": "2.3.2", 29 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 30 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 31 | "dev": true, 32 | "hasInstallScript": true, 33 | "optional": true, 34 | "os": [ 35 | "darwin" 36 | ], 37 | "engines": { 38 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 39 | } 40 | } 41 | }, 42 | "dependencies": { 43 | "aws-cdk": { 44 | "version": "2.1000.2", 45 | "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1000.2.tgz", 46 | "integrity": "sha512-QsXqJhGWjHNqP7etgE3sHOTiDBXItmSKdFKgsm1qPMBabCMyFfmWZnEeUxfZ4sMaIoxvLpr3sqoWSNeLuUk4sg==", 47 | "dev": true, 48 | "requires": { 49 | "fsevents": "2.3.2" 50 | } 51 | }, 52 | "fsevents": { 53 | "version": "2.3.2", 54 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 55 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 56 | "dev": true, 57 | "optional": true 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "aws-cdk": "2.1000.2" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | -c service/api/app/requirements.txt 2 | -c requirements.txt 3 | bandit 4 | black 5 | coverage 6 | flake8 7 | isort 8 | mypy 9 | pylint 10 | radon 11 | safety 12 | xenon 13 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.11 3 | # To update, run: 4 | # 5 | # pip-compile requirements-dev.in 6 | # 7 | annotated-types==0.7.0 8 | # via pydantic 9 | astroid==3.3.8 10 | # via pylint 11 | authlib==1.4.1 12 | # via safety 13 | bandit==1.8.3 14 | # via -r requirements-dev.in 15 | black==25.1.0 16 | # via -r requirements-dev.in 17 | certifi==2025.1.31 18 | # via requests 19 | cffi==1.17.1 20 | # via cryptography 21 | charset-normalizer==3.4.1 22 | # via requests 23 | click==8.1.8 24 | # via 25 | # black 26 | # safety 27 | # typer 28 | colorama==0.4.6 29 | # via radon 30 | coverage==7.6.12 31 | # via -r requirements-dev.in 32 | cryptography==44.0.1 33 | # via authlib 34 | dill==0.3.9 35 | # via pylint 36 | dparse==0.6.4 37 | # via 38 | # safety 39 | # safety-schemas 40 | filelock==3.16.1 41 | # via safety 42 | flake8==7.1.2 43 | # via -r requirements-dev.in 44 | idna==3.10 45 | # via requests 46 | isort==6.0.0 47 | # via 48 | # -r requirements-dev.in 49 | # pylint 50 | jinja2==3.1.5 51 | # via safety 52 | levenshtein==0.26.1 53 | # via python-levenshtein 54 | mando==0.7.1 55 | # via radon 56 | markdown-it-py==3.0.0 57 | # via rich 58 | markupsafe==3.0.2 59 | # via jinja2 60 | marshmallow==3.26.1 61 | # via safety 62 | mccabe==0.7.0 63 | # via 64 | # flake8 65 | # pylint 66 | mdurl==0.1.2 67 | # via markdown-it-py 68 | mypy==1.15.0 69 | # via -r requirements-dev.in 70 | mypy-extensions==1.0.0 71 | # via 72 | # black 73 | # mypy 74 | packaging==24.2 75 | # via 76 | # black 77 | # dparse 78 | # marshmallow 79 | # safety 80 | # safety-schemas 81 | pathspec==0.12.1 82 | # via black 83 | pbr==6.1.1 84 | # via stevedore 85 | platformdirs==4.3.6 86 | # via 87 | # black 88 | # pylint 89 | psutil==6.1.1 90 | # via safety 91 | pycodestyle==2.12.1 92 | # via flake8 93 | pycparser==2.22 94 | # via cffi 95 | pydantic==2.9.2 96 | # via 97 | # safety 98 | # safety-schemas 99 | pydantic-core==2.23.4 100 | # via pydantic 101 | pyflakes==3.2.0 102 | # via flake8 103 | pygments==2.19.1 104 | # via rich 105 | pylint==3.3.4 106 | # via -r requirements-dev.in 107 | python-levenshtein==0.26.1 108 | # via safety 109 | pyyaml==6.0.2 110 | # via 111 | # bandit 112 | # xenon 113 | radon==6.0.1 114 | # via 115 | # -r requirements-dev.in 116 | # xenon 117 | rapidfuzz==3.12.1 118 | # via levenshtein 119 | requests==2.32.3 120 | # via 121 | # safety 122 | # xenon 123 | rich==13.9.4 124 | # via 125 | # bandit 126 | # typer 127 | ruamel-yaml==0.18.10 128 | # via 129 | # safety 130 | # safety-schemas 131 | ruamel.yaml.clib==0.2.12 132 | # via ruamel-yaml 133 | safety==3.3.0 134 | # via -r requirements-dev.in 135 | safety-schemas==0.0.10 136 | # via safety 137 | shellingham==1.5.4 138 | # via typer 139 | six==1.17.0 140 | # via 141 | # -c requirements.txt 142 | # -c service/api/app/requirements.txt 143 | # mando 144 | stevedore==5.4.0 145 | # via bandit 146 | tomlkit==0.13.2 147 | # via pylint 148 | typer==0.15.1 149 | # via safety 150 | typing-extensions==4.12.2 151 | # via 152 | # -c requirements.txt 153 | # -c service/api/app/requirements.txt 154 | # mypy 155 | # pydantic 156 | # pydantic-core 157 | # safety 158 | # safety-schemas 159 | # typer 160 | urllib3==2.3.0 161 | # via 162 | # -c service/api/app/requirements.txt 163 | # requests 164 | xenon==0.9.3 165 | # via -r requirements-dev.in 166 | 167 | # The following packages are considered to be unsafe in a requirements file: 168 | # setuptools 169 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | aws-cdk-lib 2 | aws-cdk.aws-lambda-python-alpha 3 | constructs 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.11 3 | # To update, run: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | attrs==24.3.0 8 | # via 9 | # cattrs 10 | # jsii 11 | aws-cdk-lib==2.179.0 12 | # via 13 | # -r requirements.in 14 | # aws-cdk.aws-lambda-python-alpha 15 | aws-cdk.asset-awscli-v1==2.2.224 16 | # via aws-cdk-lib 17 | aws-cdk.asset-node-proxy-agent-v6==2.1.0 18 | # via aws-cdk-lib 19 | aws-cdk.aws-lambda-python-alpha==2.179.0a0 20 | # via -r requirements.in 21 | aws-cdk.cloud-assembly-schema==39.2.20 22 | # via aws-cdk-lib 23 | cattrs==24.1.2 24 | # via jsii 25 | constructs==10.4.2 26 | # via 27 | # -r requirements.in 28 | # aws-cdk-lib 29 | # aws-cdk.aws-lambda-python-alpha 30 | importlib-resources==6.5.2 31 | # via jsii 32 | jsii==1.106.0 33 | # via 34 | # aws-cdk-lib 35 | # aws-cdk.asset-awscli-v1 36 | # aws-cdk.asset-node-proxy-agent-v6 37 | # aws-cdk.aws-lambda-python-alpha 38 | # aws-cdk.cloud-assembly-schema 39 | # constructs 40 | publication==0.0.3 41 | # via 42 | # aws-cdk-lib 43 | # aws-cdk.asset-awscli-v1 44 | # aws-cdk.asset-node-proxy-agent-v6 45 | # aws-cdk.aws-lambda-python-alpha 46 | # aws-cdk.cloud-assembly-schema 47 | # constructs 48 | # jsii 49 | python-dateutil==2.9.0.post0 50 | # via jsii 51 | six==1.17.0 52 | # via python-dateutil 53 | typeguard==2.13.3 54 | # via 55 | # aws-cdk-lib 56 | # aws-cdk.asset-awscli-v1 57 | # aws-cdk.asset-node-proxy-agent-v6 58 | # aws-cdk.aws-lambda-python-alpha 59 | # aws-cdk.cloud-assembly-schema 60 | # constructs 61 | # jsii 62 | typing-extensions==4.12.2 63 | # via jsii 64 | -------------------------------------------------------------------------------- /service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpulver/usermanagement-backend/447e09f389e729901cab21fa47d2f13ea8473eea/service/__init__.py -------------------------------------------------------------------------------- /service/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpulver/usermanagement-backend/447e09f389e729901cab21fa47d2f13ea8473eea/service/api/__init__.py -------------------------------------------------------------------------------- /service/api/app/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import repository 4 | 5 | 6 | def init_users_repository() -> repository.UsersRepository: 7 | dynamodb_repository = repository.DynamoDBDatabase(os.environ["DYNAMODB_TABLE_NAME"]) 8 | users_repository = repository.UsersRepository(database=dynamodb_repository) 9 | return users_repository 10 | -------------------------------------------------------------------------------- /service/api/app/main.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from aws_lambda_powertools import Tracer 4 | from aws_lambda_powertools.event_handler import api_gateway 5 | from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext 6 | 7 | from helpers import init_users_repository 8 | 9 | app = api_gateway.ApiGatewayResolver( 10 | proxy_type=api_gateway.ProxyEventType.APIGatewayProxyEventV2 11 | ) 12 | tracer = Tracer() 13 | 14 | 15 | @tracer.capture_lambda_handler 16 | def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]: 17 | return app.resolve(event, context) 18 | 19 | 20 | @app.post("/users") 21 | def create_user() -> dict[str, Any]: 22 | user_attributes = app.current_event.json_body 23 | users_repository = init_users_repository() 24 | return users_repository.create_user(user_attributes["username"], user_attributes) 25 | 26 | 27 | @app.put("/users") 28 | def update_user() -> dict[str, Any]: 29 | user_attributes = app.current_event.json_body 30 | username = user_attributes["username"] 31 | del user_attributes["username"] 32 | users_repository = init_users_repository() 33 | return users_repository.update_user(username, user_attributes) 34 | 35 | 36 | @app.get("/users/") 37 | def get_user(username: str) -> Optional[dict[str, Any]]: 38 | users_repository = init_users_repository() 39 | return users_repository.get_user(username) 40 | 41 | 42 | @app.delete("/users/") 43 | def delete_user(username: str) -> dict[str, Any]: 44 | users_repository = init_users_repository() 45 | users_repository.delete_user(username) 46 | return {"username": username} 47 | -------------------------------------------------------------------------------- /service/api/app/repository.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Optional 3 | 4 | import boto3 5 | 6 | 7 | class DatabaseInterface(abc.ABC): 8 | @abc.abstractmethod 9 | def create_user( 10 | self, username: str, user_attributes: dict[str, str] 11 | ) -> dict[str, Any]: 12 | pass 13 | 14 | @abc.abstractmethod 15 | def update_user( 16 | self, username: str, user_attributes: dict[str, str] 17 | ) -> dict[str, Any]: 18 | pass 19 | 20 | @abc.abstractmethod 21 | def get_user(self, username: str) -> Optional[dict[str, Any]]: 22 | pass 23 | 24 | @abc.abstractmethod 25 | def delete_user(self, username: str) -> None: 26 | pass 27 | 28 | 29 | class UsersRepository: 30 | def __init__(self, *, database: DatabaseInterface): 31 | self._database = database 32 | 33 | def create_user( 34 | self, username: str, user_attributes: dict[str, str] 35 | ) -> dict[str, Any]: 36 | return self._database.create_user(username, user_attributes) 37 | 38 | def update_user( 39 | self, username: str, user_attributes: dict[str, str] 40 | ) -> dict[str, Any]: 41 | return self._database.update_user(username, user_attributes) 42 | 43 | def get_user(self, username: str) -> Optional[dict[str, Any]]: 44 | return self._database.get_user(username) 45 | 46 | def delete_user(self, username: str) -> None: 47 | self._database.delete_user(username) 48 | 49 | 50 | class DynamoDBDatabase(DatabaseInterface): 51 | _dynamodb = boto3.resource("dynamodb") 52 | 53 | def __init__(self, table_name: str): 54 | super().__init__() 55 | self._table = DynamoDBDatabase._dynamodb.Table(table_name) 56 | 57 | def create_user( 58 | self, username: str, user_attributes: dict[str, str] 59 | ) -> dict[str, Any]: 60 | user = {"username": username} 61 | user.update(user_attributes) 62 | self._table.put_item(Item=user) 63 | return user 64 | 65 | def update_user( 66 | self, username: str, user_attributes: dict[str, str] 67 | ) -> dict[str, Any]: 68 | update_expression_pairs = [f"#{key} = :{key}" for key in user_attributes] 69 | update_expression = "SET " + ", ".join(update_expression_pairs) 70 | expression_attribute_names = {f"#{key}": key for key in user_attributes} 71 | expression_attribute_values = { 72 | f":{key}": value for key, value in user_attributes.items() 73 | } 74 | updated_item: dict[str, dict[str, Any]] = self._table.update_item( 75 | Key={"username": username}, 76 | UpdateExpression=update_expression, 77 | ExpressionAttributeNames=expression_attribute_names, 78 | ExpressionAttributeValues=expression_attribute_values, 79 | ReturnValues="ALL_NEW", 80 | ) 81 | return updated_item["Attributes"] 82 | 83 | def get_user(self, username: str) -> Optional[dict[str, Any]]: 84 | response = self._table.get_item(Key={"username": username}) 85 | return response["Item"] if "Item" in response else None 86 | 87 | def delete_user(self, username: str) -> None: 88 | self._table.delete_item(Key={"username": username}) 89 | -------------------------------------------------------------------------------- /service/api/app/requirements.in: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools[tracer] 2 | boto3 3 | boto3-stubs 4 | -------------------------------------------------------------------------------- /service/api/app/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.11 3 | # To update, run: 4 | # 5 | # pip-compile service/api/app/requirements.in 6 | # 7 | aws-lambda-powertools[tracer]==3.6.0 8 | # via -r service/api/app/requirements.in 9 | aws-xray-sdk==2.14.0 10 | # via aws-lambda-powertools 11 | boto3==1.36.23 12 | # via -r service/api/app/requirements.in 13 | boto3-stubs==1.36.23 14 | # via -r service/api/app/requirements.in 15 | botocore==1.36.23 16 | # via 17 | # aws-xray-sdk 18 | # boto3 19 | # s3transfer 20 | botocore-stubs==1.36.23 21 | # via boto3-stubs 22 | jmespath==1.0.1 23 | # via 24 | # aws-lambda-powertools 25 | # boto3 26 | # botocore 27 | python-dateutil==2.9.0.post0 28 | # via botocore 29 | s3transfer==0.11.2 30 | # via boto3 31 | six==1.17.0 32 | # via python-dateutil 33 | types-awscrt==0.23.10 34 | # via botocore-stubs 35 | types-s3transfer==0.11.2 36 | # via boto3-stubs 37 | typing-extensions==4.12.2 38 | # via 39 | # aws-lambda-powertools 40 | # boto3-stubs 41 | urllib3==2.3.0 42 | # via botocore 43 | wrapt==1.17.2 44 | # via aws-xray-sdk 45 | -------------------------------------------------------------------------------- /service/api/app/tests/test_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from typing import cast 4 | from unittest import mock 5 | 6 | from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext 7 | 8 | import main 9 | 10 | 11 | class CRUDTestCase(unittest.TestCase): 12 | @mock.patch.dict("helpers.os.environ", {"DYNAMODB_TABLE_NAME": "Table"}) 13 | @mock.patch("repository.DynamoDBDatabase.get_user") 14 | def test_get_user_exists(self, mock_get_user: mock.Mock) -> None: 15 | username = "john" 16 | user = {"username": username, "email": f"{username}@example.com"} 17 | mock_get_user.return_value = user 18 | apigatewayv2_proxy_event = { 19 | "rawPath": f"/users/{username}", 20 | "requestContext": { 21 | "http": { 22 | "method": "GET", 23 | "path": f"/users/{username}", 24 | }, 25 | "stage": "$default", 26 | }, 27 | } 28 | response = main.lambda_handler( 29 | apigatewayv2_proxy_event, cast(LambdaContext, {}) 30 | ) 31 | self.assertEqual(json.loads(response["body"]), user) 32 | 33 | 34 | if __name__ == "__main__": 35 | unittest.main() 36 | -------------------------------------------------------------------------------- /service/api/compute.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from typing import cast 3 | 4 | import aws_cdk.aws_iam as iam 5 | import aws_cdk.aws_lambda as lambda_ 6 | import aws_cdk.aws_lambda_python_alpha as lambda_python_alpha 7 | from constructs import Construct 8 | 9 | import constants 10 | 11 | 12 | class Compute(Construct): 13 | def __init__( 14 | self, 15 | scope: Construct, 16 | id_: str, 17 | *, 18 | lambda_reserved_concurrency: int, 19 | dynamodb_table_name: str, 20 | ): 21 | super().__init__(scope, id_) 22 | 23 | self.lambda_function = lambda_python_alpha.PythonFunction( 24 | self, 25 | "LambdaFunction", 26 | entry=str(pathlib.Path(__file__).parent.joinpath("app").resolve()), 27 | environment={"DYNAMODB_TABLE_NAME": dynamodb_table_name}, 28 | handler="lambda_handler", 29 | index="main.py", 30 | reserved_concurrent_executions=lambda_reserved_concurrency, 31 | runtime=lambda_.Runtime( 32 | f"python{constants.PYTHON_VERSION}", family=lambda_.RuntimeFamily.PYTHON 33 | ), 34 | tracing=lambda_.Tracing.ACTIVE, 35 | ) 36 | # PythonFunction creates the IAM role automatically. 37 | lambda_function_role = cast(iam.Role, self.lambda_function.role) 38 | lambda_function_role.add_managed_policy( 39 | iam.ManagedPolicy.from_aws_managed_policy_name("AWSXRayDaemonWriteAccess") 40 | ) 41 | -------------------------------------------------------------------------------- /service/database.py: -------------------------------------------------------------------------------- 1 | import aws_cdk as cdk 2 | import aws_cdk.aws_dynamodb as dynamodb 3 | from constructs import Construct 4 | 5 | 6 | class Database(Construct): 7 | def __init__( 8 | self, scope: Construct, id_: str, *, dynamodb_billing_mode: dynamodb.BillingMode 9 | ): 10 | super().__init__(scope, id_) 11 | 12 | partition_key = dynamodb.Attribute( 13 | name="username", type=dynamodb.AttributeType.STRING 14 | ) 15 | self.dynamodb_table = dynamodb.Table( 16 | self, 17 | "DynamoDBTable", 18 | billing_mode=dynamodb_billing_mode, 19 | partition_key=partition_key, 20 | removal_policy=cdk.RemovalPolicy.DESTROY, 21 | ) 22 | -------------------------------------------------------------------------------- /service/ingress.py: -------------------------------------------------------------------------------- 1 | import aws_cdk.aws_apigatewayv2 as apigatewayv2 2 | import aws_cdk.aws_apigatewayv2_integrations as apigatewayv2_integrations 3 | import aws_cdk.aws_lambda_python_alpha as lambda_python_alpha 4 | from constructs import Construct 5 | 6 | 7 | class Ingress(Construct): 8 | def __init__( 9 | self, 10 | scope: Construct, 11 | id_: str, 12 | *, 13 | lambda_function: lambda_python_alpha.PythonFunction, 14 | ): 15 | super().__init__(scope, id_) 16 | 17 | api_gateway_http_lambda_integration = ( 18 | apigatewayv2_integrations.HttpLambdaIntegration( 19 | "APIGatewayIntegration", handler=lambda_function 20 | ) 21 | ) 22 | self.api_gateway_http_api = apigatewayv2.HttpApi( 23 | self, 24 | "APIGatewayHTTPAPI", 25 | default_integration=api_gateway_http_lambda_integration, 26 | ) 27 | -------------------------------------------------------------------------------- /service/monitoring.py: -------------------------------------------------------------------------------- 1 | import aws_cdk.aws_cloudwatch as cloudwatch 2 | from constructs import Construct 3 | 4 | from service.database import Database 5 | from service.ingress import Ingress 6 | 7 | 8 | class Monitoring(Construct): 9 | def __init__( 10 | self, 11 | scope: Construct, 12 | id_: str, 13 | *, 14 | database: Database, 15 | network: Ingress, 16 | ) -> None: 17 | super().__init__(scope, id_) 18 | widgets = [ 19 | cloudwatch.SingleValueWidget( 20 | metrics=[network.api_gateway_http_api.metric_count()] 21 | ), 22 | cloudwatch.SingleValueWidget( 23 | metrics=[database.dynamodb_table.metric_consumed_read_capacity_units()] 24 | ), 25 | ] 26 | cloudwatch.Dashboard(self, "CloudWatchDashboard", widgets=[widgets]) 27 | -------------------------------------------------------------------------------- /service/service_stack.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import aws_cdk as cdk 4 | import aws_cdk.aws_dynamodb as dynamodb 5 | from constructs import Construct 6 | 7 | from service.api.compute import Compute 8 | from service.database import Database 9 | from service.ingress import Ingress 10 | from service.monitoring import Monitoring 11 | 12 | 13 | class ServiceStack(cdk.Stack): 14 | def __init__( 15 | self, 16 | scope: Construct, 17 | id_: str, 18 | *, 19 | compute_lambda_reserved_concurrency: int, 20 | database_dynamodb_billing_mode: dynamodb.BillingMode, 21 | **kwargs: Any 22 | ): 23 | super().__init__(scope, id_, **kwargs) 24 | 25 | database = Database( 26 | self, 27 | "Database", 28 | dynamodb_billing_mode=database_dynamodb_billing_mode, 29 | ) 30 | compute = Compute( 31 | self, 32 | "Compute", 33 | lambda_reserved_concurrency=compute_lambda_reserved_concurrency, 34 | dynamodb_table_name=database.dynamodb_table.table_name, 35 | ) 36 | ingress = Ingress(self, "Ingress", lambda_function=compute.lambda_function) 37 | Monitoring(self, "Monitoring", database=database, network=ingress) 38 | 39 | database.dynamodb_table.grant_read_write_data(compute.lambda_function) 40 | 41 | self.api_endpoint = cdk.CfnOutput( 42 | self, 43 | "APIEndpoint", 44 | # API Gateway doesn't disable create_default_stage, hence URL is defined 45 | value=ingress.api_gateway_http_api.url, # type: ignore 46 | ) 47 | -------------------------------------------------------------------------------- /tests/test_compute.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import aws_cdk as cdk 4 | import aws_cdk.aws_dynamodb as dynamodb 5 | from aws_cdk import assertions 6 | 7 | from service.api.compute import Compute 8 | from service.database import Database 9 | 10 | 11 | class LambdaTestCase(unittest.TestCase): 12 | def test_bundling(self) -> None: 13 | stack = cdk.Stack() 14 | database = Database( 15 | stack, 16 | "Database", 17 | dynamodb_billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, 18 | ) 19 | Compute( 20 | stack, 21 | "Compute", 22 | dynamodb_table_name=database.dynamodb_table.table_name, 23 | lambda_reserved_concurrency=1, 24 | ) 25 | template = assertions.Template.from_stack(stack).to_json() 26 | resources = template["Resources"] 27 | lambda_function_logical_id = "ComputeLambdaFunctionB5F83B01" 28 | self.assertIn(lambda_function_logical_id, resources) 29 | lambda_function_resource = resources[lambda_function_logical_id] 30 | lambda_function_code = lambda_function_resource["Properties"]["Code"] 31 | self.assertIn("S3Bucket", lambda_function_code) 32 | self.assertIn("S3Key", lambda_function_code) 33 | 34 | 35 | if __name__ == "__main__": 36 | unittest.main() 37 | -------------------------------------------------------------------------------- /toolchain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpulver/usermanagement-backend/447e09f389e729901cab21fa47d2f13ea8473eea/toolchain/__init__.py -------------------------------------------------------------------------------- /toolchain/config/.adr-dir: -------------------------------------------------------------------------------- 1 | docs/decisions 2 | -------------------------------------------------------------------------------- /toolchain/config/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = C,E,F,W,B,B950 4 | extend-ignore = E203, E501 5 | -------------------------------------------------------------------------------- /toolchain/config/.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | force_single_line = True 4 | single_line_exclusions = typing 5 | -------------------------------------------------------------------------------- /toolchain/config/.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | -------------------------------------------------------------------------------- /toolchain/config/.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = C0111 3 | -------------------------------------------------------------------------------- /toolchain/deployment_pipeline.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | 4 | import aws_cdk as cdk 5 | import aws_cdk.aws_codebuild as codebuild 6 | from aws_cdk import pipelines 7 | from constructs import Construct 8 | 9 | import constants 10 | from service.service_stack import ServiceStack 11 | 12 | 13 | class DeploymentPipeline(Construct): 14 | def __init__( 15 | self, 16 | scope: Construct, 17 | id_: str, 18 | *, 19 | build_spec: codebuild.BuildSpec, 20 | ): 21 | super().__init__(scope, id_) 22 | 23 | source = pipelines.CodePipelineSource.connection( 24 | constants.GITHUB_OWNER + "/" + constants.GITHUB_REPO, 25 | constants.GITHUB_TRUNK_BRANCH, 26 | connection_arn=constants.GITHUB_CONNECTION_ARN, 27 | ) 28 | synth = pipelines.CodeBuildStep( 29 | "Synth", 30 | input=source, 31 | partial_build_spec=build_spec, 32 | # The build_spec argument includes build and synth commands. 33 | commands=[], 34 | primary_output_directory="cdk.out", 35 | ) 36 | pipeline = pipelines.CodePipeline( 37 | self, 38 | "Pipeline", 39 | code_build_defaults=pipelines.CodeBuildOptions( 40 | build_environment=constants.CODEBUILD_BUILD_ENVIRONMENT, 41 | ), 42 | cli_version=DeploymentPipeline._get_cdk_cli_version(), 43 | cross_account_keys=True, 44 | publish_assets_in_parallel=False, 45 | synth=synth, 46 | ) 47 | DeploymentPipeline._add_production_stage(pipeline) 48 | 49 | @staticmethod 50 | def _get_cdk_cli_version() -> str: 51 | package_json_path = ( 52 | pathlib.Path(__file__).parent.parent.joinpath("package.json").resolve() 53 | ) 54 | with open(package_json_path, encoding="utf_8") as package_json_file: 55 | package_json = json.load(package_json_file) 56 | cdk_cli_version = str(package_json["devDependencies"]["aws-cdk"]) 57 | return cdk_cli_version 58 | 59 | @staticmethod 60 | def _add_production_stage(pipeline: pipelines.CodePipeline) -> None: 61 | production_environment = constants.SERVICE_PRODUCTION_ENVIRONMENT 62 | stage = cdk.Stage( 63 | pipeline, 64 | production_environment.name, 65 | env=cdk.Environment( 66 | account=production_environment.account, 67 | region=production_environment.region, 68 | ), 69 | ) 70 | service_stack = DeploymentPipeline._create_service_stack( 71 | stage, production_environment 72 | ) 73 | smoke_test = DeploymentPipeline._create_smoke_test(service_stack) 74 | pipeline.add_stage(stage, post=[smoke_test]) 75 | 76 | @staticmethod 77 | def _create_service_stack( 78 | stage: cdk.Stage, service_environment: constants.ServiceEnvironment 79 | ) -> ServiceStack: 80 | service_stack = ServiceStack( 81 | stage, 82 | f"{constants.SERVICE_STACK_BASE_NAME}-{service_environment.name}", 83 | stack_name=f"{constants.SERVICE_STACK_BASE_NAME}-{service_environment.name}", 84 | # pylint: disable=line-too-long 85 | compute_lambda_reserved_concurrency=service_environment.compute_lambda_reserved_concurrency, 86 | database_dynamodb_billing_mode=service_environment.database_dynamodb_billing_mode, 87 | ) 88 | return service_stack 89 | 90 | @staticmethod 91 | def _create_smoke_test(service_stack: ServiceStack) -> pipelines.ShellStep: 92 | api_endpoint_env_var_name = constants.APP_NAME.upper() + "_API_ENDPOINT" 93 | smoke_test_commands = [f"curl ${api_endpoint_env_var_name}"] 94 | smoke_test = pipelines.ShellStep( 95 | "SmokeTest", 96 | env_from_cfn_outputs={ 97 | api_endpoint_env_var_name: service_stack.api_endpoint 98 | }, 99 | commands=smoke_test_commands, 100 | ) 101 | return smoke_test 102 | -------------------------------------------------------------------------------- /toolchain/pull_request_build.py: -------------------------------------------------------------------------------- 1 | from aws_cdk import aws_codebuild as codebuild 2 | from constructs import Construct 3 | 4 | import constants 5 | 6 | 7 | class PullRequestBuild(Construct): 8 | def __init__(self, scope: Construct, id_: str, *, build_spec: codebuild.BuildSpec): 9 | super().__init__(scope, id_) 10 | 11 | webhook_filters = [ 12 | codebuild.FilterGroup.in_event_of( 13 | codebuild.EventAction.PULL_REQUEST_CREATED 14 | ).and_base_branch_is(constants.GITHUB_TRUNK_BRANCH), 15 | codebuild.FilterGroup.in_event_of( 16 | codebuild.EventAction.PULL_REQUEST_UPDATED 17 | ).and_base_branch_is(constants.GITHUB_TRUNK_BRANCH), 18 | ] 19 | source = codebuild.Source.git_hub( 20 | owner=constants.GITHUB_OWNER, 21 | repo=constants.GITHUB_REPO, 22 | webhook_filters=webhook_filters, 23 | ) 24 | codebuild.Project( 25 | self, 26 | "CodeBuildProject", 27 | source=source, 28 | build_spec=build_spec, 29 | environment=constants.CODEBUILD_BUILD_ENVIRONMENT, 30 | ) 31 | -------------------------------------------------------------------------------- /toolchain/scripts/install-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o verbose 5 | 6 | # Install AWS CDK CLI locally 7 | npm install 8 | 9 | # Install project dependencies 10 | pip install -r service/api/app/requirements.txt -r requirements.txt -r requirements-dev.txt 11 | -------------------------------------------------------------------------------- /toolchain/scripts/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o verbose 5 | 6 | targets=(service tests toolchain constants.py main.py) 7 | 8 | # Find common security issues (https://bandit.readthedocs.io) 9 | bandit --recursive "${targets[@]}" 10 | 11 | # Python code formatter (https://black.readthedocs.io) 12 | black --check --diff "${targets[@]}" 13 | 14 | # Style guide enforcement (https://flake8.pycqa.org) 15 | flake8 --config toolchain/config/.flake8 "${targets[@]}" 16 | 17 | # Sort imports (https://pycqa.github.io/isort) 18 | isort --src . --src service/api/app --settings-path toolchain/config/.isort.cfg --check --diff "${targets[@]}" 19 | 20 | # Report code complexity (https://radon.readthedocs.io) 21 | radon mi "${targets[@]}" 22 | 23 | # Exit with non-zero status if code complexity exceeds thresholds (https://xenon.readthedocs.io) 24 | xenon --max-absolute A --max-modules A --max-average A "${targets[@]}" 25 | 26 | # Check dependencies for security issues (https://pyup.io/safety) 27 | # See https://data.safetycli.com/v/70612/97c/ for 70612 ignore reason. 28 | safety check -i 70612 -r service/api/app/requirements.txt -r requirements.txt -r requirements-dev.txt 29 | 30 | # Static type checker (https://mypy.readthedocs.io) 31 | MYPYPATH="${PWD}" mypy --config-file toolchain/config/.mypy.ini --exclude service/api/app "${targets[@]}" 32 | MYPYPATH="${PWD}/service/api/app" mypy --config-file toolchain/config/.mypy.ini --explicit-package-bases service/api/app 33 | 34 | # Check for errors, enforce a coding standard, look for code smells (http://pylint.pycqa.org) 35 | PYTHONPATH="${PWD}" pylint --rcfile toolchain/config/.pylintrc --ignore service/api/app "${targets[@]}" 36 | PYTHONPATH="${PWD}/service/api/app" pylint --rcfile toolchain/config/.pylintrc service/api/app 37 | 38 | # Run tests and measure code coverage (https://coverage.readthedocs.io) 39 | coverage run -m unittest discover -s tests 40 | (cd service/api/app; coverage run -m unittest discover -s tests) 41 | -------------------------------------------------------------------------------- /toolchain/toolchain_stack.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import aws_cdk as cdk 4 | import aws_cdk.aws_codebuild as codebuild 5 | from constructs import Construct 6 | 7 | import constants 8 | from toolchain.deployment_pipeline import DeploymentPipeline 9 | from toolchain.pull_request_build import PullRequestBuild 10 | 11 | 12 | class ToolchainStack(cdk.Stack): 13 | def __init__( 14 | self, 15 | scope: Construct, 16 | id_: str, 17 | **kwargs: Any, 18 | ): 19 | super().__init__(scope, id_, **kwargs) 20 | 21 | build_spec = codebuild.BuildSpec.from_object( 22 | { 23 | "phases": { 24 | "install": { 25 | "runtime-versions": {"python": constants.PYTHON_VERSION}, 26 | "commands": ["env", "toolchain/scripts/install-deps.sh"], 27 | }, 28 | "build": { 29 | "commands": ["toolchain/scripts/run-tests.sh", "npx cdk synth"] 30 | }, 31 | }, 32 | "version": "0.2", 33 | } 34 | ) 35 | DeploymentPipeline( 36 | self, 37 | "DeploymentPipeline", 38 | build_spec=build_spec, 39 | ) 40 | PullRequestBuild(self, "PullRequestBuild", build_spec=build_spec) 41 | --------------------------------------------------------------------------------