├── .coveragerc ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── NOTICE ├── Pipfile ├── Pipfile.lock ├── README.md ├── codecov.yml ├── jest.config.cjs ├── package-lock.json ├── package.json ├── python └── rpdk │ └── typescript │ ├── __init__.py │ ├── codegen.py │ ├── data │ ├── .npmrc │ ├── __init__.py │ ├── create.json │ ├── tsconfig.json │ └── typescript.gitignore │ ├── parser.py │ ├── resolver.py │ ├── templates │ ├── Makefile │ ├── README.md │ ├── handlers.ts │ ├── models.ts │ ├── package.json │ └── template.yml │ └── utils.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── src ├── exceptions.ts ├── index.ts ├── interface.ts ├── log-delivery.ts ├── metrics.ts ├── proxy.ts ├── recast.ts ├── resource.ts └── utils.ts ├── tests ├── __init__.py ├── data │ └── sample-model.ts ├── lib │ ├── exceptions.test.ts │ ├── interface.test.ts │ ├── log-delivery.test.ts │ ├── metrics.test.ts │ ├── proxy.test.ts │ ├── recast.test.ts │ ├── resource.test.ts │ └── utils.test.ts ├── plugin │ ├── __init__.py │ ├── codegen_test.py │ ├── parser_test.py │ ├── resolver_test.py │ └── utils_test.py └── tsconfig.json ├── tsconfig.eslint.json ├── tsconfig.json └── tsconfig.test.json /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | fail_under = 90 6 | show_missing = True 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | target/ 4 | .idea/ 5 | .vscode/ 6 | node_modules/ 7 | coverage/ 8 | python/ 9 | .eslintrc.js 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true, 4 | node: true, 5 | }, 6 | plugins: [ 7 | '@typescript-eslint', 8 | 'prettier', 9 | 'import', 10 | 'prefer-arrow' 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | project: './tsconfig.eslint.json', 17 | }, 18 | extends: [ 19 | 'plugin:import/typescript', 20 | 'plugin:@typescript-eslint/recommended', 21 | 'plugin:prettier/recommended', 22 | ], 23 | settings: { 24 | 'import/parsers': { 25 | '@typescript-eslint/parser': ['.ts', '.tsx'], 26 | }, 27 | 'import/resolver': { 28 | node: {}, 29 | typescript: {}, 30 | }, 31 | }, 32 | ignorePatterns: ['*.d.ts', '*.generated.ts'], 33 | rules: { 34 | // Require use of the `import { foo } from 'bar';` form instead of `import foo = require('bar');` 35 | '@typescript-eslint/no-require-imports': ['error'], 36 | 37 | '@typescript-eslint/ban-ts-comment': ['warn'], 38 | '@typescript-eslint/ban-types': ['warn'], 39 | '@typescript-eslint/no-empty-function': ['warn'], 40 | '@typescript-eslint/no-explicit-any': ['warn'], 41 | 42 | '@typescript-eslint/no-unused-vars': ['warn'], 43 | '@typescript-eslint/no-loss-of-precision': ['warn'], 44 | 45 | // Require all imported dependencies are actually declared in package.json 46 | 'import/no-extraneous-dependencies': ['error'], 47 | 'import/no-unresolved': ['error'], 48 | }, 49 | overrides: [ 50 | { 51 | files: ['*.js', '*.jsx', '*.cjs'], 52 | rules: { 53 | '@typescript-eslint/explicit-function-return-type': 'off', 54 | '@typescript-eslint/no-unused-vars': 'off', 55 | '@typescript-eslint/no-var-requires': 'off', 56 | '@typescript-eslint/no-require-imports': 'off', 57 | }, 58 | }, 59 | ], 60 | }; 61 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text eol=lf 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 6 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Continuous Delivery (Release) 3 | name: cd 4 | 5 | on: 6 | push: 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | delivery-nodejs: 12 | name: Prepare for NPM 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 20 19 | registry-url: https://registry.npmjs.org/ 20 | - name: Get Version from Git Tag 21 | id: tag_name 22 | run: | 23 | echo "VERSION=$(echo ${GITHUB_REF:11})" >> $GITHUB_ENV 24 | - name: Install Dependencies and Package Project 25 | id: installing 26 | run: | 27 | npm ci --optional 28 | npm pack 29 | - name: Upload NPM Artifacts 30 | id: upload_npm 31 | uses: actions/upload-artifact@v3 32 | with: 33 | name: package-npm 34 | path: cfn-rpdk-${{ env.VERSION }}.tgz 35 | 36 | delivery-python: 37 | name: Prepare for PyPI 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | python: [3.8] 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Setup Python ${{ matrix.python }} 45 | uses: actions/setup-python@v4 46 | with: 47 | python-version: ${{ matrix.python }} 48 | - name: Install Dependencies and Package Project 49 | id: installing 50 | run: | 51 | python -m pip install --upgrade pip setuptools wheel 52 | python3 setup.py sdist bdist_wheel 53 | - uses: actions/upload-artifact@v3 54 | with: 55 | name: dist-py${{ matrix.python }} 56 | path: dist 57 | 58 | delivery-github: 59 | name: Delivery to GitHub 60 | needs: [delivery-nodejs, delivery-python] 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v3 64 | - name: Download NPM Artifacts 65 | uses: actions/download-artifact@v3 66 | with: 67 | name: package-npm 68 | - name: Download Python 3.8 Artifacts 69 | uses: actions/download-artifact@v3 70 | with: 71 | name: dist-py3.8 72 | path: dist/ 73 | - name: List Artifacts 74 | run: | 75 | echo 'ARTIFACTS="$(echo package-npm/* && echo dist/*)"' >> $GITHUB_ENV 76 | - name: GitHub Release 77 | id: releasing 78 | uses: docker://antonyurchenko/git-release:v3.4.2 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | DRAFT_RELEASE: 'false' 82 | PRE_RELEASE: 'true' 83 | CHANGELOG_FILE: 'CHANGELOG.md' 84 | ALLOW_EMPTY_CHANGELOG: 'false' 85 | ALLOW_TAG_PREFIX: 'true' 86 | with: 87 | args: | 88 | ${{ env.ARTIFACTS }} 89 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Continous Integration 3 | name: ci 4 | 5 | on: [ push, pull_request ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/setup-python@v5 12 | with: 13 | python-version: 3.9 14 | os_build: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu-latest 20 | - macos-12 # Later versions of ARM-based macOS runners fail because the hypervisor framework required for Docker is not supported 21 | python: [ "3.8", "3.9", "3.10", "3.11"] 22 | node: [ 20 ] 23 | env: 24 | SAM_CLI_TELEMETRY: "0" 25 | AWS_REGION: "us-east-1" 26 | AWS_DEFAULT_REGION: "us-east-1" 27 | AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE" 28 | AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" 29 | LOG_PATH: /tmp/debug-logs 30 | PIP_LOG_FILE: /tmp/pip.log 31 | HOMEBREW_NO_AUTO_UPDATE: 1 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Update Homebrew and save docker version 35 | if: runner.os == 'macOS' 36 | run: | 37 | brew tap homebrew/core 38 | cat "$(brew --repository)/Library/Taps/homebrew/homebrew-core/Formula/d/docker.rb" > .github/brew-formulae 39 | - name: Configure Homebrew docker cache files 40 | uses: actions/cache@v3 41 | if: runner.os == 'macOS' 42 | with: 43 | path: | 44 | ~/Library/Caches/Homebrew/docker--* 45 | ~/Library/Caches/Homebrew/downloads/*--docker-* 46 | key: brew-${{ hashFiles('.github/brew-formulae') }} 47 | restore-keys: brew- 48 | - name: Install Docker if on MacOS and start colima 49 | id: install_mac_docker 50 | if: runner.os == 'macOS' 51 | run: | 52 | brew install docker --cask 53 | brew install colima 54 | # Docker engine is no longer available because of licensing 55 | # Alternative Colima is part of the github macOS runner 56 | # SAM v1.47.0+ needed for colima support, unable to use Python 3.6 57 | colima start 58 | # Ensure colima is configured for later user 59 | echo "DOCKER_HOST=unix://$HOME/.colima/default/docker.sock" >> $GITHUB_ENV 60 | # Verify Docker 61 | docker ps 62 | docker --version 63 | # Verify colima 64 | colima status 65 | - uses: actions/setup-python@v4 66 | with: 67 | python-version: ${{ matrix.python }} 68 | cache: 'pip' 69 | - name: Install Dependencies Python 70 | id: install_python 71 | run: | 72 | mkdir "$LOG_PATH" 73 | pip install --upgrade pip 74 | pip install --upgrade setuptools wheel aws-sam-cli -r https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli/master/requirements.txt 75 | pip install . 76 | - uses: actions/setup-node@v3 77 | with: 78 | node-version: ${{ matrix.node }} 79 | cache: 'npm' 80 | - name: Install Dependencies Node.js 81 | id: install_nodejs 82 | # Touch needed because of https://github.com/aws/aws-cli/issues/2639 83 | run: | 84 | npm ci --include=optional 85 | find ./node_modules/* -mtime +10950 -exec touch {} \; 86 | npm run build 87 | - uses: actions/cache@v3 88 | with: 89 | path: ~/.cache/pre-commit/ 90 | key: ${{ matrix.os }}-${{ env.pythonLocation }}${{ hashFiles('.pre-commit-config.yaml') }} 91 | - name: Run Unit Tests 92 | id: unit_testing 93 | run: | 94 | pre-commit run --all-files --verbose 95 | - name: Upload Coverage 96 | id: codecov 97 | run: | 98 | curl -s https://codecov.io/bash > codecov.sh 99 | bash codecov.sh -f coverage/py/coverage.xml -F unittests -n codecov-python 100 | bash codecov.sh -f coverage/ts/coverage-final.json -F unittests -n codecov-typescript 101 | - name: Upload Coverage Artifacts 102 | id: upload_coverage 103 | uses: actions/upload-artifact@v3 104 | with: 105 | name: coverage 106 | path: coverage/ 107 | - name: Run Integration Tests 108 | id: integration_testing 109 | run: | 110 | RPDK_PACKAGE=$(npm pack --silent) 111 | RPDK_PATH=$PWD/$RPDK_PACKAGE 112 | DIR=TestCI 113 | mkdir $DIR 114 | cd "$DIR" 115 | echo "PROJECT_DIR=$PWD" >> $GITHUB_ENV 116 | ls -la 117 | printf "n" | cfn init -vv --artifact-type RESOURCE --type-name AWS::Foo::Bar typescript 118 | ls -la 119 | mkdir ./dist 120 | cp "$RPDK_PATH" ./dist 121 | npm install "./dist/$RPDK_PACKAGE" 122 | cfn generate -vv && cfn validate -vv 123 | npm install --include=optional 124 | sam build --debug --build-dir ./build TypeFunction 125 | sam build --debug --build-dir ./build TestEntrypoint 126 | sam local invoke -t ./build/template.yaml --debug --event ./sam-tests/create.json --log-file ./sam.log TestEntrypoint 127 | grep -q '"SUCCESS"' sam.log 128 | - name: Gather Debug Logs 129 | id: gather_logs 130 | continue-on-error: true 131 | if: failure() 132 | run: | 133 | mkdir "$LOG_PATH/_logs" 2>&1 || : 134 | cp -r "$(npm config get cache)/_logs" "$LOG_PATH" 2>&1 || : 135 | cp "$GITHUB_WORKSPACE/npm-debug.log" "$LOG_PATH/_logs" 2>&1 || : 136 | cp "$PROJECT_DIR/npm-debug.log" "$LOG_PATH/_logs" 2>&1 || : 137 | cp "$PIP_LOG_FILE" "$LOG_PATH" 2>&1 || : 138 | cp "$PROJECT_DIR/rpdk.log" "$LOG_PATH" 2>&1 || : 139 | cp "$PROJECT_DIR/sam.log" "$LOG_PATH" 2>&1 || : 140 | - name: Upload Debug Artifacts 141 | id: upload_logs 142 | if: failure() 143 | uses: actions/upload-artifact@v3 144 | with: 145 | name: debug-logs 146 | path: ${{ env.LOG_PATH }} 147 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | .DS_Store 127 | .idea/ 128 | .vscode/ 129 | *.tar.gz 130 | *.tgz 131 | src/workers/*.js 132 | src/workers/*.d.ts 133 | tests/**/*.js 134 | tests/**/*.d.ts 135 | 136 | # We want to allow the tests/lib folder 137 | !tests/lib 138 | 139 | # Node.js 140 | node_modules/ 141 | coverage/ 142 | 143 | *.iml 144 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-isort 3 | rev: v5.10.1 4 | hooks: 5 | - id: isort 6 | # language_version: python3.6 7 | - repo: https://github.com/ambv/black 8 | rev: 22.8.0 9 | hooks: 10 | - id: black 11 | # language_version: python3.6 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.1.0 14 | hooks: 15 | - id: check-case-conflict 16 | - id: end-of-file-fixer 17 | - id: mixed-line-ending 18 | args: 19 | - --fix=lf 20 | - id: trailing-whitespace 21 | - id: pretty-format-json 22 | exclude: ^tsconfig.*.json 23 | args: 24 | - --autofix 25 | - --indent=4 26 | - --no-sort-keys 27 | - id: check-merge-conflict 28 | # - id: check-yaml # have jinja yml templates so skipping this 29 | - repo: https://github.com/pycqa/flake8 30 | rev: "5.0.4" 31 | hooks: 32 | - id: flake8 33 | additional_dependencies: 34 | - flake8-bugbear>=19.3.0 35 | - flake8-builtins>=1.4.1 36 | - flake8-commas>=2.0.0 37 | - flake8-comprehensions>=2.1.0 38 | - flake8-debugger>=3.1.0 39 | - flake8-pep3101>=1.2.1 40 | # language_version: python3.6 41 | - repo: https://github.com/pre-commit/pygrep-hooks 42 | rev: v1.9.0 43 | hooks: 44 | - id: python-check-blanket-noqa 45 | - id: python-check-mock-methods 46 | - id: python-no-log-warn 47 | - repo: https://github.com/PyCQA/bandit 48 | rev: 1.7.1 49 | hooks: 50 | - id: bandit 51 | files: "^python/" 52 | - repo: https://github.com/pre-commit/mirrors-eslint 53 | rev: v8.26.0 54 | hooks: 55 | - id: eslint 56 | args: [--fix] 57 | types: [] 58 | files: (.*.js$|.*.ts$) 59 | additional_dependencies: 60 | - eslint@8.21.0 61 | - repo: local 62 | hooks: 63 | - id: pylint-local 64 | name: pylint-local 65 | description: Run pylint in the local virtualenv 66 | entry: pylint "setup.py" "python/" "tests/" 67 | language: system 68 | # ignore all files, run on hard-coded modules instead 69 | pass_filenames: false 70 | always_run: true 71 | - id: pytest-local 72 | name: pytest-local 73 | description: Run pytest in the local virtualenv 74 | entry: pytest --cov=rpdk.typescript tests/ 75 | language: system 76 | # ignore all files, run on hard-coded modules instead 77 | pass_filenames: false 78 | always_run: true 79 | - id: jest-local 80 | name: jest-local 81 | description: Run jest in the local environment 82 | entry: npx jest --ci --verbose 83 | language: system 84 | # ignore all files, run on hard-coded modules instead 85 | pass_filenames: false 86 | always_run: true 87 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | ignore=CVS,models.py,handlers.py 4 | jobs=1 5 | persistent=yes 6 | 7 | [MESSAGES CONTROL] 8 | 9 | disable= 10 | missing-docstring, # not everything needs a docstring 11 | fixme, # work in progress 12 | bad-continuation, # clashes with black 13 | too-few-public-methods, # triggers when inheriting 14 | ungrouped-imports, # clashes with isort 15 | duplicate-code, # broken, setup.py 16 | 17 | [BASIC] 18 | 19 | good-names=e,ex,f,fp,i,j,k,v,n,_ 20 | 21 | [FORMAT] 22 | 23 | indent-string=' ' 24 | max-line-length=88 25 | 26 | [DESIGN] 27 | 28 | max-attributes=12 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.5.0] - 2020-12-02 10 | ### Added 11 | - [Support Library] Queue to avoid throttling of internal AWS API calls (#30) 12 | - [Support Library] Optional use of worker threads for performance reasons (#30) 13 | 14 | ### Changed 15 | - [Support Library] Increase default options for util inspect so that deep objects are also printed (#27) 16 | - [Support Library] Expose the model type reference in the resource class (#27) 17 | 18 | ### Fixed 19 | - [Support Library] Expired security token when logging config enabled (#31) (#30) 20 | 21 | ## [0.4.0] - 2020-10-11 22 | ### Added 23 | - [Support Library] Pass a logger interface to the handlers (#26) 24 | - [Support Library] Scrub sensitive information when logging (#26) 25 | 26 | ### Changed 27 | - [Support Library] Make the input data (`callbackContext` and `request`) immutable (#26) 28 | 29 | ### Fixed 30 | - [CLI Plugin] Avoid zip error by using less strict timestamp check (#26) 31 | 32 | 33 | ## [0.3.3] - 2020-09-23 34 | ### Changed 35 | - [CLI Plugin] Update CloudFormation CLI dependency package (#25) 36 | - [Support Library] Make certain request fields optional to unblock contract testing (#25) 37 | - [Support Library] Update optional dependency to newer AWS SDK Javascript used in Lambda runtime (#25) 38 | 39 | 40 | ## [0.3.2] - 2020-08-31 41 | ### Added 42 | - [CLI Plugin] Wildcard .gitignore pattern in case rpdk.log rotates 43 | - [Support Library] New properties for resource request: `desiredResourceTags`, `previousResourceTags`, `systemTags`, `awsAccountId`, `region` and `awsPartition` (#23) 44 | 45 | ### Removed 46 | - [Support Library] Account ID from metric namespace 47 | 48 | 49 | ## [0.3.1] - 2020-08-19 50 | ### Fixed 51 | - [Support Library] Cast from empty string to number or boolean (#12) (#22) 52 | 53 | 54 | ## [0.3.0] - 2020-08-09 55 | ### Added 56 | - [CLI Plugin] Primary and additional identifiers can be retrieved using the appropriate methods in base model class (#18) 57 | - [Support Library] Recast properties from string to intended primitive type based on model (#9) (#18) 58 | - [Support Library] New wrapper class for integer types (simplification from bigint) (#18) 59 | 60 | ### Changed 61 | - [CLI Plugin] Improve model serialization/deserialization to handle complex schemas (#18) 62 | - [Support Library] While leveraging `class-transformer` library, the properties can now be cast into proper types (#18) 63 | 64 | ### Removed 65 | - [Support Library] Global definitions and auxiliary code extending ES6 Map (#18) 66 | 67 | 68 | ## [0.2.1] - 2020-07-14 69 | ### Fixed 70 | - [Support Library] Callback context not being properly formatted (#15) (#16) 71 | 72 | 73 | ## [0.2.0] - 2020-07-08 74 | ### Added 75 | - [Support Library] Support protocol version 2.0.0 to response the handler result with callback directly and allow CloudFormation service to orchestrate the callback (#12) (#13) 76 | 77 | 78 | ## [0.1.2] - 2020-05-25 79 | ### Fixed 80 | - [Support Library] Error messages not appearing in CloudWatch (#10) (#11) 81 | 82 | 83 | ## [0.1.1] - 2020-05-02 84 | ### Fixed 85 | - [Support Library] Event handler binding issue (#7) 86 | 87 | 88 | ## [0.1.0] - 2020-04-24 89 | ### Added 90 | - [Support Library] Schedule CloudWatch Events for re-invocation during long process 91 | - [Support Library] Publish metrics to CloudWatch 92 | 93 | ### Changed 94 | - [Support Library] Fallback to S3 in log delivery 95 | 96 | ### Fixed 97 | - [Support Library] CloudWatch log delivery issue 98 | 99 | 100 | ## [0.0.1] - 2020-04-14 101 | ### Added 102 | - [CLI Plugin] Initial version in line with [Python plugin](https://github.com/aws-cloudformation/cloudformation-cli-python-plugin) (#2) 103 | - [CLI Plugin] Build using SAM CLI (both locally or with docker support) (#2) 104 | - [Support Library] Callback in order to report progress to CloudFormation (#2) 105 | - [Support Library] Mechanism for log delivery to CloudWatch (#2) 106 | - [Support Library] Base Model class as well as Progress Event class (#2) 107 | 108 | 109 | [Unreleased]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.5.0...HEAD 110 | [0.5.0]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.4.0...v0.5.0 111 | [0.4.0]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.3.3...v0.4.0 112 | [0.3.3]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.3.2...v0.3.3 113 | [0.3.2]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.3.1...v0.3.2 114 | [0.3.1]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.3.0...v0.3.1 115 | [0.3.0]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.2.1...v0.3.0 116 | [0.2.1]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.2.0...v0.2.1 117 | [0.2.0]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.1.2...v0.2.0 118 | [0.1.2]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.1.1...v0.1.2 119 | [0.1.1]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.1.0...v0.1.1 120 | [0.1.0]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/compare/v0.0.1...v0.1.0 121 | [0.0.1]: https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/releases/tag/v0.0.1 122 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq). 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin/issues), or [recently closed](https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 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. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | 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'](https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Security issue notifications 48 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 49 | 50 | 51 | ## Licensing 52 | 53 | See the [LICENSE](https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 54 | 55 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include Pipfile 4 | include Pipfile.lock 5 | 6 | graft python/rpdk/typescript 7 | graft tests/plugin 8 | 9 | # last rule wins, put excludes last 10 | global-exclude __pycache__ *.py[cod] .DS_Store 11 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | AWS CloudFormation RPDK TypeScript Plugin 2 | Copyright 2020 3 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pylint = "*" 8 | pytest = "*" 9 | pytest-cov = "*" 10 | 11 | [packages] 12 | cloudformation-cli-typescript-plugin = {editable = true,path = "."} 13 | 14 | [requires] 15 | python_version = "3" 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS CloudFormation Resource Provider Typescript Plugin 2 | 3 | We're excited to share our progress with adding new languages to the CloudFormation CLI! 4 | 5 | ## AWS CloudFormation Resource Provider TypeScript Plugin 6 | 7 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/aws-cloudformation/cloudformation-cli-typescript-plugin/ci/master)](https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin/actions?query=branch%3Amaster+workflow%3Aci) [![Codecov](https://img.shields.io/codecov/c/gh/aws-cloudformation/cloudformation-cli-typescript-plugin)](https://codecov.io/gh/aws-cloudformation/cloudformation-cli-typescript-plugin) [![PyPI version](https://img.shields.io/pypi/v/cloudformation-cli-typescript-plugin)](https://pypi.org/project/cloudformation-cli-typescript-plugin) [![NPM version](https://img.shields.io/npm/v/@amazon-web-services-cloudformation/cloudformation-cli-typescript-lib)](https://www.npmjs.com/package/@amazon-web-services-cloudformation/cloudformation-cli-typescript-lib) [![Node.js version](https://img.shields.io/badge/dynamic/json?color=brightgreen&url=https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli-typescript-plugin/master/package.json&query=$.engines.node&label=nodejs)](https://nodejs.org/) 8 | 9 | The CloudFormation CLI (cfn) allows you to author your own resource providers that can be used by CloudFormation. 10 | 11 | This plugin library helps to provide TypeScript runtime bindings for the execution of your providers by CloudFormation. 12 | 13 | Usage 14 | ----- 15 | 16 | If you are using this package to build resource providers for CloudFormation, install the [CloudFormation CLI TypeScript Plugin](https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin) - this will automatically install the [CloudFormation CLI](https://github.com/aws-cloudformation/cloudformation-cli)! A Python virtual environment is recommended. 17 | 18 | **Prerequisites** 19 | 20 | - Python version 3.6 or above 21 | - [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) 22 | - Your choice of TypeScript IDE 23 | 24 | **Installation** 25 | 26 | 27 | ```shell 28 | pip3 install cloudformation-cli-typescript-plugin 29 | ``` 30 | 31 | Refer to the [CloudFormation CLI User Guide](https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-types.html) for the [CloudFormation CLI](https://github.com/aws-cloudformation/cloudformation-cli) for usage instructions. 32 | 33 | **Howto** 34 | 35 | Example run: 36 | 37 | ``` 38 | $ cfn init 39 | Initializing new project 40 | What's the name of your resource type? 41 | (Organization::Service::Resource) 42 | >> Foo::Bar::Baz 43 | Select a language for code generation: 44 | [1] java 45 | [2] typescript 46 | (enter an integer): 47 | >> 2 48 | Use docker for platform-independent packaging (Y/n)? 49 | This is highly recommended unless you are experienced 50 | with cross-platform Typescript packaging. 51 | >> y 52 | Initialized a new project in <> 53 | $ cfn submit --dry-run 54 | $ sam local invoke --event sam-tests/create.json TestEntrypoint 55 | ``` 56 | 57 | Development 58 | ----------- 59 | 60 | For changes to the plugin, a Python virtual environment is recommended. Check out and install the plugin in editable mode: 61 | 62 | ```shell 63 | python3 -m venv env 64 | source env/bin/activate 65 | pip3 install -e /path/to/cloudformation-cli-typescript-plugin 66 | ``` 67 | 68 | You may also want to check out the [CloudFormation CLI](https://github.com/aws-cloudformation/cloudformation-cli) if you wish to make edits to that. In this case, installing them in one operation works well: 69 | 70 | ```shell 71 | pip3 install \ 72 | -e /path/to/cloudformation-cli \ 73 | -e /path/to/cloudformation-cli-typescript-plugin 74 | ``` 75 | 76 | That ensures neither is accidentally installed from PyPI. 77 | 78 | For changes to the typescript library "@amazon-web-services-cloudformation/cloudformation-cli-typescript-lib" pack up the compiled javascript: 79 | 80 | ```shell 81 | npm run build 82 | npm pack 83 | ``` 84 | 85 | You can then install this in a cfn resource project using: 86 | 87 | ```shell 88 | npm install ../path/to/cloudformation-cli-typescript-plugin/amazon-web-services-cloudformation-cloudformation-cli-typescript-lib-1.0.1.tgz 89 | ``` 90 | 91 | Linting and running unit tests is done via [pre-commit](https://pre-commit.com/), and so is performed automatically on commit after being installed (`pre-commit install`). The continuous integration also runs these checks. Manual options are available so you don't have to commit: 92 | 93 | ```shell 94 | # run all hooks on all files, mirrors what the CI runs 95 | pre-commit run --all-files 96 | # run unit tests only. can also be used for other hooks, e.g. black, flake8, pylint-local 97 | pre-commit run pytest-local 98 | ``` 99 | 100 | License 101 | ------- 102 | 103 | This library is licensed under the Apache 2.0 License. 104 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 5% 6 | 7 | patch: false 8 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | const { ignorePatterns } = require('./.eslintrc'); 2 | 3 | module.exports = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.ts?$': [ 8 | 'ts-jest', 9 | { 10 | ignoreCoverageForAllDecorators: true, 11 | tsconfig: 'tsconfig.test.json', 12 | }, 13 | ], 14 | }, 15 | testRegex: '\\.test.ts$', 16 | testRunner: 'jest-circus/runner', 17 | coverageThreshold: { 18 | global: { 19 | branches: 70, 20 | statements: 80, 21 | }, 22 | }, 23 | coverageDirectory: 'coverage/ts', 24 | collectCoverage: true, 25 | coverageReporters: ['json', 'lcov', 'text'], 26 | coveragePathIgnorePatterns: ['/node_modules/', '/tests/data/'], 27 | testTimeout: 60000, 28 | moduleNameMapper: { 29 | '^~/(.*)$': '/src/$1', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amazon-web-services-cloudformation/cloudformation-cli-typescript-lib", 3 | "version": "1.0.6", 4 | "description": "The CloudFormation Resource Provider Development Kit (RPDK) allows you to author your own resource providers that can be used by CloudFormation. This plugin library helps to provide runtime bindings for the execution of your providers by CloudFormation.", 5 | "private": false, 6 | "main": "dist/index.js", 7 | "directories": { 8 | "test": "tests" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "scripts": { 17 | "build": "npx tsc", 18 | "prepack": "npm run build", 19 | "lint": "npx eslint --ext .ts,.js .", 20 | "lint:fix": "npx eslint --fix --ext .ts,.js .", 21 | "test": "npx jest", 22 | "test:debug": "npx --node-arg=--inspect jest --runInBand" 23 | }, 24 | "engines": { 25 | "node": ">=20.0.0", 26 | "npm": ">=6.9.0" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin.git" 31 | }, 32 | "author": "Amazon Web Services", 33 | "license": "Apache License 2.0", 34 | "bugs": { 35 | "url": "https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin/issues" 36 | }, 37 | "homepage": "https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin#readme", 38 | "dependencies": { 39 | "@org-formation/tombok": "^0.0.1", 40 | "autobind-decorator": "^2.4.0", 41 | "class-transformer": "^0.5.1", 42 | "reflect-metadata": "^0.2.2", 43 | "string.prototype.replaceall": "^1.0.3", 44 | "uuid": "^7.0.2" 45 | }, 46 | "devDependencies": { 47 | "@tsconfig/node20": "^20.1.0", 48 | "@types/jest": "^29.5.0", 49 | "@types/node": "^20.12.0", 50 | "@types/uuid": "^9.0.0", 51 | "@typescript-eslint/eslint-plugin": "^6.0.0", 52 | "@typescript-eslint/parser": "^6.0.0", 53 | "aws-sdk": "~2.814.0", 54 | "eslint": "~8.21.0", 55 | "eslint-config-prettier": "^9.1.0", 56 | "eslint-import-resolver-node": "^0.3.3", 57 | "eslint-import-resolver-typescript": "^3.6.0", 58 | "eslint-plugin-import": "^2.29.0", 59 | "eslint-plugin-prefer-arrow": "^1.2.2", 60 | "eslint-plugin-prettier": "^5.1.0", 61 | "jest": "^29.7.0", 62 | "jest-circus": "^29.7.0", 63 | "prettier": "^3.1.0", 64 | "ts-jest": "^29.1.2", 65 | "ts-node": "^10.9.2", 66 | "typescript": "~5.3.0", 67 | "worker-pool-aws-sdk": "^0.1.0" 68 | }, 69 | "peerDependencies": { 70 | "aws-sdk": "^2.712.0" 71 | }, 72 | "peerDependenciesMeta": { 73 | "aws-sdk": { 74 | "optional": true 75 | } 76 | }, 77 | "prettier": { 78 | "parser": "typescript", 79 | "singleQuote": true, 80 | "tabWidth": 4, 81 | "printWidth": 88, 82 | "trailingComma": "es5", 83 | "endOfLine": "lf" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /python/rpdk/typescript/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | __version__ = "1.0.4" 4 | 5 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 6 | -------------------------------------------------------------------------------- /python/rpdk/typescript/codegen.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import sys 5 | from subprocess import PIPE, CalledProcessError, run as subprocess_run # nosec 6 | from tempfile import TemporaryFile 7 | 8 | from rpdk.core.data_loaders import resource_stream 9 | from rpdk.core.exceptions import DownstreamError 10 | from rpdk.core.init import input_with_validation 11 | from rpdk.core.jsonutils.resolver import ContainerType, resolve_models 12 | from rpdk.core.plugin_base import LanguagePlugin 13 | 14 | from .resolver import contains_model, get_inner_type, translate_type 15 | from .utils import safe_reserved 16 | 17 | if sys.version_info >= (3, 8): # pragma: no cover 18 | from zipfile import ZipFile 19 | else: # pragma: no cover 20 | from zipfile38 import ZipFile 21 | 22 | 23 | LOG = logging.getLogger(__name__) 24 | 25 | EXECUTABLE = "cfn" 26 | SUPPORT_LIB_NAME = ( 27 | "@amazon-web-services-cloudformation/cloudformation-cli-typescript-lib" 28 | ) 29 | SUPPORT_LIB_VERSION = "^1.0.1" 30 | MAIN_HANDLER_FUNCTION = "TypeFunction" 31 | 32 | 33 | def validate_no(value): 34 | return value.lower() not in ("n", "no") 35 | 36 | 37 | class TypescriptLanguagePlugin(LanguagePlugin): 38 | MODULE_NAME = __name__ 39 | NAME = "typescript" 40 | RUNTIME = "nodejs20.x" 41 | ENTRY_POINT = "dist/handlers.entrypoint" 42 | TEST_ENTRY_POINT = "dist/handlers.testEntrypoint" 43 | CODE_URI = "./" 44 | 45 | def __init__(self): 46 | self.env = self._setup_jinja_env( 47 | trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True 48 | ) 49 | self.env.filters["translate_type"] = translate_type 50 | self.env.filters["contains_model"] = contains_model 51 | self.env.filters["get_inner_type"] = get_inner_type 52 | self.env.filters["safe_reserved"] = safe_reserved 53 | self.env.globals["ContainerType"] = ContainerType 54 | self.namespace = None 55 | self.package_name = None 56 | self.package_root = None 57 | self._use_docker = None 58 | self._no_docker = None 59 | self._protocol_version = "2.0.0" 60 | self._build_command = None 61 | self._lib_path = None 62 | 63 | def _init_from_project(self, project): 64 | self.namespace = tuple(s.lower() for s in project.type_info) 65 | self.package_name = "-".join(self.namespace) 66 | # Check config file for (legacy) 'useDocker' and use_docker settings 67 | self._use_docker = project.settings.get("useDocker") or project.settings.get( 68 | "use_docker" 69 | ) 70 | self.package_root = project.root / "src" 71 | self._build_command = project.settings.get("buildCommand", None) 72 | self._lib_path = SUPPORT_LIB_VERSION 73 | 74 | def _init_settings(self, project): 75 | LOG.debug("Writing settings") 76 | # If use_docker specified in .rpdk-config file or cli switch 77 | # Ensure only 1 is true, with preference to use_docker 78 | if project.settings.get("use_docker") is True: 79 | self._use_docker = True 80 | self._no_docker = False 81 | # If no_docker specified in .rpdk-config file or cli switch 82 | elif project.settings.get("no_docker") is True: 83 | self._use_docker = False 84 | self._no_docker = True 85 | else: 86 | # If neither no_docker nor use_docker specified in .rpdk-config 87 | # file or cli switch, prompt to use containers or not 88 | self._use_docker = input_with_validation( 89 | "Use docker for platform-independent packaging (Y/n)?\n", 90 | validate_no, 91 | "This is highly recommended unless you are experienced \n" 92 | "with cross-platform Typescript packaging.", 93 | ) 94 | self._no_docker = not self._use_docker 95 | 96 | # switched to 'use_docker' from 'useDocker' to be in line with python version 97 | # project.settings will get saved into .rpdk-config by cloudformation-cli 98 | project.settings["use_docker"] = self._use_docker 99 | project.settings["no_docker"] = self._no_docker 100 | project.settings["protocolVersion"] = self._protocol_version 101 | 102 | def init(self, project): 103 | LOG.debug("Init started") 104 | 105 | self._init_from_project(project) 106 | self._init_settings(project) 107 | 108 | project.runtime = self.RUNTIME 109 | project.entrypoint = self.ENTRY_POINT 110 | project.test_entrypoint = self.TEST_ENTRY_POINT 111 | 112 | def _render_template(path, **kwargs): 113 | LOG.debug("Writing '%s'", path) 114 | template = self.env.get_template(path.name) 115 | contents = template.render(**kwargs) 116 | project.safewrite(path, contents) 117 | 118 | def _copy_resource(path, resource_name=None): 119 | LOG.debug("Writing '%s'", path) 120 | if not resource_name: 121 | resource_name = path.name 122 | contents = resource_stream(__name__, f"data/{resource_name}").read() 123 | project.safewrite(path, contents) 124 | 125 | # handler Typescript package 126 | handler_package_path = self.package_root 127 | LOG.debug("Making folder '%s'", handler_package_path) 128 | handler_package_path.mkdir(parents=True, exist_ok=True) 129 | _render_template( 130 | handler_package_path / "handlers.ts", 131 | lib_name=SUPPORT_LIB_NAME, 132 | type_name=project.type_name, 133 | ) 134 | # models.ts produced by generate 135 | 136 | # project support files 137 | _copy_resource(project.root / ".gitignore", "typescript.gitignore") 138 | _copy_resource(project.root / ".npmrc") 139 | sam_tests_folder = project.root / "sam-tests" 140 | sam_tests_folder.mkdir(exist_ok=True) 141 | _copy_resource(sam_tests_folder / "create.json") 142 | _copy_resource(project.root / "tsconfig.json") 143 | _render_template( 144 | project.root / "package.json", 145 | name=project.hypenated_name, 146 | description=f"AWS custom resource provider named {project.type_name}.", 147 | lib_name=SUPPORT_LIB_NAME, 148 | lib_path=self._lib_path, 149 | ) 150 | _render_template( 151 | project.root / "README.md", 152 | type_name=project.type_name, 153 | schema_path=project.schema_path, 154 | project_path=self.package_name, 155 | executable=EXECUTABLE, 156 | lib_name=SUPPORT_LIB_NAME, 157 | ) 158 | 159 | _render_template( 160 | project.root / "Makefile", 161 | ) 162 | 163 | # CloudFormation/SAM template for handler lambda 164 | handler_params = { 165 | "Handler": project.entrypoint, 166 | "Runtime": project.runtime, 167 | "CodeUri": self.CODE_URI, 168 | } 169 | test_handler_params = { 170 | **handler_params, 171 | "Handler": project.test_entrypoint, 172 | } 173 | _render_template( 174 | project.root / "template.yml", 175 | resource_type=project.type_name, 176 | handler_params=handler_params, 177 | test_handler_params=test_handler_params, 178 | ) 179 | 180 | LOG.debug("Init complete") 181 | 182 | def generate(self, project): 183 | LOG.debug("Generate started") 184 | 185 | self._init_from_project(project) 186 | 187 | models = resolve_models(project.schema) 188 | 189 | if project.configuration_schema: 190 | configuration_models = resolve_models( 191 | project.configuration_schema, "TypeConfigurationModel" 192 | ) 193 | else: 194 | configuration_models = {"TypeConfigurationModel": {}} 195 | 196 | models.update(configuration_models) 197 | 198 | path = self.package_root / "models.ts" 199 | LOG.debug("Writing file: %s", path) 200 | template = self.env.get_template("models.ts") 201 | 202 | contents = template.render( 203 | lib_name=SUPPORT_LIB_NAME, 204 | type_name=project.type_name, 205 | models=models, 206 | contains_type_configuration=project.configuration_schema, 207 | primaryIdentifier=project.schema.get("primaryIdentifier", []), 208 | additionalIdentifiers=project.schema.get("additionalIdentifiers", []), 209 | ) 210 | project.overwrite(path, contents) 211 | 212 | LOG.debug("Generate complete") 213 | 214 | def _pre_package(self, build_path): 215 | # Caller should own/delete this, not us. 216 | # pylint: disable=consider-using-with 217 | f = TemporaryFile("w+b") 218 | 219 | # pylint: disable=unexpected-keyword-arg 220 | with ZipFile(f, mode="w", strict_timestamps=False) as zip_file: 221 | self._recursive_relative_write(build_path, build_path, zip_file) 222 | f.seek(0) 223 | 224 | return f 225 | 226 | @staticmethod 227 | def _recursive_relative_write(src_path, base_path, zip_file): 228 | for path in src_path.rglob("*"): 229 | if path.is_file(): 230 | relative = path.relative_to(base_path) 231 | zip_file.write(path.resolve(), str(relative)) 232 | 233 | def package(self, project, zip_file): 234 | LOG.debug("Package started") 235 | 236 | self._init_from_project(project) 237 | 238 | handler_package_path = self.package_root 239 | build_path = project.root / "build" 240 | 241 | self._remove_build_artifacts(build_path) 242 | self._build(project.root) 243 | 244 | inner_zip = self._pre_package(build_path / MAIN_HANDLER_FUNCTION) 245 | zip_file.writestr("ResourceProvider.zip", inner_zip.read()) 246 | self._recursive_relative_write(handler_package_path, project.root, zip_file) 247 | 248 | LOG.debug("Package complete") 249 | 250 | @staticmethod 251 | def _remove_build_artifacts(deps_path): 252 | try: 253 | shutil.rmtree(deps_path) 254 | except FileNotFoundError: 255 | LOG.debug("'%s' not found, skipping removal", deps_path, exc_info=True) 256 | 257 | @staticmethod 258 | def _make_build_command(base_path, build_command=None): 259 | command = ( 260 | "npm install --include=optional " 261 | + f"&& sam build --debug --build-dir {os.path.join(base_path, 'build')}" 262 | ) 263 | if build_command is not None: 264 | command = build_command 265 | return command 266 | 267 | def _build(self, base_path): 268 | LOG.debug("Dependencies build started from '%s'", base_path) 269 | 270 | # TODO: We should use the build logic from SAM CLI library, instead: 271 | # https://github.com/awslabs/aws-sam-cli/blob/master/samcli/lib/build/app_builder.py 272 | command = self._make_build_command(base_path, self._build_command) 273 | if self._use_docker: 274 | command = command + " --use-container" 275 | command = command + " " + MAIN_HANDLER_FUNCTION 276 | 277 | LOG.debug("command is '%s'", command) 278 | 279 | LOG.warning("Starting build.") 280 | try: 281 | # On windows get the default CLI in environment variable comspec 282 | # run 1 command and exit. Building shell command manually, subprocess.run 283 | # with shell=True behavior is inconsistent on windows 284 | if sys.platform == "win32": # pragma: no cover 285 | shell = os.environ.get("comspec") 286 | shell_arg = "/C" 287 | completed_proc = subprocess_run( # nosec 288 | [shell, shell_arg, command], 289 | stdout=PIPE, 290 | stderr=PIPE, 291 | cwd=base_path, 292 | check=True, 293 | universal_newlines=True, 294 | ) 295 | else: # pragma: no cover 296 | # On all other OS use default shell in subprocess to run build command 297 | completed_proc = subprocess_run( # nosec 298 | [command], 299 | stdout=PIPE, 300 | stderr=PIPE, 301 | cwd=base_path, 302 | check=True, 303 | shell=True, 304 | universal_newlines=True, 305 | ) 306 | 307 | except (FileNotFoundError, CalledProcessError) as e: 308 | LOG.warning(e.stderr) 309 | raise DownstreamError("local build failed") from e 310 | 311 | LOG.debug("--- build stdout:\n%s", completed_proc.stdout) 312 | LOG.debug("--- build stderr:\n%s", completed_proc.stderr) 313 | LOG.debug("Dependencies build finished") 314 | -------------------------------------------------------------------------------- /python/rpdk/typescript/data/.npmrc: -------------------------------------------------------------------------------- 1 | optional = true 2 | -------------------------------------------------------------------------------- /python/rpdk/typescript/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli-typescript-plugin/c6741811d918954b2bead0aa844400d236537581/python/rpdk/typescript/data/__init__.py -------------------------------------------------------------------------------- /python/rpdk/typescript/data/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": { 3 | "accessKeyId": "", 4 | "secretAccessKey": "", 5 | "sessionToken": "" 6 | }, 7 | "action": "CREATE", 8 | "request": { 9 | "clientRequestToken": "4b90a7e4-b790-456b-a937-0cfdfa211dfe", 10 | "desiredResourceState": { 11 | "Title": "Create Test", 12 | "TestCode": "NOT_STARTED" 13 | }, 14 | "previousResourceState": null, 15 | "logicalResourceIdentifier": null 16 | }, 17 | "callbackContext": null 18 | } 19 | -------------------------------------------------------------------------------- /python/rpdk/typescript/data/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "noImplicitAny": true, 6 | "alwaysStrict": true, 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "allowJs": true, 10 | "experimentalDecorators": true, 11 | "outDir": "dist" 12 | }, 13 | "include": [ 14 | "src/**/*.ts" 15 | ], 16 | "exclude": [ 17 | "node_modules" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /python/rpdk/typescript/data/typescript.gitignore: -------------------------------------------------------------------------------- 1 | # Distribution / packaging 2 | build/ 3 | dist/ 4 | 5 | # Unit test / coverage reports 6 | .cache 7 | .hypothesis/ 8 | .pytest_cache/ 9 | 10 | # RPDK logs 11 | rpdk.log* 12 | 13 | # Node.js 14 | node_modules/ 15 | coverage/ 16 | 17 | # contains credentials 18 | sam-tests/ 19 | -------------------------------------------------------------------------------- /python/rpdk/typescript/parser.py: -------------------------------------------------------------------------------- 1 | def setup_subparser(subparsers, parents): 2 | parser = subparsers.add_parser( 3 | "typescript", 4 | description="This sub command generates IDE and build files for TypeScript", 5 | parents=parents, 6 | ) 7 | parser.set_defaults(language="typescript") 8 | 9 | group = parser.add_mutually_exclusive_group() 10 | 11 | group.add_argument( 12 | "-d", 13 | "--use-docker", 14 | action="store_true", 15 | help="""Use docker for TypeScript platform-independent packaging. 16 | This is highly recommended unless you are experienced 17 | with cross-platform TypeScript packaging.""", 18 | ) 19 | 20 | group.add_argument( 21 | "--no-docker", 22 | action="store_true", 23 | help="""Generally not recommended unless you are experienced 24 | with cross-platform Typescript packaging.""", 25 | ) 26 | 27 | return parser 28 | -------------------------------------------------------------------------------- /python/rpdk/typescript/resolver.py: -------------------------------------------------------------------------------- 1 | from rpdk.core.jsonutils.resolver import UNDEFINED, ContainerType 2 | 3 | PRIMITIVE_TYPES = { 4 | "string": "string", 5 | "integer": "integer", 6 | "boolean": "boolean", 7 | "number": "number", 8 | UNDEFINED: "object", 9 | } 10 | PRIMITIVE_WRAPPERS = { 11 | "string": "String", 12 | "integer": "Integer", 13 | "boolean": "Boolean", 14 | "number": "Number", 15 | "object": "Object", 16 | } 17 | 18 | 19 | class InnerType: 20 | def __init__(self, item_type): 21 | self.primitive = False 22 | self.classes = [] 23 | self.type = self.resolve_type(item_type) 24 | self.wrapper_type = self.type 25 | if self.primitive: 26 | self.wrapper_type = PRIMITIVE_WRAPPERS[self.type] 27 | 28 | def resolve_type(self, resolved_type): 29 | if resolved_type.container == ContainerType.PRIMITIVE: 30 | self.primitive = True 31 | return PRIMITIVE_TYPES[resolved_type.type] 32 | if resolved_type.container == ContainerType.MULTIPLE: 33 | self.primitive = True 34 | return "object" 35 | if resolved_type.container == ContainerType.MODEL: 36 | return resolved_type.type 37 | if resolved_type.container == ContainerType.DICT: 38 | self.classes.append("Map") 39 | elif resolved_type.container == ContainerType.LIST: 40 | self.classes.append("Array") 41 | elif resolved_type.container == ContainerType.SET: 42 | self.classes.append("Set") 43 | else: 44 | raise ValueError(f"Unknown container type {resolved_type.container}") 45 | 46 | return self.resolve_type(resolved_type.type) 47 | 48 | 49 | def get_inner_type(resolved_type): 50 | return InnerType(resolved_type) 51 | 52 | 53 | def translate_type(resolved_type): 54 | if resolved_type.container == ContainerType.MODEL: 55 | return resolved_type.type 56 | if resolved_type.container == ContainerType.PRIMITIVE: 57 | return PRIMITIVE_TYPES[resolved_type.type] 58 | if resolved_type.container == ContainerType.MULTIPLE: 59 | return "object" 60 | 61 | item_type = translate_type(resolved_type.type) 62 | 63 | if resolved_type.container == ContainerType.DICT: 64 | key_type = PRIMITIVE_TYPES["string"] 65 | return f"Map<{key_type}, {item_type}>" 66 | if resolved_type.container == ContainerType.LIST: 67 | return f"Array<{item_type}>" 68 | if resolved_type.container == ContainerType.SET: 69 | return f"Set<{item_type}>" 70 | 71 | raise ValueError(f"Unknown container type {resolved_type.container}") 72 | 73 | 74 | def contains_model(resolved_type): 75 | if resolved_type.container == ContainerType.LIST: 76 | return contains_model(resolved_type.type) 77 | return resolved_type.container == ContainerType.MODEL 78 | -------------------------------------------------------------------------------- /python/rpdk/typescript/templates/Makefile: -------------------------------------------------------------------------------- 1 | build-TypeFunction: 2 | npx npm ci 3 | npx npm run build 4 | -------------------------------------------------------------------------------- /python/rpdk/typescript/templates/README.md: -------------------------------------------------------------------------------- 1 | # {{ type_name }} 2 | 3 | Congratulations on starting development! Next steps: 4 | 5 | 1. Write the JSON schema describing your resource, [{{ schema_path.name }}](./{{ schema_path.name }}) 6 | 2. Implement your resource handlers in [handlers.ts](./{{ project_path }}/handlers.ts) 7 | 8 | > Don't modify [models.ts](./{{ project_path }}/models.ts) by hand, any modifications will be overwritten when the `generate` or `package` commands are run. 9 | 10 | Implement CloudFormation resource here. Each function must always return a ProgressEvent. 11 | 12 | ```typescript 13 | const progress = ProgressEvent.builder>() 14 | 15 | // Required 16 | // Must be one of OperationStatus.InProgress, OperationStatus.Failed, OperationStatus.Success 17 | .status(OperationStatus.InProgress) 18 | // Required on SUCCESS (except for LIST where resourceModels is required) 19 | // The current resource model after the operation; instance of ResourceModel class 20 | .resourceModel(model) 21 | .resourceModels(null) 22 | // Required on FAILED 23 | // Customer-facing message, displayed in e.g. CloudFormation stack events 24 | .message('') 25 | // Required on FAILED a HandlerErrorCode 26 | .errorCode(HandlerErrorCode.InternalFailure) 27 | // Optional 28 | // Use to store any state between re-invocation via IN_PROGRESS 29 | .callbackContext({}) 30 | // Required on IN_PROGRESS 31 | // The number of seconds to delay before re-invocation 32 | .callbackDelaySeconds(0) 33 | 34 | .build() 35 | ``` 36 | 37 | While importing the [{{ lib_name }}](https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin) library, failures can be passed back to CloudFormation by either raising an exception from `exceptions`, or setting the ProgressEvent's `status` to `OperationStatus.Failed` and `errorCode` to one of `HandlerErrorCode`. There is a static helper function, `ProgressEvent.failed`, for this common case. 38 | 39 | Keep in mind, during runtime all logs will be delivered to CloudWatch if you use the `log()` method from `LoggerProxy` class. 40 | -------------------------------------------------------------------------------- /python/rpdk/typescript/templates/handlers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | BaseResource, 4 | exceptions, 5 | handlerEvent, 6 | HandlerErrorCode, 7 | LoggerProxy, 8 | OperationStatus, 9 | Optional, 10 | ProgressEvent, 11 | ResourceHandlerRequest, 12 | SessionProxy, 13 | } from '{{lib_name}}'; 14 | import { ResourceModel, TypeConfigurationModel } from './models'; 15 | 16 | interface CallbackContext extends Record {} 17 | 18 | class Resource extends BaseResource { 19 | 20 | /** 21 | * CloudFormation invokes this handler when the resource is initially created 22 | * during stack create operations. 23 | * 24 | * @param session Current AWS session passed through from caller 25 | * @param request The request object for the provisioning request passed to the implementor 26 | * @param callbackContext Custom context object to allow the passing through of additional 27 | * state or metadata between subsequent retries 28 | * @param typeConfiguration Configuration data for this resource type, in the given account 29 | * and region 30 | * @param logger Logger to proxy requests to default publishers 31 | */ 32 | @handlerEvent(Action.Create) 33 | public async create( 34 | session: Optional, 35 | request: ResourceHandlerRequest, 36 | callbackContext: CallbackContext, 37 | logger: LoggerProxy, 38 | typeConfiguration: TypeConfigurationModel, 39 | ): Promise> { 40 | const model = new ResourceModel(request.desiredResourceState); 41 | const progress = ProgressEvent.progress>(model); 42 | // TODO: put code here 43 | 44 | // Example: 45 | try { 46 | if (session instanceof SessionProxy) { 47 | const client = session.client('S3'); 48 | } 49 | // Setting Status to success will signal to CloudFormation that the operation is complete 50 | progress.status = OperationStatus.Success; 51 | } catch(err) { 52 | logger.log(err); 53 | // exceptions module lets CloudFormation know the type of failure that occurred 54 | throw new exceptions.InternalFailure(err.message); 55 | // this can also be done by returning a failed progress event 56 | // return ProgressEvent.failed(HandlerErrorCode.InternalFailure, err.message); 57 | } 58 | return progress; 59 | } 60 | 61 | /** 62 | * CloudFormation invokes this handler when the resource is updated 63 | * as part of a stack update operation. 64 | * 65 | * @param session Current AWS session passed through from caller 66 | * @param request The request object for the provisioning request passed to the implementor 67 | * @param callbackContext Custom context object to allow the passing through of additional 68 | * state or metadata between subsequent retries 69 | * @param typeConfiguration Configuration data for this resource type, in the given account 70 | * and region 71 | * @param logger Logger to proxy requests to default publishers 72 | */ 73 | @handlerEvent(Action.Update) 74 | public async update( 75 | session: Optional, 76 | request: ResourceHandlerRequest, 77 | callbackContext: CallbackContext, 78 | logger: LoggerProxy, 79 | typeConfiguration: TypeConfigurationModel, 80 | ): Promise> { 81 | const model = new ResourceModel(request.desiredResourceState); 82 | const progress = ProgressEvent.progress>(model); 83 | // TODO: put code here 84 | progress.status = OperationStatus.Success; 85 | return progress; 86 | } 87 | 88 | /** 89 | * CloudFormation invokes this handler when the resource is deleted, either when 90 | * the resource is deleted from the stack as part of a stack update operation, 91 | * or the stack itself is deleted. 92 | * 93 | * @param session Current AWS session passed through from caller 94 | * @param request The request object for the provisioning request passed to the implementor 95 | * @param callbackContext Custom context object to allow the passing through of additional 96 | * state or metadata between subsequent retries 97 | * @param typeConfiguration Configuration data for this resource type, in the given account 98 | * and region 99 | * @param logger Logger to proxy requests to default publishers 100 | */ 101 | @handlerEvent(Action.Delete) 102 | public async delete( 103 | session: Optional, 104 | request: ResourceHandlerRequest, 105 | callbackContext: CallbackContext, 106 | logger: LoggerProxy, 107 | typeConfiguration: TypeConfigurationModel, 108 | ): Promise> { 109 | const model = new ResourceModel(request.desiredResourceState); 110 | const progress = ProgressEvent.progress>(); 111 | // TODO: put code here 112 | progress.status = OperationStatus.Success; 113 | return progress; 114 | } 115 | 116 | /** 117 | * CloudFormation invokes this handler as part of a stack update operation when 118 | * detailed information about the resource's current state is required. 119 | * 120 | * @param session Current AWS session passed through from caller 121 | * @param request The request object for the provisioning request passed to the implementor 122 | * @param callbackContext Custom context object to allow the passing through of additional 123 | * state or metadata between subsequent retries 124 | * @param typeConfiguration Configuration data for this resource type, in the given account 125 | * and region 126 | * @param logger Logger to proxy requests to default publishers 127 | */ 128 | @handlerEvent(Action.Read) 129 | public async read( 130 | session: Optional, 131 | request: ResourceHandlerRequest, 132 | callbackContext: CallbackContext, 133 | logger: LoggerProxy, 134 | typeConfiguration: TypeConfigurationModel, 135 | ): Promise> { 136 | const model = new ResourceModel(request.desiredResourceState); 137 | // TODO: put code here 138 | const progress = ProgressEvent.success>(model); 139 | return progress; 140 | } 141 | 142 | /** 143 | * CloudFormation invokes this handler when summary information about multiple 144 | * resources of this resource provider is required. 145 | * 146 | * @param session Current AWS session passed through from caller 147 | * @param request The request object for the provisioning request passed to the implementor 148 | * @param callbackContext Custom context object to allow the passing through of additional 149 | * state or metadata between subsequent retries 150 | * @param typeConfiguration Configuration data for this resource type, in the given account 151 | * and region 152 | * @param logger Logger to proxy requests to default publishers 153 | */ 154 | @handlerEvent(Action.List) 155 | public async list( 156 | session: Optional, 157 | request: ResourceHandlerRequest, 158 | callbackContext: CallbackContext, 159 | logger: LoggerProxy, 160 | typeConfiguration: TypeConfigurationModel, 161 | ): Promise> { 162 | const model = new ResourceModel(request.desiredResourceState); 163 | // TODO: put code here 164 | const progress = ProgressEvent.builder>() 165 | .status(OperationStatus.Success) 166 | .resourceModels([model]) 167 | .build(); 168 | return progress; 169 | } 170 | } 171 | 172 | // @ts-ignore // if running against v1.0.1 or earlier of plugin the 5th argument is not known but best to ignored (runtime code may warn) 173 | export const resource = new Resource(ResourceModel.TYPE_NAME, ResourceModel, null, null, TypeConfigurationModel)!; 174 | 175 | // Entrypoint for production usage after registered in CloudFormation 176 | export const entrypoint = resource.entrypoint; 177 | 178 | // Entrypoint used for local testing 179 | export const testEntrypoint = resource.testEntrypoint; 180 | -------------------------------------------------------------------------------- /python/rpdk/typescript/templates/models.ts: -------------------------------------------------------------------------------- 1 | // This is a generated file. Modifications will be overwritten. 2 | import { BaseModel, Dict, integer, Integer, Optional, transformValue } from '{{lib_name}}'; 3 | import { Exclude, Expose, Type, Transform } from 'class-transformer'; 4 | 5 | {% for model, properties in models.items() %} 6 | export class {{ model|uppercase_first_letter }} extends BaseModel { 7 | {% if model == "ResourceModel" %} 8 | @Exclude() 9 | public static readonly TYPE_NAME: string = '{{ type_name }}'; 10 | 11 | {% for identifier in primaryIdentifier %} 12 | {% set components = identifier.split("/") %} 13 | @Exclude() 14 | protected readonly IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}: string = '{{ identifier }}'; 15 | {% endfor -%} 16 | 17 | {% for identifiers in additionalIdentifiers %} 18 | {% for identifier in identifiers %} 19 | {% set components = identifier.split("/") %} 20 | @Exclude() 21 | protected readonly IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}: string = '{{ identifier }}'; 22 | {% endfor %} 23 | {% endfor %} 24 | {% endif %} 25 | 26 | {% for name, type in properties.items() %} 27 | {% set translated_type = type|translate_type %} 28 | {% set inner_type = type|get_inner_type %} 29 | @Expose({ name: '{{ name }}' }) 30 | {% if type|contains_model %} 31 | @Type(() => {{ inner_type.type }}) 32 | {% else %} 33 | @Transform( 34 | ({value, obj}) => 35 | transformValue({{ inner_type.wrapper_type }}, '{{ name|lowercase_first_letter|safe_reserved }}', value, obj, [{{ inner_type.classes|join(', ') }}]), 36 | { 37 | toClassOnly: true, 38 | } 39 | ) 40 | {% endif %} 41 | {{ name|lowercase_first_letter|safe_reserved }}?: Optional<{{ translated_type }}>; 42 | {% endfor %} 43 | 44 | {% if model == "ResourceModel" %} 45 | @Exclude() 46 | public getPrimaryIdentifier(): Dict { 47 | const identifier: Dict = {}; 48 | {% for identifier in primaryIdentifier %} 49 | {% set components = identifier.split("/") %} 50 | if (this.{{components[2]|lowercase_first_letter}} != null 51 | {%- for i in range(4, components|length + 1) -%} 52 | {#- #} && this 53 | {%- for component in components[2:i] -%} .{{component|lowercase_first_letter}} {%- endfor -%} 54 | {#- #} != null 55 | {%- endfor -%} 56 | ) { 57 | identifier[this.IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}] = this{% for component in components[2:] %}.{{component|lowercase_first_letter}}{% endfor %}; 58 | } 59 | 60 | {% endfor %} 61 | // only return the identifier if it can be used, i.e. if all components are present 62 | return Object.keys(identifier).length === {{ primaryIdentifier|length }} ? identifier : null; 63 | } 64 | 65 | @Exclude() 66 | public getAdditionalIdentifiers(): Array { 67 | const identifiers: Array = new Array(); 68 | {% for identifiers in additionalIdentifiers %} 69 | if (this.getIdentifier {%- for identifier in identifiers -%} _{{identifier.split("/")[-1]|uppercase_first_letter}} {%- endfor -%} () != null) { 70 | identifiers.push(this.getIdentifier{% for identifier in identifiers %}_{{identifier.split("/")[-1]|uppercase_first_letter}}{% endfor %}()); 71 | } 72 | {% endfor %} 73 | // only return the identifiers if any can be used 74 | return identifiers.length === 0 ? null : identifiers; 75 | } 76 | {% for identifiers in additionalIdentifiers %} 77 | 78 | @Exclude() 79 | public getIdentifier {%- for identifier in identifiers -%} _{{identifier.split("/")[-1]|uppercase_first_letter}} {%- endfor -%} (): Dict { 80 | const identifier: Dict = {}; 81 | {% for identifier in identifiers %} 82 | {% set components = identifier.split("/") %} 83 | if ((this as any).{{components[2]|lowercase_first_letter}} != null 84 | {%- for i in range(4, components|length + 1) -%} 85 | {#- #} && (this as any) 86 | {%- for component in components[2:i] -%} .{{component|lowercase_first_letter}} {%- endfor -%} 87 | {#- #} != null 88 | {%- endfor -%} 89 | ) { 90 | identifier[this.IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}] = (this as any){% for component in components[2:] %}.{{component|lowercase_first_letter}}{% endfor %}; 91 | } 92 | 93 | {% endfor %} 94 | // only return the identifier if it can be used, i.e. if all components are present 95 | return Object.keys(identifier).length === {{ identifiers|length }} ? identifier : null; 96 | } 97 | {% endfor %} 98 | {% endif %} 99 | } 100 | 101 | {% endfor -%} 102 | -------------------------------------------------------------------------------- /python/rpdk/typescript/templates/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{ name }}", 3 | "version": "0.1.0", 4 | "description": "{{ description }}", 5 | "private": true, 6 | "main": "dist/handlers.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "npx tsc --skipLibCheck", 12 | "prepack": "npm run build", 13 | "test": "echo \"Error: no test specified\" && exit 1" 14 | }, 15 | "dependencies": { 16 | "{{lib_name}}": "{{lib_path}}", 17 | "class-transformer": "0.3.1" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^20.0.0", 21 | "typescript": "^5.3.0" 22 | }, 23 | "optionalDependencies": { 24 | "aws-sdk": "^2.656.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /python/rpdk/typescript/templates/template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: AWS SAM template for the {{ resource_type }} resource type 4 | 5 | Globals: 6 | Function: 7 | Timeout: 180 # docker start-up times can be long for SAM CLI 8 | MemorySize: 256 9 | 10 | Resources: 11 | TestEntrypoint: 12 | Type: AWS::Serverless::Function 13 | Properties: 14 | {% for key, value in test_handler_params.items() %} 15 | {{ key }}: {{ value }} 16 | {% endfor %} 17 | 18 | TypeFunction: 19 | Type: AWS::Serverless::Function 20 | Properties: 21 | {% for key, value in handler_params.items() %} 22 | {{ key }}: {{ value }} 23 | {% endfor %} 24 | Metadata: 25 | BuildMethod: makefile 26 | -------------------------------------------------------------------------------- /python/rpdk/typescript/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from rpdk.core.exceptions import WizardValidationError 4 | 5 | # https://github.com/Microsoft/TypeScript/issues/2536 6 | LANGUAGE_KEYWORDS = { 7 | "abstract", 8 | "any", 9 | "as", 10 | "async", 11 | "await", 12 | "bigint", 13 | "boolean", 14 | "break", 15 | "case", 16 | "catch", 17 | "class", 18 | "configurable", 19 | "const", 20 | "constructor", 21 | "continue", 22 | "debugger", 23 | "declare", 24 | "default", 25 | "delete", 26 | "do", 27 | "else", 28 | "enum", 29 | "enumerable", 30 | "export", 31 | "extends", 32 | "false", 33 | "finally", 34 | "for", 35 | "from", 36 | "function", 37 | "get", 38 | "if", 39 | "in", 40 | "implements", 41 | "import", 42 | "instanceof", 43 | "interface", 44 | "is", 45 | "let", 46 | "module", 47 | "namespace", 48 | "never", 49 | "new", 50 | "null", 51 | "number", 52 | "of", 53 | "package", 54 | "private", 55 | "protected", 56 | "public", 57 | "readonly", 58 | "require", 59 | "return", 60 | "set", 61 | "static", 62 | "string", 63 | "super", 64 | "switch", 65 | "symbol", 66 | "this", 67 | "throw", 68 | "true", 69 | "try", 70 | "type", 71 | "typeof", 72 | "undefined", 73 | "value", 74 | "var", 75 | "void", 76 | "while", 77 | "with", 78 | "writable", 79 | "yield", 80 | } 81 | 82 | 83 | def safe_reserved(token): 84 | if token in LANGUAGE_KEYWORDS: 85 | return token + "_" 86 | return token 87 | 88 | 89 | def validate_codegen_model(default): 90 | pattern = r"^[1-2]$" 91 | 92 | def _validate_codegen_model(value): 93 | if not value: 94 | return default 95 | 96 | match = re.match(pattern, value) 97 | if not match: 98 | raise WizardValidationError("Invalid selection.") 99 | 100 | return value 101 | 102 | return _validate_codegen_model 103 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | description-file = README.md 4 | 5 | [flake8] 6 | exclude = 7 | .git, 8 | __pycache__, 9 | build, 10 | dist, 11 | *.pyc, 12 | *.egg-info, 13 | .cache, 14 | .eggs, 15 | .tox 16 | max-complexity = 10 17 | max-line-length = 88 18 | select = C,E,F,W,B,B950 19 | # C812, C815, W503 clash with black, F723 false positive 20 | ignore = E501,C812,C815,C816,W503,F723 21 | 22 | [isort] 23 | line_length = 88 24 | indent = ' ' 25 | multi_line_output = 3 26 | default_section = FIRSTPARTY 27 | skip = env 28 | include_trailing_comma = true 29 | combine_as_imports = True 30 | force_grid_wrap = 0 31 | known_first_party = rpdk 32 | known_third_party = boto3,botocore,jinja2,jsonschema,werkzeug,yaml,requests 33 | 34 | [tool:pytest] 35 | addopts = --cov-config=.coveragerc --cov-report xml:coverage/py/coverage.xml --cov-report html:coverage/py/html --doctest-modules 36 | # can't do anything about 3rd party modules, so don't spam us 37 | filterwarnings = 38 | ignore::DeprecationWarning:botocore 39 | ignore::DeprecationWarning:werkzeug 40 | ignore::DeprecationWarning:yaml 41 | 42 | [mypy-setuptools] # don't want to stub external libraries for now 43 | ignore_missing_imports = True 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Typescript language support for the CloudFormation CLI""" 3 | import os.path 4 | import re 5 | 6 | from setuptools import setup 7 | 8 | HERE = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | 11 | def read(*parts): 12 | with open(os.path.join(HERE, *parts), "r", encoding="utf-8") as fp: 13 | return fp.read() 14 | 15 | 16 | # https://packaging.python.org/guides/single-sourcing-package-version/ 17 | def find_version(*file_paths): 18 | version_file = read(*file_paths) 19 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 20 | if version_match: 21 | return version_match.group(1) 22 | raise RuntimeError("Unable to find version string.") 23 | 24 | 25 | setup( 26 | name="cloudformation-cli-typescript-plugin", 27 | version=find_version("python", "rpdk", "typescript", "__init__.py"), 28 | description=__doc__, 29 | long_description=read("README.md"), 30 | long_description_content_type="text/markdown", 31 | author="Amazon Web Services", 32 | author_email="aws-cloudformation-developers@amazon.com", 33 | url="https://github.com/aws-cloudformation/cloudformation-cli-typescript-plugin", 34 | packages=["rpdk.typescript"], 35 | package_dir={"": "python"}, 36 | # package_data -> use MANIFEST.in instead 37 | include_package_data=True, 38 | zip_safe=True, 39 | python_requires=">=3.8", 40 | install_requires=[ 41 | "cloudformation-cli>=0.1.14", 42 | "zipfile38>=0.0.3,<0.2", 43 | ], 44 | entry_points={ 45 | "rpdk.v1.languages": [ 46 | "typescript = rpdk.typescript.codegen:TypescriptLanguagePlugin", 47 | ], 48 | "rpdk.v1.parsers": [ 49 | "typescript = rpdk.typescript.parser:setup_subparser", 50 | ], 51 | }, 52 | license="Apache License 2.0", 53 | classifiers=[ 54 | "Development Status :: 5 - Production/Stable", 55 | "Environment :: Console", 56 | "Intended Audience :: Developers", 57 | "License :: OSI Approved :: Apache Software License", 58 | "Natural Language :: English", 59 | "Topic :: Software Development :: Build Tools", 60 | "Topic :: Software Development :: Code Generators", 61 | "Operating System :: OS Independent", 62 | "Programming Language :: Python :: 3 :: Only", 63 | "Programming Language :: Python :: 3.8", 64 | "Programming Language :: Python :: 3.9", 65 | "Programming Language :: Python :: 3.10", 66 | "Programming Language :: Python :: 3.11", 67 | ], 68 | keywords="Amazon Web Services AWS CloudFormation", 69 | ) 70 | -------------------------------------------------------------------------------- /src/exceptions.ts: -------------------------------------------------------------------------------- 1 | import { BaseModel, HandlerErrorCode } from './interface'; 2 | import { ProgressEvent } from './proxy'; 3 | 4 | export abstract class BaseHandlerException extends Error { 5 | static serialVersionUID = -1646136434112354328; 6 | 7 | public errorCode: HandlerErrorCode; 8 | 9 | public constructor(message?: any, errorCode?: HandlerErrorCode) { 10 | super(message); 11 | this.errorCode = 12 | errorCode || HandlerErrorCode[this.constructor.name as HandlerErrorCode]; 13 | Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain 14 | } 15 | 16 | public toProgressEvent(): ProgressEvent { 17 | return ProgressEvent.failed>(this.errorCode, this.toString()); 18 | } 19 | } 20 | 21 | export class NotUpdatable extends BaseHandlerException {} 22 | 23 | export class InvalidRequest extends BaseHandlerException {} 24 | 25 | export class InvalidTypeConfiguration extends BaseHandlerException { 26 | constructor(typeName: string, reason: string) { 27 | super( 28 | `Invalid TypeConfiguration provided for type '${typeName}'. Reason: ${reason}`, 29 | HandlerErrorCode.InvalidTypeConfiguration 30 | ); 31 | } 32 | } 33 | 34 | export class AccessDenied extends BaseHandlerException {} 35 | 36 | export class InvalidCredentials extends BaseHandlerException {} 37 | 38 | export class AlreadyExists extends BaseHandlerException { 39 | constructor(typeName: string, identifier: string) { 40 | super( 41 | `Resource of type '${typeName}' with identifier '${identifier}' already exists.` 42 | ); 43 | } 44 | } 45 | 46 | export class NotFound extends BaseHandlerException { 47 | constructor(typeName: string, identifier: string) { 48 | super( 49 | `Resource of type '${typeName}' with identifier '${identifier}' was not found.` 50 | ); 51 | } 52 | } 53 | 54 | export class ResourceConflict extends BaseHandlerException {} 55 | 56 | export class Throttling extends BaseHandlerException {} 57 | 58 | export class ServiceLimitExceeded extends BaseHandlerException {} 59 | 60 | export class NotStabilized extends BaseHandlerException {} 61 | 62 | export class GeneralServiceException extends BaseHandlerException {} 63 | 64 | export class ServiceInternalError extends BaseHandlerException {} 65 | 66 | export class NetworkFailure extends BaseHandlerException {} 67 | 68 | export class InternalFailure extends BaseHandlerException {} 69 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * as exceptions from './exceptions'; 2 | export * from './interface'; 3 | export * from './log-delivery'; 4 | export * from './metrics'; 5 | export * from './proxy'; 6 | export * from './resource'; 7 | export * from './recast'; 8 | export * from './utils'; 9 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { 3 | ClientRequestToken, 4 | LogGroupName, 5 | LogicalResourceId, 6 | NextToken, 7 | } from 'aws-sdk/clients/cloudformation'; 8 | import { Service } from 'aws-sdk/lib/service'; 9 | import { 10 | ClassTransformOptions, 11 | Exclude, 12 | Expose, 13 | instanceToPlain, 14 | plainToInstance, 15 | } from 'class-transformer'; 16 | 17 | export type Optional = T | undefined | null; 18 | export type Dict = Record; 19 | export type Constructor = new (...args: any[]) => T; 20 | export type integer = bigint; 21 | 22 | export type InstanceProperties< 23 | T extends object = Service, 24 | C extends Constructor = Constructor, 25 | > = keyof InstanceType; 26 | 27 | export type ServiceProperties< 28 | S extends Service = Service, 29 | C extends Constructor = Constructor, 30 | > = Exclude< 31 | InstanceProperties, 32 | InstanceProperties> 33 | >; 34 | 35 | export type OverloadedArguments = T extends { 36 | (...args: any[]): any; 37 | (params: infer P, callback: any): any; 38 | (callback: any): any; 39 | } 40 | ? P 41 | : T extends { 42 | (params: infer P, callback: any): any; 43 | (callback: any): any; 44 | } 45 | ? P 46 | : T extends (params: infer P, callback: any) => any 47 | ? P 48 | : any; 49 | 50 | export type OverloadedReturnType = T extends { 51 | (...args: any[]): any; 52 | (params: any, callback: any): infer R; 53 | (callback: any): any; 54 | } 55 | ? R 56 | : T extends { 57 | (params: any, callback: any): infer R; 58 | (callback: any): any; 59 | } 60 | ? R 61 | : T extends (callback: any) => infer R 62 | ? R 63 | : any; 64 | 65 | export interface Callable, T> { 66 | (...args: R): T; 67 | } 68 | 69 | // @ts-ignore 70 | // eslint-disable-next-line 71 | interface Integer extends BigInt { 72 | /** 73 | * Defines the default JSON representation of 74 | * Integer (BigInt) to be a number. 75 | */ 76 | toJSON(): number; 77 | 78 | /** Returns the primitive value of the specified object. */ 79 | valueOf(): integer; 80 | 81 | readonly [Symbol.toStringTag]: 'Integer'; 82 | } 83 | 84 | // @ts-ignore 85 | interface IntegerConstructor extends BigIntConstructor { 86 | (value?: bigint | integer | boolean | number | string): bigint; 87 | readonly prototype: Integer; 88 | /** 89 | * Returns true if the value passed is a safe integer 90 | * to be parsed as number. 91 | * @param value An integer value. 92 | */ 93 | isSafeInteger(value: integer): boolean; 94 | } 95 | 96 | /** 97 | * Wrapper with additional JSON serialization for bigint type 98 | */ 99 | // @ts-ignore 100 | export const Integer: IntegerConstructor = new Proxy(BigInt, { 101 | // @ts-ignore 102 | apply( 103 | target: IntegerConstructor, 104 | _thisArg: unknown, 105 | argArray?: unknown[] 106 | ): integer { 107 | target.prototype.toJSON = function (): number { 108 | return Number(this.valueOf()); 109 | }; 110 | const isSafeInteger = (value: bigint): boolean => { 111 | if ( 112 | value && 113 | (value < BigInt(Number.MIN_SAFE_INTEGER) || 114 | value > BigInt(Number.MAX_SAFE_INTEGER)) 115 | ) { 116 | return false; 117 | } 118 | return true; 119 | }; 120 | target.isSafeInteger = isSafeInteger; 121 | // @ts-expect-error argArray is unknown 122 | const value = target(...argArray); 123 | if (value && !isSafeInteger(value)) { 124 | throw new RangeError(`Value is not a safe integer: ${value.toString()}`); 125 | } 126 | return value; 127 | }, 128 | }) as IntegerConstructor; 129 | 130 | export enum Action { 131 | Create = 'CREATE', 132 | Read = 'READ', 133 | Update = 'UPDATE', 134 | Delete = 'DELETE', 135 | List = 'LIST', 136 | } 137 | 138 | export enum StandardUnit { 139 | Count = 'Count', 140 | Milliseconds = 'Milliseconds', 141 | } 142 | 143 | export enum MetricTypes { 144 | HandlerException = 'HandlerException', 145 | HandlerInvocationCount = 'HandlerInvocationCount', 146 | HandlerInvocationDuration = 'HandlerInvocationDuration', 147 | } 148 | 149 | export enum OperationStatus { 150 | Pending = 'PENDING', 151 | InProgress = 'IN_PROGRESS', 152 | Success = 'SUCCESS', 153 | Failed = 'FAILED', 154 | } 155 | 156 | export enum HandlerErrorCode { 157 | NotUpdatable = 'NotUpdatable', 158 | InvalidRequest = 'InvalidRequest', 159 | AccessDenied = 'AccessDenied', 160 | InvalidCredentials = 'InvalidCredentials', 161 | AlreadyExists = 'AlreadyExists', 162 | NotFound = 'NotFound', 163 | ResourceConflict = 'ResourceConflict', 164 | Throttling = 'Throttling', 165 | ServiceLimitExceeded = 'ServiceLimitExceeded', 166 | NotStabilized = 'NotStabilized', 167 | GeneralServiceException = 'GeneralServiceException', 168 | ServiceInternalError = 'ServiceInternalError', 169 | NetworkFailure = 'NetworkFailure', 170 | InternalFailure = 'InternalFailure', 171 | InvalidTypeConfiguration = 'InvalidTypeConfiguration', 172 | } 173 | 174 | export interface Credentials { 175 | accessKeyId: string; 176 | secretAccessKey: string; 177 | sessionToken: string; 178 | } 179 | 180 | /** 181 | * Base class for data transfer objects that will contain 182 | * serialization and deserialization mechanisms. 183 | */ 184 | export abstract class BaseDto { 185 | constructor(partial?: unknown) { 186 | if (partial === undefined) { 187 | return this; 188 | } 189 | if (partial) { 190 | Object.assign(this, partial); 191 | } 192 | } 193 | 194 | @Exclude() 195 | static serializer = { 196 | instanceToPlain, 197 | plainToInstance, 198 | }; 199 | 200 | @Exclude() 201 | public serialize(removeNull = true): Dict { 202 | const data: Dict = JSON.parse(JSON.stringify(instanceToPlain(this))); 203 | // To match Java serialization, which drops 'null' values, and the 204 | // contract tests currently expect this also. 205 | if (removeNull) { 206 | for (const key in data) { 207 | const value = data[key]; 208 | if (value == null) { 209 | delete data[key]; 210 | } 211 | } 212 | } 213 | return data; 214 | } 215 | 216 | public static deserialize( 217 | this: new () => T, 218 | jsonData: Dict, 219 | options: ClassTransformOptions = {} 220 | ): T { 221 | if (jsonData == null) { 222 | return null; 223 | } 224 | return plainToInstance(this, jsonData, { 225 | enableImplicitConversion: false, 226 | excludeExtraneousValues: true, 227 | ...options, 228 | }); 229 | } 230 | 231 | @Exclude() 232 | public toJSON(key?: string): Dict { 233 | return this.serialize(); 234 | } 235 | } 236 | 237 | export interface RequestContext { 238 | invocation: number; 239 | callbackContext: T; 240 | cloudWatchEventsRuleName: string; 241 | cloudWatchEventsTargetId: string; 242 | } 243 | 244 | export class BaseModel extends BaseDto { 245 | constructor(partial?: unknown) { 246 | super(); 247 | if (partial) { 248 | Object.assign(this, partial); 249 | } 250 | } 251 | 252 | @Exclude() 253 | protected static readonly TYPE_NAME?: string; 254 | 255 | @Exclude() 256 | public getTypeName(): string { 257 | return Object.getPrototypeOf(this).constructor.TYPE_NAME; 258 | } 259 | } 260 | 261 | export class TestEvent extends BaseDto { 262 | @Expose() credentials: Credentials; 263 | @Expose() action: Action; 264 | @Expose() request: Dict; 265 | @Expose() callbackContext: Dict; 266 | @Expose() region?: string; 267 | } 268 | 269 | export class RequestData extends BaseDto { 270 | @Expose() resourceProperties: T; 271 | @Expose() providerLogGroupName?: LogGroupName; 272 | @Expose() logicalResourceId?: LogicalResourceId; 273 | @Expose() systemTags?: Dict; 274 | @Expose() stackTags?: Dict; 275 | // platform credentials aren't really optional, but this is used to 276 | // zero them out to prevent e.g. accidental logging 277 | @Expose() callerCredentials?: Credentials; 278 | @Expose() providerCredentials?: Credentials; 279 | @Expose() previousResourceProperties?: T; 280 | @Expose() previousStackTags?: Dict; 281 | @Expose() typeConfiguration?: Dict; 282 | } 283 | 284 | export class HandlerRequest extends BaseDto { 285 | @Expose() action: Action; 286 | @Expose() awsAccountId: string; 287 | @Expose() bearerToken: string; 288 | @Expose() region: string; 289 | @Expose() requestData: RequestData; 290 | @Expose() responseEndpoint?: string; 291 | @Expose() stackId?: string; 292 | @Expose() resourceType?: string; 293 | @Expose() resourceTypeVersion?: string; 294 | @Expose() callbackContext?: CallbackT; 295 | @Expose() nextToken?: NextToken; 296 | @Expose() requestContext?: RequestContext; 297 | } 298 | 299 | export class BaseResourceHandlerRequest extends BaseDto { 300 | @Expose() clientRequestToken: ClientRequestToken; 301 | @Expose() desiredResourceState?: T; 302 | @Expose() previousResourceState?: T; 303 | @Expose() desiredResourceTags: Dict; 304 | @Expose() previousResourceTags: Dict; 305 | @Expose() systemTags: Dict; 306 | @Expose() awsAccountId: string; 307 | @Expose() awsPartition: string; 308 | @Expose() logicalResourceIdentifier?: LogicalResourceId; 309 | @Expose() nextToken?: NextToken; 310 | @Expose() region: string; 311 | } 312 | 313 | export class UnmodeledRequest extends BaseResourceHandlerRequest { 314 | @Exclude() 315 | public static fromUnmodeled(obj: Dict): UnmodeledRequest { 316 | return UnmodeledRequest.deserialize(obj); 317 | } 318 | 319 | @Exclude() 320 | public static getPartition(region: Optional): Optional { 321 | if (!region) { 322 | return null; 323 | } 324 | if (region.startsWith('cn')) { 325 | return 'aws-cn'; 326 | } 327 | if (region.startsWith('us-gov')) { 328 | return 'aws-gov'; 329 | } 330 | return 'aws'; 331 | } 332 | 333 | @Exclude() 334 | public toModeled( 335 | modelTypeReference: Constructor & { deserialize?: Function } 336 | ): BaseResourceHandlerRequest { 337 | const request = BaseResourceHandlerRequest.deserialize< 338 | BaseResourceHandlerRequest 339 | >({ 340 | clientRequestToken: this.clientRequestToken, 341 | desiredResourceTags: this.desiredResourceTags, 342 | previousResourceTags: this.previousResourceTags, 343 | systemTags: this.systemTags, 344 | awsAccountId: this.awsAccountId, 345 | logicalResourceIdentifier: this.logicalResourceIdentifier, 346 | nextToken: this.nextToken, 347 | region: this.region, 348 | awsPartition: UnmodeledRequest.getPartition(this.region), 349 | }); 350 | request.desiredResourceState = modelTypeReference.deserialize( 351 | this.desiredResourceState || {} 352 | ); 353 | request.previousResourceState = modelTypeReference.deserialize( 354 | this.previousResourceState || {} 355 | ); 356 | return request; 357 | } 358 | } 359 | 360 | export interface CfnResponse { 361 | errorCode?: HandlerErrorCode; 362 | status: OperationStatus; 363 | message: string; 364 | resourceModel?: T; 365 | resourceModels?: T[]; 366 | nextToken?: NextToken; 367 | } 368 | 369 | export interface LambdaContext { 370 | functionName?: string; 371 | functionVersion?: string; 372 | invokedFunctionArn?: string; 373 | memoryLimitInMB?: number; 374 | awsRequestId?: string; 375 | callbackWaitsForEmptyEventLoop?: boolean; 376 | getRemainingTimeInMillis(): number; 377 | } 378 | -------------------------------------------------------------------------------- /src/metrics.ts: -------------------------------------------------------------------------------- 1 | import CloudWatch, { Dimension, DimensionName } from 'aws-sdk/clients/cloudwatch'; 2 | import { ServiceConfigurationOptions } from 'aws-sdk/lib/service'; 3 | 4 | import { Logger } from './log-delivery'; 5 | import { AwsTaskWorkerPool, ExtendedClient, SessionProxy } from './proxy'; 6 | import { Action, MetricTypes, StandardUnit } from './interface'; 7 | import { BaseHandlerException } from './exceptions'; 8 | import { Queue } from './utils'; 9 | 10 | const METRIC_NAMESPACE_ROOT = 'AWS/CloudFormation'; 11 | 12 | export type DimensionRecord = Record; 13 | 14 | export function formatDimensions(dimensions: DimensionRecord): Array { 15 | const formatted: Array = []; 16 | for (const key in dimensions) { 17 | const value = dimensions[key]; 18 | const dimension: Dimension = { 19 | Name: key, 20 | Value: value, 21 | }; 22 | formatted.push(dimension); 23 | } 24 | return formatted; 25 | } 26 | 27 | /** 28 | * A cloudwatch based metric publisher. 29 | * Given a resource type and session, 30 | * this publisher will publish metrics to CloudWatch. 31 | * Can be used with the MetricsPublisherProxy. 32 | */ 33 | export class MetricsPublisher { 34 | private resourceNamespace: string; 35 | private client: ExtendedClient; 36 | 37 | constructor( 38 | private readonly session: SessionProxy, 39 | private readonly logger: Logger, 40 | private readonly resourceType: string, 41 | protected readonly workerPool?: AwsTaskWorkerPool 42 | ) { 43 | this.resourceNamespace = resourceType.replace(/::/g, '/'); 44 | } 45 | 46 | public refreshClient(options?: ServiceConfigurationOptions): void { 47 | this.client = this.session.client(CloudWatch, options, this.workerPool); 48 | } 49 | 50 | async publishMetric( 51 | metricName: MetricTypes, 52 | dimensions: DimensionRecord, 53 | unit: StandardUnit, 54 | value: number, 55 | timestamp: Date 56 | ): Promise { 57 | if (!this.client) { 58 | throw Error( 59 | 'CloudWatch client was not initialized. You must call refreshClient() first.' 60 | ); 61 | } 62 | try { 63 | const metric = await this.client.makeRequestPromise('putMetricData', { 64 | Namespace: `${METRIC_NAMESPACE_ROOT}/${this.resourceNamespace}`, 65 | MetricData: [ 66 | { 67 | MetricName: metricName, 68 | Dimensions: formatDimensions(dimensions), 69 | Unit: unit, 70 | Timestamp: timestamp, 71 | Value: value, 72 | }, 73 | ], 74 | }); 75 | this.log('Response from "putMetricData"', metric); 76 | } catch (err) { 77 | // @ts-expect-error fix in aws sdk v3 78 | if (err.retryable) { 79 | throw err; 80 | } else { 81 | // @ts-expect-error fix in aws sdk v3 82 | this.log(`An error occurred while publishing metrics: ${err.message}`); 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Publishes an exception based metric 89 | */ 90 | async publishExceptionMetric( 91 | timestamp: Date, 92 | action: Action, 93 | error: Error 94 | ): Promise { 95 | const dimensions: DimensionRecord = { 96 | DimensionKeyActionType: action, 97 | DimensionKeyExceptionType: 98 | (error as BaseHandlerException).errorCode || error.constructor.name, 99 | DimensionKeyResourceType: this.resourceType, 100 | }; 101 | return this.publishMetric( 102 | MetricTypes.HandlerException, 103 | dimensions, 104 | StandardUnit.Count, 105 | 1.0, 106 | timestamp 107 | ); 108 | } 109 | 110 | /** 111 | * Publishes a metric related to invocations 112 | */ 113 | async publishInvocationMetric(timestamp: Date, action: Action): Promise { 114 | const dimensions: DimensionRecord = { 115 | DimensionKeyActionType: action, 116 | DimensionKeyResourceType: this.resourceType, 117 | }; 118 | return this.publishMetric( 119 | MetricTypes.HandlerInvocationCount, 120 | dimensions, 121 | StandardUnit.Count, 122 | 1.0, 123 | timestamp 124 | ); 125 | } 126 | 127 | /** 128 | * Publishes an duration metric 129 | */ 130 | async publishDurationMetric( 131 | timestamp: Date, 132 | action: Action, 133 | milliseconds: number 134 | ): Promise { 135 | const dimensions: DimensionRecord = { 136 | DimensionKeyActionType: action, 137 | DimensionKeyResourceType: this.resourceType, 138 | }; 139 | return this.publishMetric( 140 | MetricTypes.HandlerInvocationDuration, 141 | dimensions, 142 | StandardUnit.Milliseconds, 143 | milliseconds, 144 | timestamp 145 | ); 146 | } 147 | 148 | /** 149 | * Publishes an log delivery exception metric 150 | */ 151 | async publishLogDeliveryExceptionMetric( 152 | timestamp: Date, 153 | error: Error 154 | ): Promise { 155 | const dimensions: DimensionRecord = { 156 | DimensionKeyActionType: 'ProviderLogDelivery', 157 | DimensionKeyExceptionType: 158 | (error as BaseHandlerException).errorCode || error.constructor.name, 159 | DimensionKeyResourceType: this.resourceType, 160 | }; 161 | try { 162 | return await this.publishMetric( 163 | MetricTypes.HandlerException, 164 | dimensions, 165 | StandardUnit.Count, 166 | 1.0, 167 | timestamp 168 | ); 169 | } catch (err) { 170 | this.log(err); 171 | } 172 | return Promise.resolve(null); 173 | } 174 | 175 | private log(message?: any, ...optionalParams: any[]): void { 176 | if (this.logger) { 177 | this.logger.log(message, ...optionalParams); 178 | } 179 | } 180 | } 181 | 182 | /** 183 | * A proxy for publishing metrics to multiple publishers. 184 | * Iterates over available publishers and publishes. 185 | */ 186 | export class MetricsPublisherProxy { 187 | private publishers: Array = []; 188 | private queue = new Queue(); 189 | 190 | /** 191 | * Adds a metrics publisher to the list of publishers 192 | */ 193 | addMetricsPublisher(metricsPublisher?: MetricsPublisher): void { 194 | if (metricsPublisher) { 195 | this.publishers.push(metricsPublisher); 196 | } 197 | } 198 | 199 | /** 200 | * Publishes an exception based metric to the list of publishers 201 | */ 202 | async publishExceptionMetric( 203 | timestamp: Date, 204 | action: Action, 205 | error: Error 206 | ): Promise { 207 | for (const publisher of this.publishers) { 208 | await this.queue.enqueue(() => 209 | publisher.publishExceptionMetric(timestamp, action, error) 210 | ); 211 | } 212 | } 213 | 214 | /** 215 | * Publishes a metric related to invocations to the list of publishers 216 | */ 217 | async publishInvocationMetric(timestamp: Date, action: Action): Promise { 218 | for (const publisher of this.publishers) { 219 | await this.queue.enqueue(() => 220 | publisher.publishInvocationMetric(timestamp, action) 221 | ); 222 | } 223 | } 224 | 225 | /** 226 | * Publishes a duration metric to the list of publishers 227 | */ 228 | async publishDurationMetric( 229 | timestamp: Date, 230 | action: Action, 231 | milliseconds: number 232 | ): Promise { 233 | for (const publisher of this.publishers) { 234 | await this.queue.enqueue(() => 235 | publisher.publishDurationMetric(timestamp, action, milliseconds) 236 | ); 237 | } 238 | } 239 | 240 | /** 241 | * Publishes a log delivery exception metric to the list of publishers 242 | */ 243 | async publishLogDeliveryExceptionMetric( 244 | timestamp: Date, 245 | error: Error 246 | ): Promise { 247 | for (const publisher of this.publishers) { 248 | await this.queue.enqueue(() => 249 | publisher.publishLogDeliveryExceptionMetric(timestamp, error) 250 | ); 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- 1 | import { AWSError } from 'aws-sdk'; 2 | import Aws from 'aws-sdk/clients/all'; 3 | import { NextToken } from 'aws-sdk/clients/cloudformation'; 4 | import { CredentialsOptions } from 'aws-sdk/lib/credentials'; 5 | import { PromiseResult } from 'aws-sdk/lib/request'; 6 | import { Service, ServiceConfigurationOptions } from 'aws-sdk/lib/service'; 7 | import { EventEmitter } from 'events'; 8 | import { builder, IBuilder } from '@org-formation/tombok'; 9 | import { Exclude, Expose, Type } from 'class-transformer'; 10 | 11 | import { 12 | BaseDto, 13 | BaseResourceHandlerRequest, 14 | BaseModel, 15 | Constructor, 16 | Dict, 17 | HandlerErrorCode, 18 | OperationStatus, 19 | OverloadedArguments, 20 | ServiceProperties, 21 | } from './interface'; 22 | 23 | type ClientMap = typeof Aws; 24 | export type ClientName = keyof ClientMap; 25 | export type Client = InstanceType; 26 | 27 | export type Result = T extends (...args: any) => infer R ? R : any; 28 | export type Input = T extends (...args: infer P) => any ? P : never; 29 | export type ServiceOptions = ConstructorParameters< 30 | Constructor 31 | >[0]; 32 | export type ServiceOperation< 33 | S extends Service = Service, 34 | C extends Constructor = Constructor, 35 | O extends ServiceProperties = ServiceProperties, 36 | E extends Error = AWSError, 37 | > = InstanceType[O] & { 38 | promise(): Promise>; 39 | }; 40 | export type InferredResult< 41 | S extends Service = Service, 42 | C extends Constructor = Constructor, 43 | O extends ServiceProperties = ServiceProperties, 44 | E extends Error = AWSError, 45 | N extends ServiceOperation = ServiceOperation, 46 | > = Input['promise']>['then']>[0]>[0]; 47 | 48 | type AwsTaskSignature = < 49 | S extends Service = Service, 50 | C extends Constructor = Constructor, 51 | O extends ServiceProperties = ServiceProperties, 52 | E extends Error = AWSError, 53 | N extends ServiceOperation = ServiceOperation, 54 | >( 55 | params: any 56 | ) => Promise>; 57 | 58 | /** 59 | * Promise final result Type from a AWS Service Function 60 | * 61 | * @param S Type of the AWS Service 62 | * @param C Type of the constructor function of the AWS Service 63 | * @param O Names of the operations (method) within the service 64 | * @param E Type of the error thrown by the service function 65 | * @param N Type of the service function inferred by the given operation name 66 | */ 67 | export type ExtendedClient = S & { 68 | serviceIdentifier?: string; 69 | makeRequestPromise?: < 70 | C extends Constructor = Constructor, 71 | O extends ServiceProperties = ServiceProperties, 72 | E extends Error = AWSError, 73 | N extends ServiceOperation = ServiceOperation, 74 | >( 75 | operation: O, 76 | input?: OverloadedArguments, 77 | headers?: Record 78 | ) => Promise>; 79 | }; 80 | export interface AwsTaskWorkerPool extends EventEmitter { 81 | runAwsTask: AwsTaskSignature; 82 | shutdown: (doDestroy?: boolean) => Promise; 83 | completed?: number; 84 | duration?: number; 85 | } 86 | export interface Session { 87 | client: ( 88 | service: ClientName | S | Constructor, 89 | options?: ServiceConfigurationOptions 90 | ) => ExtendedClient; 91 | } 92 | 93 | export class SessionProxy implements Session { 94 | constructor(private options: ServiceConfigurationOptions) {} 95 | 96 | private extendAwsClient< 97 | S extends Service = Service, 98 | C extends Constructor = Constructor, 99 | O extends ServiceProperties = ServiceProperties, 100 | E extends Error = AWSError, 101 | N extends ServiceOperation = ServiceOperation, 102 | >( 103 | service: S, 104 | options?: ServiceConfigurationOptions, 105 | workerPool?: AwsTaskWorkerPool 106 | ): ExtendedClient { 107 | const client: ExtendedClient = new Proxy(service, { 108 | get(obj: ExtendedClient, prop: string) { 109 | if ('makeRequestPromise' === prop) { 110 | // Extend AWS client with promisified make request method 111 | // that can be used with worker pool 112 | return async ( 113 | operation: O, 114 | input?: OverloadedArguments, 115 | headers?: Record 116 | ): Promise> => { 117 | if (workerPool && workerPool.runAwsTask) { 118 | try { 119 | const result = await workerPool.runAwsTask< 120 | S, 121 | C, 122 | O, 123 | E, 124 | N 125 | >({ 126 | name: obj.serviceIdentifier, 127 | options, 128 | operation, 129 | input, 130 | headers, 131 | }); 132 | return result; 133 | } catch (err) { 134 | console.log(err); 135 | } 136 | } 137 | const request = obj.makeRequest(operation as string, input); 138 | const headerEntries = Object.entries(headers || {}); 139 | if (headerEntries.length) { 140 | request.on('build', () => { 141 | for (const [key, value] of headerEntries) { 142 | request.httpRequest.headers[key] = value; 143 | } 144 | }); 145 | } 146 | return await request.promise(); 147 | }; 148 | } 149 | return obj[prop]; 150 | }, 151 | }); 152 | if (client.config && client.config.update) { 153 | client.config.update(options); 154 | } 155 | return client; 156 | } 157 | 158 | public client( 159 | service: ClientName | S | Constructor, 160 | options?: ServiceConfigurationOptions, 161 | workerPool?: AwsTaskWorkerPool 162 | ): ExtendedClient { 163 | const updatedConfig = { ...this.options, ...options }; 164 | let ctor: Constructor; 165 | let client: ExtendedClient; 166 | if (typeof service === 'string') { 167 | // Kept for backward compatibility 168 | const clients: { [K in ClientName]: ClientMap[K] } = Aws; 169 | ctor = clients[service] as unknown as Constructor; 170 | } else if (typeof service === 'function') { 171 | ctor = service as Constructor; 172 | } else { 173 | client = this.extendAwsClient(service, updatedConfig, workerPool); 174 | } 175 | if (!client) { 176 | client = this.extendAwsClient( 177 | new ctor(updatedConfig), 178 | updatedConfig, 179 | workerPool 180 | ); 181 | } 182 | return client; 183 | } 184 | 185 | get configuration(): ServiceConfigurationOptions { 186 | return this.options; 187 | } 188 | 189 | public static getSession( 190 | credentials?: CredentialsOptions, 191 | region?: string 192 | ): SessionProxy | null { 193 | if (!credentials) { 194 | return null; 195 | } 196 | return new SessionProxy({ 197 | credentials, 198 | region, 199 | }); 200 | } 201 | } 202 | 203 | @builder 204 | export class ProgressEvent< 205 | ResourceT extends BaseModel = BaseModel, 206 | CallbackT = Dict, 207 | > extends BaseDto { 208 | /** 209 | * The status indicates whether the handler has reached a terminal state or is 210 | * still computing and requires more time to complete 211 | */ 212 | @Expose() status: OperationStatus; 213 | 214 | /** 215 | * If OperationStatus is FAILED or IN_PROGRESS, an error code should be provided 216 | */ 217 | @Expose() errorCode?: HandlerErrorCode; 218 | 219 | /** 220 | * The handler can (and should) specify a contextual information message which 221 | * can be shown to callers to indicate the nature of a progress transition or 222 | * callback delay; for example a message indicating "propagating to edge" 223 | */ 224 | @Expose() message = ''; 225 | 226 | /** 227 | * The callback context is an arbitrary datum which the handler can return in an 228 | * IN_PROGRESS event to allow the passing through of additional state or 229 | * metadata between subsequent retries; for example to pass through a Resource 230 | * identifier which can be used to continue polling for stabilization 231 | */ 232 | @Expose() callbackContext?: CallbackT; 233 | 234 | /** 235 | * A callback will be scheduled with an initial delay of no less than the number 236 | * of seconds specified in the progress event. 237 | */ 238 | @Expose() callbackDelaySeconds = 0; 239 | 240 | /** 241 | * The output resource instance populated by a READ for synchronous results and 242 | * by CREATE/UPDATE/DELETE for final response validation/confirmation 243 | */ 244 | @Expose() resourceModel?: ResourceT; 245 | 246 | /** 247 | * The output resource instances populated by a LIST for synchronous results 248 | */ 249 | @Expose() resourceModels?: Array; 250 | 251 | /** 252 | * The token used to request additional pages of resources for a LIST operation 253 | */ 254 | @Expose() nextToken?: NextToken; 255 | 256 | constructor(partial?: Partial) { 257 | super(); 258 | if (partial) { 259 | Object.assign(this, partial); 260 | } 261 | } 262 | 263 | // TODO: remove workaround when decorator mutation implemented: https://github.com/microsoft/TypeScript/issues/4881 264 | @Exclude() 265 | public static builder(template?: Partial): IBuilder { 266 | /* istanbul ignore next */ 267 | return null; 268 | } 269 | 270 | /** 271 | * Convenience method for constructing FAILED response 272 | */ 273 | @Exclude() 274 | public static failed( 275 | errorCode: HandlerErrorCode, 276 | message: string 277 | ): T { 278 | const event = ProgressEvent.builder() 279 | .status(OperationStatus.Failed) 280 | .errorCode(errorCode) 281 | .message(message) 282 | .build(); 283 | return event; 284 | } 285 | 286 | /** 287 | * Convenience method for constructing IN_PROGRESS response 288 | */ 289 | @Exclude() 290 | public static progress(model?: any, ctx?: any): T { 291 | const progress = ProgressEvent.builder().status(OperationStatus.InProgress); 292 | if (ctx) { 293 | progress.callbackContext(ctx); 294 | } 295 | if (model) { 296 | progress.resourceModel(model); 297 | } 298 | const event = progress.build(); 299 | return event; 300 | } 301 | 302 | /** 303 | * Convenience method for constructing a SUCCESS response 304 | */ 305 | @Exclude() 306 | public static success(model?: any, ctx?: any): T { 307 | const event = ProgressEvent.progress(model, ctx); 308 | event.status = OperationStatus.Success; 309 | return event; 310 | } 311 | } 312 | 313 | /** 314 | * This interface describes the request object for the provisioning request 315 | * passed to the implementor. It is transformed from an instance of 316 | * HandlerRequest by the LambdaWrapper to only items of concern 317 | * 318 | * @param Type of resource model being provisioned 319 | */ 320 | export class ResourceHandlerRequest< 321 | T extends BaseModel, 322 | > extends BaseResourceHandlerRequest {} 323 | -------------------------------------------------------------------------------- /src/recast.ts: -------------------------------------------------------------------------------- 1 | import { InvalidRequest } from './exceptions'; 2 | import { BaseModel, Callable, integer, Integer } from './interface'; 3 | 4 | type primitive = string | number | boolean | bigint | integer | object; 5 | 6 | /** 7 | * CloudFormation recasts all primitive types as strings, this tries to set them back to 8 | * the types defined in the model class 9 | */ 10 | export const recastPrimitive = ( 11 | cls: Callable, 12 | k: string, 13 | v: string 14 | ): primitive => { 15 | if (Object.is(cls, Object)) { 16 | // If the type is plain object, we cannot guess what the original type was, 17 | // so we leave it as a string 18 | return v; 19 | } 20 | if ( 21 | (Object.is(cls, Boolean) || 22 | Object.is(cls, Number) || 23 | Object.is(cls, BigInt) || 24 | Object.is(cls, Integer)) && 25 | v.length === 0 26 | ) { 27 | return null; 28 | } 29 | if (Object.is(cls, Boolean)) { 30 | if (v.toLowerCase() === 'true') { 31 | return true; 32 | } 33 | if (v.toLowerCase() === 'false') { 34 | return false; 35 | } 36 | throw new InvalidRequest(`Value for ${k} "${v}" is not boolean`); 37 | } 38 | return cls(v).valueOf(); 39 | }; 40 | 41 | export const transformValue = ( 42 | cls: any, 43 | key: string, 44 | value: any, 45 | obj: any, 46 | classes: any[] = [], 47 | index = 0 48 | ): primitive => { 49 | if (value == null) { 50 | return value; 51 | } 52 | if (index === 0) { 53 | classes.push(cls); 54 | } 55 | const currentClass = classes[index || 0]; 56 | if (value instanceof Map || Object.is(currentClass, Map)) { 57 | const temp = new Map(value instanceof Map ? value : Object.entries(value)); 58 | temp.forEach((item: any, itemKey: string) => { 59 | temp.set(itemKey, transformValue(cls, key, item, obj, classes, index + 1)); 60 | }); 61 | return new Map(temp); 62 | } else if (value instanceof Set || Object.is(currentClass, Set)) { 63 | const temp = Array.from(value).map((item: any) => { 64 | return transformValue(cls, key, item, obj, classes, index + 1); 65 | }); 66 | return new Set(temp); 67 | } else if (Array.isArray(value) || Array.isArray(currentClass)) { 68 | return value.map((item: any) => { 69 | return transformValue(cls, key, item, obj, classes, index + 1); 70 | }); 71 | } else { 72 | // if type is plain object, we leave it as is 73 | if (Object.is(cls, Object) || cls.prototype instanceof BaseModel) { 74 | return value; 75 | } 76 | if ( 77 | Object.is(cls, String) || 78 | cls.name === 'String' || 79 | Object.is(cls, Number) || 80 | cls.name === 'Number' || 81 | Object.is(cls, Boolean) || 82 | cls.name === 'Boolean' || 83 | Object.is(cls, BigInt) || 84 | cls.name === 'BigInt' || 85 | Object.is(cls, Integer) || 86 | cls.name === 'Integer' 87 | ) { 88 | if (typeof value === 'string') { 89 | return recastPrimitive(cls, key, value); 90 | } 91 | return value; 92 | } else { 93 | throw new InvalidRequest( 94 | `Unsupported type: ${typeof value} [${cls.name}] for ${key}` 95 | ); 96 | } 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | // eslint-disable-next-line 3 | const replaceAllShim = require('string.prototype.replaceall'); 4 | 5 | type PromiseFunction = () => Promise; 6 | 7 | interface QueueItem { 8 | promise: PromiseFunction; 9 | reject: (value: any) => void; 10 | resolve: (reason: any) => void; 11 | } 12 | 13 | /** 14 | * Wait for a specified amount of time. 15 | * 16 | * @param {number} seconds Seconds that we will wait 17 | */ 18 | export async function delay(seconds: number): Promise { 19 | return new Promise((_) => setTimeout(() => _(), seconds * 1000)); 20 | } 21 | 22 | /** 23 | * Class to track progress of multiple asynchronous tasks, 24 | * so that we know when they are all finished. 25 | */ 26 | export class ProgressTracker extends EventEmitter { 27 | #tasksSubmitted: number; 28 | #tasksCompleted: number; 29 | #tasksFailed: number; 30 | #done: boolean; 31 | 32 | constructor() { 33 | super(); 34 | this.restart(); 35 | this.on('include', (kind: string) => { 36 | // console.debug(`Progress type being included [${kind}]`, this.message); 37 | if (kind !== 'submitted' && this.isFinished) { 38 | process.nextTick(() => this.emit('finished')); 39 | } 40 | }); 41 | } 42 | 43 | get done(): boolean { 44 | return this.#done; 45 | } 46 | 47 | set done(value: boolean) { 48 | this.#done = !!value; 49 | } 50 | 51 | end(): void { 52 | this.#done = true; 53 | } 54 | 55 | restart(): void { 56 | this.#tasksSubmitted = 0; 57 | this.#tasksCompleted = 0; 58 | this.#tasksFailed = 0; 59 | this.#done = false; 60 | } 61 | 62 | addSubmitted(): void { 63 | if (this.isFinished) { 64 | throw Error( 65 | 'Not allowed to submit a new task after progress tracker has been closed.' 66 | ); 67 | } 68 | this.#tasksSubmitted++; 69 | process.nextTick(() => this.emit('include', 'submitted')); 70 | } 71 | 72 | addCompleted(): void { 73 | this.#tasksCompleted++; 74 | process.nextTick(() => this.emit('include', 'completed')); 75 | } 76 | 77 | addFailed(): void { 78 | this.#tasksFailed++; 79 | process.nextTick(() => this.emit('include', 'failed')); 80 | } 81 | 82 | get completed(): number { 83 | return this.#tasksCompleted + this.#tasksFailed; 84 | } 85 | 86 | get isFinished(): boolean { 87 | return this.done && this.completed === this.#tasksSubmitted; 88 | } 89 | 90 | get message(): string { 91 | return ( 92 | `${this.#tasksCompleted} of ${this.#tasksSubmitted} completed` + 93 | ` ${((this.#tasksCompleted / this.#tasksSubmitted) * 100).toFixed(2)}%` + 94 | ` [${this.#tasksFailed} failed]` 95 | ); 96 | } 97 | 98 | async waitCompletion(): Promise { 99 | await new Promise((resolve) => { 100 | if (this.isFinished) { 101 | resolve(); 102 | } else { 103 | this.once('finished', resolve); 104 | } 105 | }); 106 | this.restart(); 107 | } 108 | } 109 | 110 | export class Queue { 111 | #queue: QueueItem[] = []; 112 | #pendingPromise = false; 113 | 114 | public enqueue(promise: PromiseFunction): Promise { 115 | return new Promise((resolve, reject) => { 116 | this.#queue.push({ 117 | promise, 118 | resolve, 119 | reject, 120 | }); 121 | this.dequeue(); 122 | }); 123 | } 124 | 125 | private dequeue(): boolean { 126 | if (this.#pendingPromise) { 127 | return false; 128 | } 129 | const item = this.#queue.shift(); 130 | if (!item) { 131 | return false; 132 | } 133 | try { 134 | this.#pendingPromise = true; 135 | item.promise() 136 | .then((value) => { 137 | this.#pendingPromise = false; 138 | item.resolve(value); 139 | this.dequeue(); 140 | }) 141 | .catch((err) => { 142 | this.#pendingPromise = false; 143 | item.reject(err); 144 | this.dequeue(); 145 | }); 146 | } catch (err) { 147 | this.#pendingPromise = false; 148 | item.reject(err); 149 | this.dequeue(); 150 | } 151 | return true; 152 | } 153 | } 154 | 155 | /** 156 | * Replaces all matched values in a string. 157 | * 158 | * @param original The original string where the replacement will take place. 159 | * @param substr A literal string that is to be replaced by newSubstr. 160 | * @param newSubstr The string that replaces the substring specified by the specified substr parameter. 161 | * @returns A new string, with all matches of a pattern replaced by a replacement. 162 | */ 163 | export function replaceAll( 164 | original: string, 165 | substr: string, 166 | newSubstr: string 167 | ): string { 168 | if (original) { 169 | return replaceAllShim(original, substr, newSubstr); 170 | } 171 | return original; 172 | } 173 | 174 | /** 175 | * Recursively apply provided operation on object and all of the object properties that are either object or function. 176 | * 177 | * @param obj The object to freeze 178 | * @returns Initial object with frozen properties applied on it 179 | */ 180 | export function deepFreeze( 181 | obj: Record | Array | Function, 182 | processed = new Set() 183 | ): Record { 184 | if ( 185 | // Prevent circular reference 186 | processed.has(obj) || 187 | // Prevent not supported types 188 | !obj || 189 | obj === Function.prototype || 190 | !(typeof obj === 'object' || typeof obj === 'function' || Array.isArray(obj)) || 191 | // Prevent issue with freezing buffers 192 | ArrayBuffer.isView(obj) 193 | ) { 194 | return obj; 195 | } 196 | 197 | processed.add(obj); 198 | 199 | // Retrieve the property names defined on object 200 | let propNames: Array = Object.getOwnPropertyNames(obj); 201 | 202 | if (Object.getOwnPropertySymbols) { 203 | propNames = propNames.concat(Object.getOwnPropertySymbols(obj)); 204 | } 205 | 206 | // Freeze properties before freezing self 207 | for (const name of propNames) { 208 | const value = obj[name as any]; 209 | 210 | deepFreeze(value, processed); 211 | } 212 | 213 | return Object.isFrozen(obj) ? obj : Object.freeze(obj); 214 | } 215 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli-typescript-plugin/c6741811d918954b2bead0aa844400d236537581/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/sample-model.ts: -------------------------------------------------------------------------------- 1 | // This file represents a model built from a complex schema to test that ser/de is 2 | // happening as expected 3 | /* eslint-disable @typescript-eslint/no-use-before-define */ 4 | import { BaseModel, integer, Integer, Optional, transformValue } from '../../src'; 5 | import { Exclude, Expose, Transform, Type } from 'class-transformer'; 6 | 7 | export class ResourceModel extends BaseModel { 8 | @Exclude() 9 | public static readonly TYPE_NAME: string = 'Organization::Service::ComplexResource'; 10 | 11 | @Expose({ name: 'ListListAny' }) 12 | @Transform( 13 | ({ value, obj }) => 14 | transformValue(Object, 'listListAny', value, obj, [Array, Array]), 15 | { 16 | toClassOnly: true, 17 | } 18 | ) 19 | listListAny?: Optional>>; 20 | @Expose({ name: 'ListSetInt' }) 21 | @Transform( 22 | ({ value, obj }) => 23 | transformValue(Integer, 'listSetInt', value, obj, [Array, Set]), 24 | { 25 | toClassOnly: true, 26 | } 27 | ) 28 | listSetInt?: Optional>>; 29 | @Expose({ name: 'ListListInt' }) 30 | @Transform( 31 | ({ value, obj }) => 32 | transformValue(Integer, 'listListInt', value, obj, [Array, Array]), 33 | { 34 | toClassOnly: true, 35 | } 36 | ) 37 | listListInt?: Optional>>; 38 | @Expose({ name: 'ASet' }) 39 | @Transform(({ value, obj }) => transformValue(Object, 'aSet', value, obj, [Set]), { 40 | toClassOnly: true, 41 | }) 42 | aSet?: Optional>; 43 | @Expose({ name: 'AnotherSet' }) 44 | @Transform( 45 | ({ value, obj }) => transformValue(String, 'anotherSet', value, obj, [Set]), 46 | { 47 | toClassOnly: true, 48 | } 49 | ) 50 | anotherSet?: Optional>; 51 | @Expose({ name: 'AFreeformDict' }) 52 | @Transform( 53 | ({ value, obj }) => transformValue(Object, 'aFreeformDict', value, obj, [Map]), 54 | { 55 | toClassOnly: true, 56 | } 57 | ) 58 | aFreeformDict?: Optional>; 59 | @Expose({ name: 'ANumberDict' }) 60 | @Transform( 61 | ({ value, obj }) => transformValue(Number, 'aNumberDict', value, obj, [Map]), 62 | { 63 | toClassOnly: true, 64 | } 65 | ) 66 | aNumberDict?: Optional>; 67 | @Expose({ name: 'AnInt' }) 68 | @Transform(({ value, obj }) => transformValue(Integer, 'anInt', value, obj), { 69 | toClassOnly: true, 70 | }) 71 | anInt?: Optional; 72 | @Expose({ name: 'ABool' }) 73 | @Transform(({ value, obj }) => transformValue(Boolean, 'aBool', value, obj), { 74 | toClassOnly: true, 75 | }) 76 | aBool?: Optional; 77 | @Expose({ name: 'NestedList' }) 78 | @Type(() => NestedList) 79 | nestedList?: Optional>>; 80 | @Expose({ name: 'AList' }) 81 | @Type(() => AList) 82 | aList?: Optional>; 83 | @Expose({ name: 'ADict' }) 84 | @Type(() => ADict) 85 | aDict?: Optional>; 86 | } 87 | 88 | export class NestedList extends BaseModel { 89 | @Expose({ name: 'NestedListBool' }) 90 | @Transform( 91 | ({ value, obj }) => transformValue(Boolean, 'nestedListBool', value, obj), 92 | { 93 | toClassOnly: true, 94 | } 95 | ) 96 | nestedListBool?: Optional; 97 | @Expose({ name: 'NestedListList' }) 98 | @Transform( 99 | ({ value, obj }) => transformValue(Number, 'nestedListList', value, obj), 100 | { 101 | toClassOnly: true, 102 | } 103 | ) 104 | nestedListList?: Optional; 105 | } 106 | 107 | export class AList extends BaseModel { 108 | @Expose({ name: 'DeeperBool' }) 109 | @Transform(({ value, obj }) => transformValue(Boolean, 'deeperBool', value, obj), { 110 | toClassOnly: true, 111 | }) 112 | deeperBool?: Optional; 113 | @Expose({ name: 'DeeperList' }) 114 | @Transform( 115 | ({ value, obj }) => transformValue(Integer, 'deeperList', value, obj, [Array]), 116 | { 117 | toClassOnly: true, 118 | } 119 | ) 120 | deeperList?: Optional>; 121 | @Expose({ name: 'DeeperDictInList' }) 122 | @Type(() => DeeperDictInList) 123 | deeperDictInList?: Optional; 124 | } 125 | 126 | export class DeeperDictInList extends BaseModel { 127 | @Expose({ name: 'DeepestBool' }) 128 | @Transform(({ value, obj }) => transformValue(Boolean, 'deepestBool', value, obj), { 129 | toClassOnly: true, 130 | }) 131 | deepestBool?: Optional; 132 | @Expose({ name: 'DeepestList' }) 133 | @Transform( 134 | ({ value, obj }) => transformValue(Integer, 'deepestList', value, obj, [Array]), 135 | { 136 | toClassOnly: true, 137 | } 138 | ) 139 | deepestList?: Optional>; 140 | } 141 | 142 | export class ADict extends BaseModel { 143 | @Expose({ name: 'DeepBool' }) 144 | @Transform(({ value, obj }) => transformValue(Boolean, 'deepBool', value, obj), { 145 | toClassOnly: true, 146 | }) 147 | deepBool?: Optional; 148 | @Expose({ name: 'DeepList' }) 149 | @Transform( 150 | ({ value, obj }) => transformValue(Integer, 'deepList', value, obj, [Array]), 151 | { 152 | toClassOnly: true, 153 | } 154 | ) 155 | deepList?: Optional>; 156 | @Expose({ name: 'DeepDict' }) 157 | @Type(() => DeepDict) 158 | deepDict?: Optional; 159 | } 160 | 161 | export class DeepDict extends BaseModel { 162 | @Expose({ name: 'DeeperBool' }) 163 | @Transform(({ value, obj }) => transformValue(Boolean, 'deeperBool', value, obj), { 164 | toClassOnly: true, 165 | }) 166 | deeperBool?: Optional; 167 | @Expose({ name: 'DeeperList' }) 168 | @Transform( 169 | ({ value, obj }) => transformValue(Integer, 'deeperList', value, obj, [Array]), 170 | { 171 | toClassOnly: true, 172 | } 173 | ) 174 | deeperList?: Optional>; 175 | @Expose({ name: 'DeeperDict' }) 176 | @Type(() => DeeperDict) 177 | deeperDict?: Optional; 178 | } 179 | 180 | export class DeeperDict extends BaseModel { 181 | @Expose({ name: 'DeepestBool' }) 182 | @Transform(({ value, obj }) => transformValue(Boolean, 'deepestBool', value, obj), { 183 | toClassOnly: true, 184 | }) 185 | deepestBool?: Optional; 186 | @Expose({ name: 'DeepestList' }) 187 | @Transform( 188 | ({ value, obj }) => transformValue(Integer, 'deepestList', value, obj, [Array]), 189 | { 190 | toClassOnly: true, 191 | } 192 | ) 193 | deepestList?: Optional>; 194 | } 195 | 196 | export class TagsModel extends BaseModel { 197 | @Expose({ name: 'Tags' }) 198 | @Transform(({ value, obj }) => transformValue(Tag, 'tags', value, obj, [Set]), { 199 | toClassOnly: true, 200 | }) 201 | tags?: Optional>; 202 | } 203 | 204 | class Tag extends BaseModel { 205 | @Expose({ name: 'Name' }) 206 | name: string; 207 | @Expose({ name: 'Value' }) 208 | value: string; 209 | } 210 | 211 | export class SimpleResourceModel extends BaseModel { 212 | @Exclude() 213 | public static readonly TYPE_NAME: string = 'Organization::Service::SimpleResource'; 214 | 215 | @Expose({ name: 'ANumber' }) 216 | @Transform(({ value, obj }) => transformValue(Number, 'aNumber', value, obj), { 217 | toClassOnly: true, 218 | }) 219 | aNumber?: Optional; 220 | @Expose({ name: 'ABoolean' }) 221 | @Transform(({ value, obj }) => transformValue(Boolean, 'aBoolean', value, obj), { 222 | toClassOnly: true, 223 | }) 224 | aBoolean?: Optional; 225 | } 226 | 227 | export class SimpleStateModel extends BaseModel { 228 | @Exclude() 229 | public static readonly TYPE_NAME: string = 'Organization::Service::SimpleState'; 230 | 231 | @Expose() 232 | @Transform(({ value, obj }) => transformValue(String, 'state', value, obj), { 233 | toClassOnly: true, 234 | }) 235 | state?: Optional; 236 | } 237 | 238 | export class SerializableModel extends BaseModel { 239 | public static readonly TYPE_NAME: string = 'Organization::Service::Serializable'; 240 | 241 | @Expose() somekey?: Optional; 242 | @Expose() somestring?: Optional; 243 | @Expose() someotherkey?: Optional; 244 | @Expose({ name: 'SomeInt' }) 245 | @Transform(({ value, obj }) => transformValue(Integer, 'someint', value, obj), { 246 | toClassOnly: true, 247 | }) 248 | someint?: Optional; 249 | } 250 | -------------------------------------------------------------------------------- /tests/lib/exceptions.test.ts: -------------------------------------------------------------------------------- 1 | import * as exceptions from '~/exceptions'; 2 | import { HandlerErrorCode, OperationStatus } from '~/interface'; 3 | 4 | type Exceptions = keyof typeof exceptions; 5 | 6 | describe('when getting exceptions', () => { 7 | test('all error codes have exceptions', () => { 8 | expect(exceptions.BaseHandlerException).toBeDefined(); 9 | for (const errorCode in HandlerErrorCode) { 10 | const exceptionName = errorCode as Exceptions; 11 | expect(exceptions[exceptionName].prototype).toBeInstanceOf( 12 | exceptions.BaseHandlerException 13 | ); 14 | } 15 | }); 16 | 17 | test('exception to progress event', () => { 18 | for (const errorCode in HandlerErrorCode) { 19 | const exceptionName = errorCode as Exceptions; 20 | let e: exceptions.BaseHandlerException; 21 | try { 22 | // @ts-expect-error resolve later 23 | e = new exceptions[exceptionName](null, null); 24 | } catch (err) { 25 | // @ts-expect-error resolve later 26 | e = new exceptions[exceptionName]( 27 | 'Foo::Bar::Baz', 28 | errorCode as HandlerErrorCode 29 | ); 30 | } 31 | const progressEvent = e.toProgressEvent(); 32 | expect(progressEvent.status).toBe(OperationStatus.Failed); 33 | expect(progressEvent.errorCode).toBe( 34 | HandlerErrorCode[errorCode as HandlerErrorCode] 35 | ); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/lib/interface.test.ts: -------------------------------------------------------------------------------- 1 | import { Integer, UnmodeledRequest } from '~/interface'; 2 | import { SerializableModel } from '../data/sample-model'; 3 | 4 | describe('when getting interface', () => { 5 | test('base resource model get type name', () => { 6 | const model = new SerializableModel(); 7 | expect(model.getTypeName()).toBe(SerializableModel.TYPE_NAME); 8 | }); 9 | 10 | test('base resource model deserialize', () => { 11 | const model = SerializableModel.deserialize(null); 12 | expect(model).toBeNull(); 13 | }); 14 | 15 | test('base resource model serialize', () => { 16 | const model = SerializableModel.deserialize({ 17 | somekey: 'a', 18 | somestring: '', 19 | someotherkey: null, 20 | someint: null, 21 | }); 22 | const serialized = JSON.parse(JSON.stringify(model)); 23 | expect(Object.keys(serialized).length).toBe(2); 24 | expect(serialized.somekey).toBe('a'); 25 | expect(serialized.somestring).toBe(''); 26 | expect(serialized.someotherkey).not.toBeDefined(); 27 | }); 28 | 29 | test('base resource model to plain object', () => { 30 | const model = SerializableModel.deserialize({ 31 | somekey: 'a', 32 | someotherkey: 'b', 33 | }); 34 | const obj = model.toJSON(); 35 | expect(obj).toMatchObject({ 36 | somekey: 'a', 37 | someotherkey: 'b', 38 | }); 39 | }); 40 | 41 | test('integer serialize from number to number', () => { 42 | const valueNumber = 123597129357; 43 | expect(typeof valueNumber).toBe('number'); 44 | const valueInteger = Integer(valueNumber); 45 | expect(typeof valueInteger).toBe('bigint'); 46 | const serialized = JSON.parse(JSON.stringify(valueInteger)); 47 | expect(typeof serialized).toBe('number'); 48 | expect(serialized).toBe(valueNumber); 49 | }); 50 | 51 | test('integer serialize invalid number', () => { 52 | const parseInteger = () => { 53 | Integer(Math.pow(2, 53)); 54 | }; 55 | expect(parseInteger).toThrow(RangeError); 56 | expect(parseInteger).toThrow('Value is not a safe integer'); 57 | }); 58 | 59 | test('integer serialize from string to number', () => { 60 | const model = SerializableModel.deserialize({ 61 | SomeInt: '35190274', 62 | }); 63 | expect(model['someint']).toBe(Integer(35190274)); 64 | const serialized = model.serialize(); 65 | expect(typeof serialized['SomeInt']).toBe('number'); 66 | expect(serialized['SomeInt']).toBe(35190274); 67 | }); 68 | 69 | test('unmodeled request partion', () => { 70 | const partionMap = [null, 'aws', 'aws-cn', 'aws-gov']; 71 | [null, 'us-east-1', 'cn-region1', 'us-gov-region1'].forEach( 72 | (region: string, index: number) => { 73 | const partion = UnmodeledRequest.getPartition(region); 74 | expect(partion).toBe(partionMap[index]); 75 | } 76 | ); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /tests/lib/metrics.test.ts: -------------------------------------------------------------------------------- 1 | import CloudWatch from 'aws-sdk/clients/cloudwatch'; 2 | import awsUtil from 'aws-sdk/lib/util'; 3 | import WorkerPoolAwsSdk from 'worker-pool-aws-sdk'; 4 | 5 | import { Action, MetricTypes, ServiceProperties, StandardUnit } from '~/interface'; 6 | import { SessionProxy } from '~/proxy'; 7 | import { 8 | DimensionRecord, 9 | formatDimensions, 10 | MetricsPublisher, 11 | MetricsPublisherProxy, 12 | } from '~/metrics'; 13 | 14 | const mockResult = (output: any): jest.Mock => { 15 | return jest.fn().mockReturnValue({ 16 | promise: jest.fn().mockResolvedValue(output), 17 | }); 18 | }; 19 | 20 | jest.mock('aws-sdk/clients/cloudwatch'); 21 | 22 | describe('when getting metrics', () => { 23 | const MOCK_DATE = new Date('2020-01-01T23:05:38.964Z'); 24 | const RESOURCE_TYPE = 'Aa::Bb::Cc'; 25 | const NAMESPACE = 'AWS/CloudFormation/Aa/Bb/Cc'; 26 | const AWS_CONFIG = { 27 | region: 'us-east-1', 28 | credentials: { 29 | accessKeyId: 'AAAAA', 30 | secretAccessKey: '11111', 31 | }, 32 | }; 33 | 34 | let session: SessionProxy; 35 | let workerPool: WorkerPoolAwsSdk; 36 | let proxy: MetricsPublisherProxy; 37 | let publisher: MetricsPublisher; 38 | let cloudwatch: jest.Mock>; 39 | let putMetricData: jest.Mock; 40 | 41 | beforeAll(() => { 42 | session = new SessionProxy(AWS_CONFIG); 43 | jest.spyOn(WorkerPoolAwsSdk.prototype, 'runTask').mockRejectedValue( 44 | Error('Method runTask should not be called.') 45 | ); 46 | workerPool = new WorkerPoolAwsSdk({ minThreads: 1, maxThreads: 1 }); 47 | workerPool.runAwsTask = null; 48 | }); 49 | 50 | beforeEach(() => { 51 | putMetricData = mockResult({ ResponseMetadata: { RequestId: 'mock-request' } }); 52 | cloudwatch = CloudWatch as unknown as jest.Mock; 53 | cloudwatch.mockImplementation((config = {}) => { 54 | const returnValue: jest.Mocked> = { 55 | putMetricData, 56 | }; 57 | const ctor = CloudWatch; 58 | ctor['serviceIdentifier'] = 'cloudwatch'; 59 | return { 60 | ...returnValue, 61 | config: { ...AWS_CONFIG, ...config, update: () => undefined }, 62 | constructor: ctor, 63 | makeRequest: ( 64 | operation: ServiceProperties, 65 | params?: Record 66 | ): any => { 67 | return returnValue[operation](params as any); 68 | }, 69 | }; 70 | }); 71 | proxy = new MetricsPublisherProxy(); 72 | publisher = new MetricsPublisher(session, console, RESOURCE_TYPE, workerPool); 73 | proxy.addMetricsPublisher(publisher); 74 | publisher.refreshClient(); 75 | workerPool.restart(); 76 | }); 77 | 78 | afterEach(() => { 79 | jest.clearAllMocks(); 80 | jest.restoreAllMocks(); 81 | }); 82 | 83 | afterAll(async () => { 84 | await workerPool.shutdown(); 85 | }); 86 | 87 | test('format dimensions', () => { 88 | const dimensions: DimensionRecord = { 89 | MyDimensionKeyOne: 'valOne', 90 | MyDimensionKeyTwo: 'valTwo', 91 | }; 92 | const result = formatDimensions(dimensions); 93 | expect(result).toMatchObject([ 94 | { Name: 'MyDimensionKeyOne', Value: 'valOne' }, 95 | { Name: 'MyDimensionKeyTwo', Value: 'valTwo' }, 96 | ]); 97 | }); 98 | 99 | test('put metric catches error', async () => { 100 | const spyLogger: jest.SpyInstance = jest.spyOn(publisher['logger'], 'log'); 101 | putMetricData.mockReturnValueOnce({ 102 | promise: jest.fn().mockRejectedValueOnce( 103 | awsUtil.error(new Error(), { 104 | code: 'InternalServiceError', 105 | message: 106 | 'An error occurred (InternalServiceError) when ' + 107 | 'calling the PutMetricData operation: ', 108 | retryable: false, 109 | }) 110 | ), 111 | }); 112 | const dimensions: DimensionRecord = { 113 | DimensionKeyActionType: Action.Create, 114 | DimensionKeyResourceType: RESOURCE_TYPE, 115 | }; 116 | await publisher.publishMetric( 117 | MetricTypes.HandlerInvocationCount, 118 | dimensions, 119 | StandardUnit.Count, 120 | 1.0, 121 | MOCK_DATE 122 | ); 123 | expect(putMetricData).toHaveBeenCalledTimes(1); 124 | expect(putMetricData).toHaveBeenCalledWith({ 125 | MetricData: [ 126 | { 127 | Dimensions: [ 128 | { 129 | Name: 'DimensionKeyActionType', 130 | Value: 'CREATE', 131 | }, 132 | { 133 | Name: 'DimensionKeyResourceType', 134 | Value: 'Aa::Bb::Cc', 135 | }, 136 | ], 137 | MetricName: MetricTypes.HandlerInvocationCount, 138 | Timestamp: MOCK_DATE, 139 | Unit: StandardUnit.Count, 140 | Value: 1.0, 141 | }, 142 | ], 143 | Namespace: NAMESPACE, 144 | }); 145 | expect(spyLogger).toHaveBeenCalledTimes(1); 146 | expect(spyLogger).toHaveBeenCalledWith( 147 | 'An error occurred while ' + 148 | 'publishing metrics: An error occurred (InternalServiceError) ' + 149 | 'when calling the PutMetricData operation: ' 150 | ); 151 | }); 152 | 153 | test('publish exception metric', async () => { 154 | await proxy.publishExceptionMetric( 155 | MOCK_DATE, 156 | Action.Create, 157 | new Error('fake-err') 158 | ); 159 | expect(putMetricData).toHaveBeenCalledTimes(1); 160 | expect(putMetricData).toHaveBeenCalledWith({ 161 | MetricData: [ 162 | { 163 | Dimensions: [ 164 | { 165 | Name: 'DimensionKeyActionType', 166 | Value: 'CREATE', 167 | }, 168 | { 169 | Name: 'DimensionKeyExceptionType', 170 | Value: 'Error', 171 | }, 172 | { 173 | Name: 'DimensionKeyResourceType', 174 | Value: 'Aa::Bb::Cc', 175 | }, 176 | ], 177 | MetricName: MetricTypes.HandlerException, 178 | Timestamp: MOCK_DATE, 179 | Unit: StandardUnit.Count, 180 | Value: 1.0, 181 | }, 182 | ], 183 | Namespace: NAMESPACE, 184 | }); 185 | }); 186 | 187 | test('publish invocation metric', async () => { 188 | await proxy.publishInvocationMetric(MOCK_DATE, Action.Create); 189 | expect(putMetricData).toHaveBeenCalledTimes(1); 190 | expect(putMetricData).toHaveBeenCalledWith({ 191 | MetricData: [ 192 | { 193 | Dimensions: [ 194 | { 195 | Name: 'DimensionKeyActionType', 196 | Value: 'CREATE', 197 | }, 198 | { 199 | Name: 'DimensionKeyResourceType', 200 | Value: 'Aa::Bb::Cc', 201 | }, 202 | ], 203 | MetricName: MetricTypes.HandlerInvocationCount, 204 | Timestamp: MOCK_DATE, 205 | Unit: StandardUnit.Count, 206 | Value: 1.0, 207 | }, 208 | ], 209 | Namespace: NAMESPACE, 210 | }); 211 | }); 212 | 213 | test('publish duration metric', async () => { 214 | await proxy.publishDurationMetric(MOCK_DATE, Action.Create, 100); 215 | expect(putMetricData).toHaveBeenCalledTimes(1); 216 | expect(putMetricData).toHaveBeenCalledWith({ 217 | MetricData: [ 218 | { 219 | Dimensions: [ 220 | { 221 | Name: 'DimensionKeyActionType', 222 | Value: 'CREATE', 223 | }, 224 | { 225 | Name: 'DimensionKeyResourceType', 226 | Value: 'Aa::Bb::Cc', 227 | }, 228 | ], 229 | MetricName: MetricTypes.HandlerInvocationDuration, 230 | Timestamp: MOCK_DATE, 231 | Unit: StandardUnit.Milliseconds, 232 | Value: 100, 233 | }, 234 | ], 235 | Namespace: NAMESPACE, 236 | }); 237 | }); 238 | 239 | test('publish log delivery exception metric', async () => { 240 | await proxy.publishLogDeliveryExceptionMetric(MOCK_DATE, new TypeError('test')); 241 | expect(putMetricData).toHaveBeenCalledTimes(1); 242 | expect(putMetricData).toHaveBeenCalledWith({ 243 | MetricData: [ 244 | { 245 | Dimensions: [ 246 | { 247 | Name: 'DimensionKeyActionType', 248 | Value: 'ProviderLogDelivery', 249 | }, 250 | { 251 | Name: 'DimensionKeyExceptionType', 252 | Value: 'TypeError', 253 | }, 254 | { 255 | Name: 'DimensionKeyResourceType', 256 | Value: 'Aa::Bb::Cc', 257 | }, 258 | ], 259 | MetricName: MetricTypes.HandlerException, 260 | Timestamp: MOCK_DATE, 261 | Unit: StandardUnit.Count, 262 | Value: 1.0, 263 | }, 264 | ], 265 | Namespace: NAMESPACE, 266 | }); 267 | }); 268 | 269 | test('publish log delivery exception metric with error', async () => { 270 | const spyLogger: jest.SpyInstance = jest.spyOn(publisher['logger'], 'log'); 271 | const spyPublishLog: jest.SpyInstance = jest.spyOn( 272 | publisher, 273 | 'publishLogDeliveryExceptionMetric' 274 | ); 275 | const errorObject = { 276 | code: 'InternalServiceError', 277 | message: 'Sorry', 278 | retryable: true, 279 | }; 280 | putMetricData.mockReturnValueOnce({ 281 | promise: jest 282 | .fn() 283 | .mockRejectedValueOnce(awsUtil.error(new Error(), errorObject)), 284 | }); 285 | await proxy.publishLogDeliveryExceptionMetric(MOCK_DATE, new TypeError('test')); 286 | expect(putMetricData).toHaveBeenCalledTimes(1); 287 | expect(putMetricData).toHaveBeenCalledWith({ 288 | MetricData: expect.any(Array), 289 | Namespace: NAMESPACE, 290 | }); 291 | expect(spyLogger).toHaveBeenCalledTimes(1); 292 | expect(spyLogger).toHaveBeenCalledWith(expect.objectContaining(errorObject)); 293 | expect(spyPublishLog).toHaveReturnedWith(Promise.resolve(null)); 294 | }); 295 | 296 | test('metrics publisher without refreshing client', async () => { 297 | expect.assertions(1); 298 | const metricsPublisher = new MetricsPublisher(session, console, RESOURCE_TYPE); 299 | try { 300 | await metricsPublisher.publishMetric( 301 | MetricTypes.HandlerInvocationCount, 302 | null, 303 | StandardUnit.Count, 304 | 1.0, 305 | MOCK_DATE 306 | ); 307 | } catch (e) { 308 | if (e instanceof Error) { 309 | expect(e.message).toMatch(/CloudWatch client was not initialized/); 310 | } 311 | } 312 | }); 313 | 314 | test('metrics publisher proxy add metrics publisher null safe', () => { 315 | const proxy = new MetricsPublisherProxy(); 316 | proxy.addMetricsPublisher(null); 317 | proxy.addMetricsPublisher(undefined); 318 | expect(proxy['publishers']).toMatchObject([]); 319 | }); 320 | }); 321 | -------------------------------------------------------------------------------- /tests/lib/proxy.test.ts: -------------------------------------------------------------------------------- 1 | import STS from 'aws-sdk/clients/sts'; 2 | import WorkerPoolAwsSdk from 'worker-pool-aws-sdk'; 3 | 4 | import { ProgressEvent, SessionProxy } from '~/proxy'; 5 | import { BaseModel, HandlerErrorCode, OperationStatus, Optional } from '~/interface'; 6 | 7 | jest.mock('aws-sdk/clients/sts'); 8 | jest.mock('worker-pool-aws-sdk'); 9 | 10 | const mockResult = (output: any): jest.Mock => { 11 | return jest.fn().mockReturnValue({ 12 | promise: jest.fn().mockResolvedValue(output), 13 | httpRequest: { headers: {} }, 14 | on: jest.fn().mockImplementation((_event: string, listener: () => void) => { 15 | if (listener) { 16 | listener(); 17 | } 18 | }), 19 | }); 20 | }; 21 | 22 | describe('when getting session proxy', () => { 23 | class ResourceModel extends BaseModel { 24 | constructor(partial?: unknown) { 25 | super(); 26 | if (partial) { 27 | Object.assign(this, partial); 28 | } 29 | } 30 | public static readonly TYPE_NAME: string = 'Test::Resource::Model'; 31 | 32 | public somekey: Optional; 33 | public someotherkey: Optional; 34 | } 35 | 36 | afterEach(() => { 37 | jest.clearAllMocks(); 38 | jest.restoreAllMocks(); 39 | }); 40 | 41 | describe('session proxy', () => { 42 | const AWS_CONFIG = { 43 | region: 'us-east-1', 44 | credentials: { 45 | accessKeyId: 'AAAAA', 46 | secretAccessKey: '11111', 47 | }, 48 | }; 49 | 50 | test('should return modified client with worker pool', async () => { 51 | const workerPool = new WorkerPoolAwsSdk({ minThreads: 1, maxThreads: 1 }); 52 | workerPool.runTask = null; 53 | workerPool.runAwsTask = jest.fn().mockResolvedValue(true); 54 | const proxy = new SessionProxy(AWS_CONFIG); 55 | const client = proxy.client(new STS(), null, workerPool); 56 | expect(proxy).toBeInstanceOf(SessionProxy); 57 | expect(proxy.configuration).toMatchObject(AWS_CONFIG); 58 | const result = await client.makeRequestPromise('getCallerIdentity', {}); 59 | expect(result).toBe(true); 60 | expect(workerPool.runAwsTask).toHaveBeenCalledTimes(1); 61 | }); 62 | 63 | test('should return modified client with service instance input', async () => { 64 | const workerPool = new WorkerPoolAwsSdk({ minThreads: 1, maxThreads: 1 }); 65 | workerPool.runTask = null; 66 | workerPool.runAwsTask = jest.fn().mockRejectedValue(null); 67 | const proxy = new SessionProxy(AWS_CONFIG); 68 | const modifiedConfig = { ...AWS_CONFIG, region: 'us-east-2' }; 69 | const mockMakeRequest = mockResult(true); 70 | (STS as unknown as jest.Mock).mockImplementation(() => { 71 | const ctor = STS; 72 | ctor['serviceIdentifier'] = 'sts'; 73 | return { 74 | config: { ...modifiedConfig, update: () => modifiedConfig }, 75 | constructor: ctor, 76 | makeRequest: mockMakeRequest, 77 | }; 78 | }); 79 | const client = proxy.client(new STS(), modifiedConfig, workerPool); 80 | expect(proxy).toBeInstanceOf(SessionProxy); 81 | expect(client.config).toMatchObject(modifiedConfig); 82 | const result = await client.makeRequestPromise( 83 | 'getCallerIdentity', 84 | {}, 85 | { 'X-Dummy-Header': 'DUMMY HEADER' } 86 | ); 87 | expect(result).toBe(true); 88 | expect(mockMakeRequest).toHaveBeenCalledTimes(1); 89 | }); 90 | 91 | test('should return proxy with get session credentials argument', () => { 92 | const proxy = SessionProxy.getSession( 93 | AWS_CONFIG.credentials, 94 | AWS_CONFIG.region 95 | ); 96 | expect(proxy).toBeInstanceOf(SessionProxy); 97 | expect(proxy.client('CloudWatch')).toBeDefined(); 98 | }); 99 | 100 | test('should return null with get session null argument', () => { 101 | const proxy = SessionProxy.getSession(null); 102 | expect(proxy).toBeNull(); 103 | }); 104 | }); 105 | 106 | describe('progress event', () => { 107 | test('should fail with json serializable', () => { 108 | const errorCode = HandlerErrorCode.AlreadyExists; 109 | const message = 'message of failed event'; 110 | const event = ProgressEvent.failed(errorCode, message); 111 | expect(event.status).toBe(OperationStatus.Failed); 112 | expect(event.errorCode).toBe(errorCode); 113 | expect(event.message).toBe(message); 114 | const serialized = event.serialize(); 115 | expect(serialized).toMatchObject({ 116 | status: OperationStatus.Failed, 117 | errorCode: errorCode, 118 | message, 119 | callbackDelaySeconds: 0, 120 | }); 121 | }); 122 | 123 | test('should serialize to response with context', () => { 124 | const message = 'message of event with context'; 125 | const event = ProgressEvent.builder() 126 | .callbackContext({ a: 'b' }) 127 | .message(message) 128 | .status(OperationStatus.Success) 129 | .build(); 130 | const serialized = event.serialize(); 131 | expect(serialized).toMatchObject({ 132 | status: OperationStatus.Success, 133 | message, 134 | callbackContext: { 135 | a: 'b', 136 | }, 137 | callbackDelaySeconds: 0, 138 | }); 139 | }); 140 | 141 | test('should serialize to response with model', () => { 142 | const message = 'message of event with model'; 143 | const model = new ResourceModel({ 144 | somekey: 'a', 145 | someotherkey: 'b', 146 | somenullkey: null, 147 | }); 148 | const event = ProgressEvent.progress>( 149 | model, 150 | null 151 | ); 152 | event.message = message; 153 | const serialized = event.serialize(); 154 | expect(serialized).toMatchObject({ 155 | status: OperationStatus.InProgress, 156 | message, 157 | resourceModel: { 158 | somekey: 'a', 159 | someotherkey: 'b', 160 | }, 161 | callbackDelaySeconds: 0, 162 | }); 163 | }); 164 | 165 | test('should serialize to response with models', () => { 166 | const message = 'message of event with models'; 167 | const models = [ 168 | new ResourceModel({ 169 | somekey: 'a', 170 | someotherkey: 'b', 171 | }), 172 | new ResourceModel({ 173 | somekey: 'c', 174 | someotherkey: 'd', 175 | }), 176 | ]; 177 | const event = new ProgressEvent({ 178 | status: OperationStatus.Success, 179 | message, 180 | resourceModels: models, 181 | }); 182 | const serialized = event.serialize(); 183 | expect(serialized).toMatchObject({ 184 | status: OperationStatus.Success, 185 | message, 186 | resourceModels: [ 187 | { 188 | somekey: 'a', 189 | someotherkey: 'b', 190 | }, 191 | { 192 | somekey: 'c', 193 | someotherkey: 'd', 194 | }, 195 | ], 196 | callbackDelaySeconds: 0, 197 | }); 198 | }); 199 | 200 | test('should serialize to response with error code', () => { 201 | const message = 'message of event with error code'; 202 | const event = new ProgressEvent({ 203 | status: OperationStatus.Failed, 204 | message, 205 | errorCode: HandlerErrorCode.InvalidRequest, 206 | }); 207 | const serialized = event.serialize(); 208 | expect(serialized).toMatchObject({ 209 | status: OperationStatus.Failed, 210 | message, 211 | errorCode: HandlerErrorCode.InvalidRequest, 212 | callbackDelaySeconds: 0, 213 | }); 214 | }); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /tests/lib/recast.test.ts: -------------------------------------------------------------------------------- 1 | import * as exceptions from '~/exceptions'; 2 | import { transformValue, recastPrimitive } from '~/recast'; 3 | import { 4 | ResourceModel as ComplexResourceModel, 5 | SimpleResourceModel, 6 | TagsModel, 7 | } from '../data/sample-model'; 8 | 9 | describe('when recasting objects', () => { 10 | beforeAll(() => {}); 11 | 12 | afterEach(() => { 13 | jest.clearAllMocks(); 14 | jest.restoreAllMocks(); 15 | }); 16 | 17 | test('recast simple object', () => { 18 | const payload = { 19 | ANumber: '12.54', 20 | ABoolean: 'false', 21 | }; 22 | const expected = { 23 | ANumber: 12.54, 24 | ABoolean: false, 25 | }; 26 | const model = SimpleResourceModel.deserialize(payload); 27 | expect(model.toJSON()).toMatchObject(expected); 28 | const serialized = JSON.parse(JSON.stringify(model)); 29 | expect(serialized).toMatchObject(expected); 30 | }); 31 | 32 | test('recast complex object', () => { 33 | const payload = { 34 | ListListAny: [[{ key: 'val' }]], 35 | ListListInt: [['1', '2', '3', '']], 36 | ListSetInt: [['1', '2', '3']], 37 | ASet: ['1', '2', '3'], 38 | AnotherSet: ['a', 'b', 'c', ''], 39 | AFreeformDict: { somekey: 'somevalue', someotherkey: '1' }, 40 | ANumberDict: { key: '52.76' }, 41 | AnInt: '1', 42 | ABool: 'true', 43 | AList: [ 44 | { 45 | DeeperBool: 'false', 46 | DeeperList: ['1', '2', '3'], 47 | DeeperDictInList: { DeepestBool: 'true', DeepestList: ['3', '4'] }, 48 | }, 49 | { DeeperDictInList: { DeepestBool: 'false', DeepestList: ['6', '7'] } }, 50 | ], 51 | ADict: { 52 | DeepBool: 'true', 53 | DeepList: ['10', '11'], 54 | DeepDict: { 55 | DeeperBool: 'false', 56 | DeeperList: ['1', '2', '3'], 57 | DeeperDict: { DeepestBool: 'true', DeepestList: ['13', '17'] }, 58 | }, 59 | }, 60 | NestedList: [ 61 | [{ NestedListBool: 'true', NestedListList: ['1', '2', '3'] }], 62 | [{ NestedListBool: 'false', NestedListList: ['11', '12', '13'] }], 63 | ], 64 | }; 65 | const expected = { 66 | ListSetInt: [new Set([1, 2, 3])], 67 | ListListInt: [[1, 2, 3, null]], 68 | ListListAny: [[{ key: 'val' }]], 69 | ASet: new Set(['1', '2', '3']), 70 | AnotherSet: new Set(['a', 'b', 'c', '']), 71 | AFreeformDict: new Map([ 72 | ['somekey', 'somevalue'], 73 | ['someotherkey', '1'], 74 | ]), 75 | ANumberDict: new Map([['key', 52.76]]), 76 | AnInt: 1, 77 | ABool: true, 78 | AList: [ 79 | { 80 | DeeperBool: false, 81 | DeeperList: [1, 2, 3], 82 | DeeperDictInList: { DeepestBool: true, DeepestList: [3, 4] }, 83 | }, 84 | { DeeperDictInList: { DeepestBool: false, DeepestList: [6, 7] } }, 85 | ], 86 | ADict: { 87 | DeepBool: true, 88 | DeepList: [10, 11], 89 | DeepDict: { 90 | DeeperBool: false, 91 | DeeperList: [1, 2, 3], 92 | DeeperDict: { DeepestBool: true, DeepestList: [13, 17] }, 93 | }, 94 | }, 95 | NestedList: [ 96 | [{ NestedListBool: true, NestedListList: [1.0, 2.0, 3.0] }], 97 | [{ NestedListBool: false, NestedListList: [11.0, 12.0, 13.0] }], 98 | ], 99 | }; 100 | const model = ComplexResourceModel.deserialize(payload); 101 | const serialized = JSON.parse(JSON.stringify(model)); 102 | expect(serialized).toMatchObject(expected); 103 | // re-invocations should not fail because they already type-cast payloads 104 | expect(ComplexResourceModel.deserialize(serialized).serialize()).toMatchObject( 105 | expected 106 | ); 107 | }); 108 | 109 | test('recast set type - array with unique items', () => { 110 | const payload = { 111 | Tags: [{ key: 'name', value: 'value' }], 112 | }; 113 | const expected = { 114 | Tags: new Set([{ key: 'name', value: 'value' }]), 115 | }; 116 | const model = TagsModel.deserialize(payload); 117 | const serialized = JSON.parse(JSON.stringify(model)); 118 | expect(serialized).toMatchObject(expected); 119 | expect(TagsModel.deserialize(serialized).serialize()).toMatchObject(expected); 120 | }); 121 | 122 | test('recast object invalid sub type', () => { 123 | class InvalidClass {} 124 | const k = 'key'; 125 | const v = { a: 1, b: 2 }; 126 | const recastObject = () => { 127 | transformValue(InvalidClass, k, v, {}); 128 | }; 129 | expect(recastObject).toThrow(exceptions.InvalidRequest); 130 | expect(recastObject).toThrow( 131 | `Unsupported type: ${typeof v} [${InvalidClass.name}] for ${k}` 132 | ); 133 | }); 134 | 135 | test('recast primitive object type', () => { 136 | const k = 'key'; 137 | const v = '{"a":"b"}'; 138 | const value = recastPrimitive(Object, k, v); 139 | expect(value).toBe(v); 140 | }); 141 | 142 | test('recast primitive boolean invalid value', () => { 143 | const k = 'key'; 144 | const v = 'not-a-bool'; 145 | const recastingPrimitive = () => { 146 | recastPrimitive(Boolean, k, v); 147 | }; 148 | expect(recastingPrimitive).toThrow(exceptions.InvalidRequest); 149 | expect(recastingPrimitive).toThrow(`Value for ${k} "${v}" is not boolean`); 150 | }); 151 | 152 | test('recast primitive number valid value', () => { 153 | const k = 'key'; 154 | const v = '1252.53'; 155 | const num = recastPrimitive(Number, k, v); 156 | expect(num).toBe(1252.53); 157 | }); 158 | 159 | test('recast primitive boolean/number empty string', () => { 160 | const k = 'key'; 161 | const v = ''; 162 | const bool = recastPrimitive(Boolean, k, v); 163 | const num = recastPrimitive(Number, k, v); 164 | const int = recastPrimitive(BigInt, k, v); 165 | const string = recastPrimitive(String, k, v); 166 | expect(bool).toBeNull(); 167 | expect(num).toBeNull(); 168 | expect(int).toBeNull(); 169 | expect(string).toBe(''); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /tests/lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { deepFreeze, replaceAll } from '~/utils'; 2 | 3 | describe('when getting utils', () => { 4 | afterEach(() => { 5 | jest.clearAllMocks(); 6 | jest.restoreAllMocks(); 7 | }); 8 | 9 | describe('replace all', () => { 10 | test('should skip replace falsy', () => { 11 | expect(replaceAll(null, null, null)).toBe(null); 12 | expect(replaceAll(undefined, null, null)).toBe(undefined); 13 | expect(replaceAll('', null, null)).toBe(''); 14 | }); 15 | 16 | test('should replace all occurrences', () => { 17 | const BEARER_TOKEN = 'ce1919f7-8f9b-43fd-881e-c616ca74c4d3'; 18 | const SECRET_ACCESS_KEY = '66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5/poh2O0'; 19 | const SESSION_TOKEN = 20 | 'lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg\\.*+-?^${}()|[]'; 21 | const input = ` 22 | { 23 | awsAccountId: '123456789012', 24 | bearerToken: '${BEARER_TOKEN}', 25 | region: 'eu-central-1', 26 | action: 'CREATE', 27 | responseEndpoint: null, 28 | resourceType: 'Community::Monitoring::Website', 29 | resourceTypeVersion: '000001', 30 | callbackContext: null, 31 | requestData: { 32 | callerCredentials: { 33 | accessKeyId: '', 34 | secretAccessKey: '${SECRET_ACCESS_KEY}', 35 | sessionToken: '${SESSION_TOKEN}' 36 | }, 37 | providerCredentials: { 38 | accessKeyId: '', 39 | secretAccessKey: '${SECRET_ACCESS_KEY}', 40 | sessionToken: '${SESSION_TOKEN}' 41 | }, 42 | providerLogGroupName: 'community-monitoring-website-logs', 43 | logicalResourceId: 'MyResource', 44 | resourceProperties: { 45 | Name: 'MyWebsiteMonitor', 46 | BerearToken: '${BEARER_TOKEN}' 47 | }, 48 | previousResourceProperties: null, 49 | stackTags: {}, 50 | previousStackTags: {} 51 | }, 52 | stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968' 53 | } 54 | `; 55 | const expected = ` 56 | { 57 | awsAccountId: '123456789012', 58 | bearerToken: '', 59 | region: 'eu-central-1', 60 | action: 'CREATE', 61 | responseEndpoint: null, 62 | resourceType: 'Community::Monitoring::Website', 63 | resourceTypeVersion: '000001', 64 | callbackContext: null, 65 | requestData: { 66 | callerCredentials: { 67 | accessKeyId: '', 68 | secretAccessKey: '', 69 | sessionToken: '' 70 | }, 71 | providerCredentials: { 72 | accessKeyId: '', 73 | secretAccessKey: '', 74 | sessionToken: '' 75 | }, 76 | providerLogGroupName: 'community-monitoring-website-logs', 77 | logicalResourceId: 'MyResource', 78 | resourceProperties: { 79 | Name: 'MyWebsiteMonitor', 80 | BerearToken: '' 81 | }, 82 | previousResourceProperties: null, 83 | stackTags: {}, 84 | previousStackTags: {} 85 | }, 86 | stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968' 87 | } 88 | `; 89 | expect( 90 | replaceAll( 91 | replaceAll( 92 | replaceAll(input, BEARER_TOKEN, ''), 93 | SECRET_ACCESS_KEY, 94 | '' 95 | ), 96 | SESSION_TOKEN, 97 | '' 98 | ) 99 | ).toBe(expected); 100 | }); 101 | }); 102 | 103 | describe('deep freeze', () => { 104 | let obj; 105 | let circ1; 106 | let circ2; 107 | let proto; 108 | 109 | beforeEach(() => { 110 | obj = {}; 111 | obj.first = { 112 | second: { third: { num: 11, fun() {} } }, 113 | }; 114 | 115 | circ1 = { first: { test: 1 } }; 116 | circ2 = { second: { test: 2 } }; 117 | 118 | // Create circular reference 119 | circ2.circ1 = circ1; 120 | circ1.circ2 = circ2; 121 | 122 | const ob1 = { proto: { test: { is: 1 } } }; 123 | const ob2 = Object.create(ob1); 124 | ob2.ob2Prop = { prop: 'prop' }; 125 | proto = Object.create(ob2); 126 | proto.child = { test: 1 }; 127 | proto.fun = () => {}; 128 | }); 129 | 130 | test('should deep freeze nested objects', () => { 131 | deepFreeze(obj); 132 | expect(Object.isFrozen(obj.first.second)).toBe(true); 133 | expect(Object.isFrozen(obj.first.second.third)).toBe(true); 134 | expect(Object.isFrozen(obj.first.second.third.fun)).toBe(true); 135 | }); 136 | 137 | test('should handle circular reference', () => { 138 | deepFreeze(circ1); 139 | expect(Object.isFrozen(circ1.first)).toBe(true); 140 | expect(Object.isFrozen(circ1.circ2)).toBe(true); 141 | expect(Object.isFrozen(circ1.circ2.second)).toBe(true); 142 | }); 143 | 144 | test('should not freeze prototype chain', () => { 145 | deepFreeze(proto); 146 | expect(Object.isFrozen(proto)).toBe(true); 147 | expect(Object.isFrozen(proto.child)).toBe(true); 148 | expect(Object.isFrozen(proto.function)).toBe(true); 149 | expect(Object.isFrozen(proto.ob2Prop)).toBe(false); 150 | expect(Object.isFrozen(proto.proto.test)).toBe(false); 151 | }); 152 | 153 | test('should not brake on restricted properties', () => { 154 | const fun = function () {}; 155 | const funPrototype = Object.getPrototypeOf(fun); 156 | deepFreeze(funPrototype); 157 | expect(Object.isFrozen(funPrototype)).toBe(false); 158 | }); 159 | 160 | test('should deep freeze object with null prototype', () => { 161 | const ob1 = Object.create(null); 162 | ob1.test = 'test'; 163 | ob1.ob2 = Object.create(null); 164 | 165 | deepFreeze(ob1); 166 | expect(Object.isFrozen(ob1)).toBe(true); 167 | expect(Object.isFrozen(ob1.ob2)).toBe(true); 168 | }); 169 | 170 | test('should deep freeze complex object', () => { 171 | const fun = () => {}; 172 | const arr = [{ prop: { prop2: 1 } }]; 173 | const set = new Set([{ prop: { prop2: 1 } }]); 174 | const ob = { arr, fun, set }; 175 | 176 | fun.test = { prop: { prop2: 1 } }; 177 | arr['test'] = { prop: { prop2: 1 } }; 178 | set['test'] = { prop: { prop2: 1 } }; 179 | 180 | deepFreeze(ob); 181 | expect(Object.isFrozen(ob)).toBe(true); 182 | expect(Object.isFrozen(ob.fun)).toBe(true); 183 | expect(Object.isFrozen(ob.fun.test)).toBe(true); 184 | expect(Object.isFrozen(ob.arr)).toBe(true); 185 | expect(Object.isFrozen(ob.arr['test'])).toBe(true); 186 | expect(Object.isFrozen(ob.arr['test'])).toBe(true); 187 | expect(Object.isFrozen(ob.set)).toBe(true); 188 | expect(Object.isFrozen(ob.set['test'])).toBe(true); 189 | }); 190 | 191 | test('should deep freeze non enumerable properties', () => { 192 | Object.defineProperty(obj, 'nonEnumerable', { 193 | enumerable: false, 194 | value: {}, 195 | }); 196 | 197 | deepFreeze(obj); 198 | expect(Object.isFrozen(obj.nonEnumerable)).toBe(true); 199 | }); 200 | 201 | test('should validate some examples', () => { 202 | const person = { 203 | fullName: 'test person', 204 | dob: new Date(), 205 | address: { 206 | country: 'Croatia', 207 | city: 'this one', 208 | }, 209 | }; 210 | 211 | Object.freeze(person); 212 | expect(Object.isFrozen(person)).toBe(true); 213 | expect(Object.isFrozen(person.address)).toBe(false); 214 | 215 | deepFreeze(person); 216 | expect(Object.isFrozen(person)).toBe(true); 217 | expect(Object.isFrozen(person.address)).toBe(true); 218 | 219 | const ob1 = { test: { a: 'a' } }; 220 | const ob2 = Object.create(ob1); 221 | 222 | deepFreeze(ob2); 223 | 224 | expect(Object.isFrozen(ob2)).toBe(true); 225 | expect(Object.isFrozen(Object.getPrototypeOf(ob2))).toBe(false); 226 | expect(Object.isFrozen(ob1)).toBe(false); 227 | expect(Object.isFrozen(Object.getPrototypeOf(ob1))).toBe(false); 228 | }); 229 | 230 | test('should freeze object with Symbol property', () => { 231 | const sim = Symbol('test'); 232 | obj[sim] = { 233 | key: { test: 1 }, 234 | }; 235 | 236 | deepFreeze(obj); 237 | expect(Object.isFrozen(obj[sim].key)).toBe(true); 238 | }); 239 | 240 | test('should not break for TypedArray properties', () => { 241 | obj.typedArray = new Uint32Array(4); 242 | obj.buffer = Buffer.from('TEST'); 243 | 244 | deepFreeze(obj); 245 | expect(Object.isFrozen(obj)).toBe(true); 246 | }); 247 | 248 | test('should deep freeze children of already frozen object', () => { 249 | Object.freeze(obj.first); 250 | 251 | deepFreeze(obj); 252 | expect(Object.isFrozen(obj.first.second)).toBe(true); 253 | expect(Object.isFrozen(obj.first.second.third)).toBe(true); 254 | }); 255 | 256 | test('should not freeze object prototype', () => { 257 | deepFreeze(proto); 258 | expect(Object.isFrozen(proto)).toBe(true); 259 | expect(Object.isFrozen(Object.getPrototypeOf(proto))).toBe(false); 260 | }); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /tests/plugin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli-typescript-plugin/c6741811d918954b2bead0aa844400d236537581/tests/plugin/__init__.py -------------------------------------------------------------------------------- /tests/plugin/codegen_test.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name,protected-access 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | import sys 6 | from subprocess import CalledProcessError 7 | from unittest.mock import patch, sentinel 8 | from uuid import uuid4 9 | 10 | import pytest 11 | from rpdk.core.exceptions import DownstreamError 12 | from rpdk.core.project import Project 13 | from rpdk.typescript.codegen import ( 14 | SUPPORT_LIB_NAME, 15 | TypescriptLanguagePlugin, 16 | validate_no, 17 | ) 18 | 19 | if sys.version_info >= (3, 8): # pragma: no cover 20 | from zipfile import ZipFile 21 | else: # pragma: no cover 22 | from zipfile38 import ZipFile 23 | 24 | 25 | TYPE_NAME = "foo::bar::baz" 26 | 27 | 28 | @pytest.fixture 29 | def plugin(): 30 | return TypescriptLanguagePlugin() 31 | 32 | 33 | @pytest.fixture 34 | def project(tmp_path: str): 35 | project = Project(root=tmp_path) 36 | 37 | patch_plugins = patch.dict( 38 | "rpdk.core.plugin_registry.PLUGIN_REGISTRY", 39 | {TypescriptLanguagePlugin.NAME: lambda: TypescriptLanguagePlugin}, 40 | clear=True, 41 | ) 42 | patch_wizard = patch( 43 | "rpdk.typescript.codegen.input_with_validation", 44 | autospec=True, 45 | side_effect=[False], 46 | ) 47 | with patch_plugins, patch_wizard: 48 | current_path = os.path.abspath(__file__) 49 | lib_abspath = os.path.abspath(os.path.join(current_path, "..", "..", "..")) 50 | TypescriptLanguagePlugin.SUPPORT_LIB_URI = f"file:{lib_abspath}" 51 | project.init(TYPE_NAME, TypescriptLanguagePlugin.NAME) 52 | return project 53 | 54 | 55 | @pytest.fixture 56 | def project_use_docker(tmp_path: str): 57 | project_use_docker = Project(root=tmp_path) 58 | 59 | patch_plugins = patch.dict( 60 | "rpdk.core.plugin_registry.PLUGIN_REGISTRY", 61 | {TypescriptLanguagePlugin.NAME: lambda: TypescriptLanguagePlugin}, 62 | clear=True, 63 | ) 64 | with patch_plugins: 65 | current_path = os.path.abspath(__file__) 66 | lib_abspath = os.path.abspath(os.path.join(current_path, "..", "..", "..")) 67 | TypescriptLanguagePlugin.SUPPORT_LIB_URI = f"file:{lib_abspath}" 68 | project_use_docker.init( 69 | TYPE_NAME, 70 | TypescriptLanguagePlugin.NAME, 71 | settings={"use_docker": True, "no_docker": False}, 72 | ) 73 | return project_use_docker 74 | 75 | 76 | @pytest.fixture 77 | def project_no_docker(tmp_path: str): 78 | project_no_docker = Project(root=tmp_path) 79 | 80 | patch_plugins = patch.dict( 81 | "rpdk.core.plugin_registry.PLUGIN_REGISTRY", 82 | {TypescriptLanguagePlugin.NAME: lambda: TypescriptLanguagePlugin}, 83 | clear=True, 84 | ) 85 | with patch_plugins: 86 | current_path = os.path.abspath(__file__) 87 | lib_abspath = os.path.abspath(os.path.join(current_path, "..", "..", "..")) 88 | TypescriptLanguagePlugin.SUPPORT_LIB_URI = f"file:{lib_abspath}" 89 | project_no_docker.init( 90 | TYPE_NAME, 91 | TypescriptLanguagePlugin.NAME, 92 | settings={"use_docker": False, "no_docker": True}, 93 | ) 94 | return project_no_docker 95 | 96 | 97 | @pytest.fixture 98 | def project_both_true(tmp_path: str): 99 | project_both_true = Project(root=tmp_path) 100 | 101 | patch_plugins = patch.dict( 102 | "rpdk.core.plugin_registry.PLUGIN_REGISTRY", 103 | {TypescriptLanguagePlugin.NAME: lambda: TypescriptLanguagePlugin}, 104 | clear=True, 105 | ) 106 | with patch_plugins: 107 | current_path = os.path.abspath(__file__) 108 | lib_abspath = os.path.abspath(os.path.join(current_path, "..", "..", "..")) 109 | TypescriptLanguagePlugin.SUPPORT_LIB_URI = f"file:{lib_abspath}" 110 | project_both_true.init( 111 | TYPE_NAME, 112 | TypescriptLanguagePlugin.NAME, 113 | settings={"use_docker": True, "no_docker": True}, 114 | ) 115 | return project_both_true 116 | 117 | 118 | def get_files_in_project(project: Project): 119 | return { 120 | str(child.relative_to(project.root)): child for child in project.root.rglob("*") 121 | } 122 | 123 | 124 | @pytest.mark.parametrize( 125 | "value,result", 126 | [ 127 | ("y", True), 128 | ("Y", True), 129 | ("yes", True), 130 | ("Yes", True), 131 | ("YES", True), 132 | ("asdf", True), 133 | ("no", False), 134 | ("No", False), 135 | ("No", False), 136 | ("n", False), 137 | ("N", False), 138 | ], 139 | ) 140 | def test_validate_no(value: str, result: bool): 141 | assert validate_no(value) is result 142 | 143 | 144 | def test__remove_build_artifacts_file_found(tmp_path: str): 145 | deps_path = tmp_path / "build" 146 | deps_path.mkdir() 147 | TypescriptLanguagePlugin._remove_build_artifacts(deps_path) 148 | 149 | 150 | def test__remove_build_artifacts_file_not_found(tmp_path: str): 151 | deps_path = tmp_path / "build" 152 | with patch("rpdk.typescript.codegen.LOG", autospec=True) as mock_log: 153 | TypescriptLanguagePlugin._remove_build_artifacts(deps_path) 154 | 155 | mock_log.debug.assert_called_once() 156 | 157 | 158 | @pytest.fixture 159 | def project_no_docker_use_docker_values( 160 | request, project, project_use_docker, project_no_docker, project_both_true 161 | ): 162 | return [ 163 | (project, True, False), 164 | (project_use_docker, False, True), 165 | (project_no_docker, True, False), 166 | (project_both_true, False, True), 167 | ][request.param] 168 | 169 | 170 | @pytest.mark.parametrize( 171 | "project_no_docker_use_docker_values", [0, 1, 2, 3], indirect=True 172 | ) 173 | def test_initialize(project_no_docker_use_docker_values): 174 | ( 175 | project_value, 176 | no_docker_value, 177 | use_docker_value, 178 | ) = project_no_docker_use_docker_values 179 | lib_path = project_value._plugin._lib_path 180 | assert project_value.settings == { 181 | "protocolVersion": "2.0.0", 182 | "no_docker": no_docker_value, 183 | "use_docker": use_docker_value, 184 | } 185 | 186 | files = get_files_in_project(project_value) 187 | assert set(files) == { 188 | ".gitignore", 189 | ".npmrc", 190 | ".rpdk-config", 191 | "foo-bar-baz.json", 192 | "example_inputs", 193 | f"{os.path.join('example_inputs', 'inputs_1_create.json')}", 194 | f"{os.path.join('example_inputs', 'inputs_1_invalid.json')}", 195 | f"{os.path.join('example_inputs', 'inputs_1_update.json')}", 196 | "Makefile", 197 | "package.json", 198 | "README.md", 199 | "sam-tests", 200 | f"{os.path.join('sam-tests', 'create.json')}", 201 | "src", 202 | f"{os.path.join('src', 'handlers.ts')}", 203 | "template.yml", 204 | "tsconfig.json", 205 | } 206 | 207 | assert "node_modules" in files[".gitignore"].read_text() 208 | package_json = files["package.json"].read_text() 209 | assert SUPPORT_LIB_NAME in package_json 210 | assert lib_path in package_json 211 | 212 | readme = files["README.md"].read_text() 213 | assert project_value.type_name in readme 214 | assert SUPPORT_LIB_NAME in readme 215 | assert "handlers.ts" in readme 216 | assert "models.ts" in readme 217 | 218 | assert project_value.entrypoint in files["template.yml"].read_text() 219 | assert "BuildMethod: makefile" in files["template.yml"].read_text() 220 | 221 | 222 | def test_generate(project: Project): 223 | project.load_schema() 224 | before = get_files_in_project(project) 225 | project.generate() 226 | after = get_files_in_project(project) 227 | files = after.keys() - before.keys() - {"resource-role.yaml"} 228 | 229 | assert files == {f"{os.path.join('src', 'models.ts')}"} 230 | 231 | 232 | def test_package_local(project: Project): 233 | project.load_schema() 234 | project.generate() 235 | 236 | zip_path = project.root / "foo-bar-baz.zip" 237 | 238 | # pylint: disable=unexpected-keyword-arg 239 | with zip_path.open("wb") as f, ZipFile( 240 | f, mode="w", strict_timestamps=False 241 | ) as zip_file: 242 | project._plugin.package(project, zip_file) 243 | 244 | with zip_path.open("rb") as f, ZipFile( 245 | f, mode="r", strict_timestamps=False 246 | ) as zip_file: 247 | assert sorted(zip_file.namelist()) == [ 248 | "ResourceProvider.zip", 249 | "src/handlers.ts", 250 | "src/models.ts", 251 | ] 252 | 253 | 254 | def test__build_called_process_error(plugin: TypescriptLanguagePlugin, tmp_path: str): 255 | executable_name = str(uuid4()) 256 | plugin._build_command = executable_name 257 | 258 | with patch.object( 259 | TypescriptLanguagePlugin, 260 | "_make_build_command", 261 | wraps=TypescriptLanguagePlugin._make_build_command, 262 | ) as mock_cmd: 263 | with pytest.raises(DownstreamError) as excinfo: 264 | plugin._build(tmp_path) 265 | 266 | mock_cmd.assert_called_once_with(tmp_path, executable_name) 267 | 268 | assert isinstance(excinfo.value.__cause__, CalledProcessError) 269 | 270 | 271 | def test__build_docker(plugin: TypescriptLanguagePlugin): 272 | plugin._use_docker = True 273 | 274 | patch_cmd = patch.object( 275 | TypescriptLanguagePlugin, "_make_build_command", return_value="" 276 | ) 277 | patch_subprocess_run = patch( 278 | "rpdk.typescript.codegen.subprocess_run", autospec=True 279 | ) 280 | with patch_cmd as mock_cmd, patch_subprocess_run as mock_subprocess_run: 281 | plugin._build(sentinel.base_path) 282 | 283 | mock_cmd.assert_called_once_with(sentinel.base_path, None) 284 | if sys.platform == "win32": 285 | mock_subprocess_run.assert_called_once_with( 286 | [os.environ.get("comspec"), "/C", " --use-container TypeFunction"], 287 | check=True, 288 | cwd=sentinel.base_path, 289 | stderr=-1, 290 | stdout=-1, 291 | universal_newlines=True, 292 | ) 293 | else: 294 | mock_subprocess_run.assert_called_once_with( 295 | [" --use-container TypeFunction"], 296 | check=True, 297 | cwd=sentinel.base_path, 298 | stderr=-1, 299 | stdout=-1, 300 | shell=True, 301 | universal_newlines=True, 302 | ) 303 | -------------------------------------------------------------------------------- /tests/plugin/parser_test.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import pytest 4 | from rpdk.typescript.parser import setup_subparser 5 | 6 | 7 | def test_setup_subparser(): 8 | parser = argparse.ArgumentParser() 9 | subparsers = parser.add_subparsers(dest="subparser_name") 10 | 11 | sub_parser = setup_subparser(subparsers, []) 12 | 13 | args = sub_parser.parse_args([]) 14 | assert args.language == "typescript" 15 | assert args.use_docker is False 16 | assert args.no_docker is False 17 | 18 | short_args = sub_parser.parse_args(["-d"]) 19 | assert short_args.language == "typescript" 20 | assert short_args.use_docker is True 21 | assert short_args.no_docker is False 22 | 23 | long_args = sub_parser.parse_args(["--use-docker"]) 24 | assert long_args.language == "typescript" 25 | assert long_args.use_docker is True 26 | assert long_args.no_docker is False 27 | 28 | no_docker = sub_parser.parse_args(["--no-docker"]) 29 | assert no_docker.language == "typescript" 30 | assert no_docker.use_docker is False 31 | assert no_docker.no_docker is True 32 | 33 | with pytest.raises(SystemExit): 34 | sub_parser.parse_args(["--no-docker", "--use-docker"]) 35 | -------------------------------------------------------------------------------- /tests/plugin/resolver_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpdk.core.jsonutils.resolver import ContainerType, ResolvedType 3 | from rpdk.typescript.resolver import ( 4 | PRIMITIVE_TYPES, 5 | contains_model, 6 | get_inner_type, 7 | translate_type, 8 | ) 9 | 10 | RESOLVED_TYPES = [ 11 | (ResolvedType(ContainerType.PRIMITIVE, item_type), native_type) 12 | for item_type, native_type in PRIMITIVE_TYPES.items() 13 | ] 14 | 15 | 16 | def test_translate_type_model_passthrough(): 17 | item_type = object() 18 | translated = translate_type(ResolvedType(ContainerType.MODEL, item_type)) 19 | assert translated is item_type 20 | 21 | 22 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 23 | def test_translate_type_primitive(resolved_type, native_type): 24 | assert translate_type(resolved_type) == native_type 25 | 26 | 27 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 28 | def test_translate_type_dict(resolved_type, native_type): 29 | translated = translate_type(ResolvedType(ContainerType.DICT, resolved_type)) 30 | assert translated == f"Map" 31 | 32 | 33 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 34 | def test_translate_type_list(resolved_type, native_type): 35 | translated = translate_type(ResolvedType(ContainerType.LIST, resolved_type)) 36 | assert translated == f"Array<{native_type}>" 37 | 38 | 39 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 40 | def test_translate_type_set(resolved_type, native_type): 41 | translated = translate_type(ResolvedType(ContainerType.SET, resolved_type)) 42 | assert translated == f"Set<{native_type}>" 43 | 44 | 45 | @pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) 46 | def test_translate_type_multiple(resolved_type, _native_type): 47 | translated = translate_type(ResolvedType(ContainerType.MULTIPLE, resolved_type)) 48 | assert translated == "object" 49 | 50 | 51 | @pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) 52 | def test_translate_type_unknown(resolved_type, _native_type): 53 | with pytest.raises(ValueError): 54 | translate_type(ResolvedType("foo", resolved_type)) 55 | 56 | 57 | @pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) 58 | def test_contains_model_list_containing_primitive(resolved_type, _native_type): 59 | assert contains_model(ResolvedType(ContainerType.LIST, resolved_type)) is False 60 | 61 | 62 | def test_contains_model_list_containing_model(): 63 | resolved_type = ResolvedType( 64 | ContainerType.LIST, 65 | ResolvedType(ContainerType.LIST, ResolvedType(ContainerType.MODEL, "Foo")), 66 | ) 67 | assert contains_model(resolved_type) is True 68 | 69 | 70 | def test_inner_type_model_passthrough(): 71 | item_type = object() 72 | inner_type = get_inner_type(ResolvedType(ContainerType.MODEL, item_type)) 73 | assert inner_type.type is item_type 74 | assert inner_type.primitive is False 75 | 76 | 77 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 78 | def test_inner_type_primitive(resolved_type, native_type): 79 | inner_type = get_inner_type(resolved_type) 80 | assert inner_type.type == native_type 81 | assert inner_type.primitive is True 82 | 83 | 84 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 85 | def test_inner_type_dict(resolved_type, native_type): 86 | inner_type = get_inner_type(ResolvedType(ContainerType.DICT, resolved_type)) 87 | assert inner_type.type == native_type 88 | assert inner_type.classes == ["Map"] 89 | 90 | 91 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 92 | def test_inner_type_list(resolved_type, native_type): 93 | inner_type = get_inner_type(ResolvedType(ContainerType.LIST, resolved_type)) 94 | assert inner_type.type == native_type 95 | assert inner_type.classes == ["Array"] 96 | 97 | 98 | @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) 99 | def test_inner_type_set(resolved_type, native_type): 100 | inner_type = get_inner_type(ResolvedType(ContainerType.SET, resolved_type)) 101 | assert inner_type.type == native_type 102 | assert inner_type.classes == ["Set"] 103 | 104 | 105 | @pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) 106 | def test_inner_type_multiple(resolved_type, _native_type): 107 | inner_type = get_inner_type(ResolvedType(ContainerType.MULTIPLE, resolved_type)) 108 | assert inner_type.type == "object" 109 | assert inner_type.primitive is True 110 | 111 | 112 | @pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) 113 | def test_inner_type_unknown(resolved_type, _native_type): 114 | with pytest.raises(ValueError): 115 | get_inner_type(ResolvedType("foo", resolved_type)) 116 | -------------------------------------------------------------------------------- /tests/plugin/utils_test.py: -------------------------------------------------------------------------------- 1 | # fixture and parameter have the same name 2 | # pylint: disable=redefined-outer-name 3 | import pytest 4 | from rpdk.core.exceptions import WizardValidationError 5 | from rpdk.typescript.utils import ( 6 | safe_reserved, 7 | validate_codegen_model as validate_codegen_model_factory, 8 | ) 9 | 10 | DEFAULT = object() 11 | 12 | 13 | @pytest.fixture 14 | def validate_codegen_model(): 15 | return validate_codegen_model_factory(DEFAULT) 16 | 17 | 18 | def test_safe_reserved_safe_string(): 19 | assert safe_reserved("foo") == "foo" 20 | 21 | 22 | def test_safe_reserved_unsafe_javascript_string(): 23 | assert safe_reserved("null") == "null_" 24 | 25 | 26 | def test_safe_reserved_unsafe_typescript_string(): 27 | assert safe_reserved("interface") == "interface_" 28 | 29 | 30 | def test_validate_codegen_model_choose_1(validate_codegen_model): 31 | assert validate_codegen_model("1") == "1" 32 | 33 | 34 | def test_validate_codegen_model_choose_2(validate_codegen_model): 35 | assert validate_codegen_model("2") == "2" 36 | 37 | 38 | def test_validate_codegen_model_invalid_selection(validate_codegen_model): 39 | with pytest.raises(WizardValidationError) as excinfo: 40 | validate_codegen_model("3") 41 | assert "Invalid selection." in str(excinfo.value) 42 | 43 | 44 | def test_validate_codegen_model_no_selection(validate_codegen_model): 45 | assert validate_codegen_model("") == DEFAULT 46 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "." 5 | }, 6 | "include": [ 7 | "./**/*.ts" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitReturns": false, 5 | "sourceMap": true, 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "tests/**/*.ts", 10 | "jest.config.cjs" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "noImplicitAny": false, 6 | "strictNullChecks": false, 7 | "strictPropertyInitialization": false, 8 | "allowJs": true, 9 | "declaration": true, 10 | "removeComments": true, 11 | "sourceMap": false, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "outDir": "dist", 15 | "baseUrl": ".", 16 | "paths": { 17 | "~/*": [ 18 | "src/*" 19 | ] 20 | }, 21 | }, 22 | "include": [ 23 | "src/**/*.ts", 24 | ], 25 | "exclude": [ 26 | "node_modules" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": true, 5 | }, 6 | } 7 | --------------------------------------------------------------------------------