From 31566de3afaf694ee9b93b22252b25160126b663 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Sun, 8 Mar 2020 23:32:31 +0100 Subject: [PATCH] add cli typescript plugin --- .pre-commit-config.yaml | 65 +++ CODE_OF_CONDUCT.md | 3 + CONTRIBUTING.md | 55 ++ NOTICE | 2 + Pipfile | 15 + Pipfile.lock | 523 ++++++++++++++++++ README.md | 44 ++ python/rpdk/typescript/__init__.py | 5 + python/rpdk/typescript/codegen.py | 271 +++++++++ python/rpdk/typescript/data/__init__.py | 0 python/rpdk/typescript/data/tsconfig.json | 15 + .../rpdk/typescript/data/typescript.gitignore | 10 + python/rpdk/typescript/resolver.py | 28 + python/rpdk/typescript/templates/README.md | 35 ++ python/rpdk/typescript/templates/handlers.ts | 115 ++++ python/rpdk/typescript/templates/models.ts | 16 + python/rpdk/typescript/templates/package.json | 20 + python/rpdk/typescript/utils.py | 81 +++ tests/__init__.py | 0 tests/plugin/__init__.py | 0 tests/plugin/codegen_test.py | 243 ++++++++ tests/plugin/resolver_test.py | 43 ++ 22 files changed, 1589 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 NOTICE create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 python/rpdk/typescript/__init__.py create mode 100644 python/rpdk/typescript/codegen.py create mode 100644 python/rpdk/typescript/data/__init__.py create mode 100644 python/rpdk/typescript/data/tsconfig.json create mode 100644 python/rpdk/typescript/data/typescript.gitignore create mode 100644 python/rpdk/typescript/resolver.py create mode 100644 python/rpdk/typescript/templates/README.md create mode 100644 python/rpdk/typescript/templates/handlers.ts create mode 100644 python/rpdk/typescript/templates/models.ts create mode 100644 python/rpdk/typescript/templates/package.json create mode 100644 python/rpdk/typescript/utils.py create mode 100644 tests/__init__.py create mode 100644 tests/plugin/__init__.py create mode 100644 tests/plugin/codegen_test.py create mode 100644 tests/plugin/resolver_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b32bf45 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,65 @@ +repos: +- repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.17 + hooks: + - id: isort + # language_version: python3.6 +- repo: https://github.com/ambv/black + rev: stable + hooks: + - id: black + # language_version: python3.6 +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.0.0 + hooks: + - id: check-case-conflict + - id: end-of-file-fixer + - id: mixed-line-ending + args: + - --fix=lf + - id: trailing-whitespace + - id: flake8 + additional_dependencies: + - flake8-bugbear>=19.3.0 + - flake8-builtins>=1.4.1 + - flake8-commas>=2.0.0 + - flake8-comprehensions>=2.1.0 + - flake8-debugger>=3.1.0 + - flake8-pep3101>=1.2.1 + # language_version: python3.6 + - id: pretty-format-json + args: + - --autofix + - --indent=4 + - --no-sort-keys + - id: check-merge-conflict + - id: check-yaml +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.3.0 + hooks: + - id: python-check-blanket-noqa + - id: python-check-mock-methods + - id: python-no-log-warn +- repo: https://github.com/PyCQA/bandit + rev: f5a6f0ca62 # TODO: update once a release > 1.5.1 hits with this change in + hooks: + - id: bandit + files: "^python/" +- repo: local + hooks: + - id: pylint-local + name: pylint-local + description: Run pylint in the local virtualenv + entry: pylint "setup.py" "python/" "tests/" + language: system + # ignore all files, run on hard-coded modules instead + pass_filenames: false + always_run: true + - id: pytest-local + name: pytest-local + description: Run pytest in the local virtualenv + entry: pytest --cov=rpdk.typescript --doctest-modules tests/ + language: system + # ignore all files, run on hard-coded modules instead + pass_filenames: false + always_run: true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..531381d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +## Code of Conduct +This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). +For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8cb508c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing Guidelines + +Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional +documentation, we greatly value feedback and contributions from our community. + +Please read through this document before submitting any issues or pull requests to ensure we have all the necessary +information to effectively respond to your bug report or contribution. + + +## Reporting Bugs/Feature Requests + +We welcome you to use the GitHub issue tracker to report bugs or suggest features. + +When filing an issue, please check [existing open](https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/issues), or [recently closed](https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already +reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: + +* A reproducible test case or series of steps +* The version of our code being used +* Any modifications you've made relevant to the bug +* Anything unusual about your environment or deployment + + +## Contributing via Pull Requests +Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: + +1. You are working against the latest source on the *master* branch. +2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. +3. You open an issue to discuss any significant work - we would hate for your time to be wasted. + +To send us a pull request, please: + +1. Fork the repository. +2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. +3. Ensure local tests pass. +4. Commit to your fork using clear commit messages. +5. Send us a pull request, answering any default questions in the pull request interface. +6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. + +GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and +[creating a pull request](https://help.github.com/articles/creating-a-pull-request/). + + +## Finding contributions to work on +Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/labels/help%20wanted) issues is a great place to start. + + +## Security issue notifications +If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. + + +## Licensing + +See the [LICENSE](https://github.com/eduardomourar/cloudformation-cli-typescript-plugin/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. + +We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a726382 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +AWS CloudFormation RPDK TypeScript Plugin +Copyright 2020 diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..2916757 --- /dev/null +++ b/Pipfile @@ -0,0 +1,15 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +pylint = "*" +pytest = "*" +pytest-cov = "*" + +[packages] +cloudformation-cli-typescript-plugin = {editable = true,path = "."} + +[requires] +python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..c11f094 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,523 @@ +{ + "_meta": { + "hash": { + "sha256": "d4bc3cf48ff20b1ccd5fa373f31b3b7f6465f2e759b82e23f727615864d40a9b" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "attrs": { + "hashes": [ + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + ], + "version": "==19.3.0" + }, + "boto3": { + "hashes": [ + "sha256:a01e3a373638850822c863914fa56e25fd1a651a48fb9c6323694a634c76289c", + "sha256:ba1229df5f1e32dee4c8bb73e774dfb7d54e293f0895d4f825294e2d46e3c7b3" + ], + "version": "==1.12.16" + }, + "botocore": { + "hashes": [ + "sha256:1109f36e658de2097d1e466842d6634a6b66bb9d3779abe16698171360e1ae5f", + "sha256:39e903e1d1ae862e469b4d5f15dc6770a7c9c81da9fcffb1a40f551ea36acd35" + ], + "version": "==1.15.16" + }, + "certifi": { + "hashes": [ + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + ], + "version": "==2019.11.28" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "cloudformation-cli": { + "hashes": [ + "sha256:7c42bc8aff4e13dc61020a84efcb5c599cdb3f64603daa0a793a48676843c51b", + "sha256:cf25004eb26ffeccfcae1576513152e0717f6cce48e7a0b43ca888df5c92b1ec" + ], + "version": "==0.1.2" + }, + "cloudformation-cli-typescript-plugin": { + "editable": true, + "path": "." + }, + "colorama": { + "hashes": [ + "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", + "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" + ], + "version": "==0.4.3" + }, + "docker": { + "hashes": [ + "sha256:2434b396e616a5ef682fbf80e04839a59e8b81880ece5662c33dff34b8863519", + "sha256:a062a9f82dff025f79c2097c46f49f143f8898274db7e66041f78cafee66b962" + ], + "version": "==3.7.3" + }, + "docker-pycreds": { + "hashes": [ + "sha256:6ce3270bcaf404cc4c3e27e4b6c70d3521deae82fb508767870fdbf772d584d4", + "sha256:7266112468627868005106ec19cd0d722702d2b7d5912a28e19b826c3d37af49" + ], + "version": "==0.4.0" + }, + "docutils": { + "hashes": [ + "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", + "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", + "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" + ], + "version": "==0.15.2" + }, + "hypothesis": { + "hashes": [ + "sha256:22fb60bd0c6eb7849121a7df263a91da23b4e8506d3ba9e92ac696d2720ac0f5", + "sha256:4b4c236a2e14fca1dfc254bb38592360dfb1cbd9d3c4c410c0b7e91f37a43bf3" + ], + "version": "==5.6.0" + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "importlib-metadata": { + "hashes": [ + "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", + "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" + ], + "markers": "python_version < '3.8'", + "version": "==1.5.0" + }, + "jinja2": { + "hashes": [ + "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", + "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49" + ], + "version": "==2.11.1" + }, + "jmespath": { + "hashes": [ + "sha256:695cb76fa78a10663425d5b73ddc5714eb711157e52704d69be03b1a02ba4fec", + "sha256:cca55c8d153173e21baa59983015ad0daf603f9cb799904ff057bfb8ff8dc2d9" + ], + "version": "==0.9.5" + }, + "jsonschema": { + "hashes": [ + "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", + "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" + ], + "version": "==3.2.0" + }, + "markupsafe": { + "hashes": [ + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + ], + "version": "==1.1.1" + }, + "more-itertools": { + "hashes": [ + "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", + "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" + ], + "version": "==8.2.0" + }, + "packaging": { + "hashes": [ + "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", + "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" + ], + "version": "==20.3" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "version": "==0.13.1" + }, + "py": { + "hashes": [ + "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", + "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" + ], + "version": "==1.8.1" + }, + "pyparsing": { + "hashes": [ + "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", + "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" + ], + "version": "==2.4.6" + }, + "pyrsistent": { + "hashes": [ + "sha256:cdc7b5e3ed77bed61270a47d35434a30617b9becdf2478af76ad2c6ade307280" + ], + "version": "==0.15.7" + }, + "pytest": { + "hashes": [ + "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d", + "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6" + ], + "version": "==5.3.5" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "version": "==2.8.1" + }, + "pyyaml": { + "hashes": [ + "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", + "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", + "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", + "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", + "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", + "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", + "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", + "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", + "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", + "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", + "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + ], + "version": "==5.3" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "version": "==2.23.0" + }, + "s3transfer": { + "hashes": [ + "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", + "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db" + ], + "version": "==0.3.3" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + }, + "sortedcontainers": { + "hashes": [ + "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a", + "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60" + ], + "version": "==2.1.0" + }, + "urllib3": { + "hashes": [ + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + ], + "markers": "python_version != '3.4'", + "version": "==1.25.8" + }, + "wcwidth": { + "hashes": [ + "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603", + "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8" + ], + "version": "==0.1.8" + }, + "websocket-client": { + "hashes": [ + "sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549", + "sha256:d735b91d6d1692a6a181f2a8c9e0238e5f6373356f561bb9dc4c7af36f452010" + ], + "version": "==0.57.0" + }, + "werkzeug": { + "hashes": [ + "sha256:169ba8a33788476292d04186ab33b01d6add475033dfc07215e6d219cc077096", + "sha256:6dc65cf9091cf750012f56f2cad759fa9e879f511b5ff8685e456b4e3bf90d16" + ], + "version": "==1.0.0" + }, + "zipp": { + "hashes": [ + "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", + "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + ], + "version": "==3.1.0" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", + "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42" + ], + "version": "==2.3.3" + }, + "attrs": { + "hashes": [ + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + ], + "version": "==19.3.0" + }, + "coverage": { + "hashes": [ + "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3", + "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c", + "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0", + "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477", + "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a", + "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf", + "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691", + "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73", + "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987", + "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894", + "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e", + "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef", + "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf", + "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68", + "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8", + "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954", + "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2", + "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40", + "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc", + "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc", + "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e", + "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d", + "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f", + "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc", + "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301", + "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea", + "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb", + "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af", + "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52", + "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37", + "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0" + ], + "version": "==5.0.3" + }, + "importlib-metadata": { + "hashes": [ + "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", + "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" + ], + "markers": "python_version < '3.8'", + "version": "==1.5.0" + }, + "isort": { + "hashes": [ + "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", + "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" + ], + "version": "==4.3.21" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", + "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", + "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", + "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", + "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", + "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", + "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", + "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", + "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", + "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", + "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", + "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", + "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", + "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", + "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", + "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", + "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", + "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", + "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", + "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", + "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" + ], + "version": "==1.4.3" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "more-itertools": { + "hashes": [ + "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", + "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" + ], + "version": "==8.2.0" + }, + "packaging": { + "hashes": [ + "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", + "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" + ], + "version": "==20.3" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "version": "==0.13.1" + }, + "py": { + "hashes": [ + "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", + "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" + ], + "version": "==1.8.1" + }, + "pylint": { + "hashes": [ + "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", + "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4" + ], + "index": "pypi", + "version": "==2.4.4" + }, + "pyparsing": { + "hashes": [ + "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", + "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" + ], + "version": "==2.4.6" + }, + "pytest": { + "hashes": [ + "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d", + "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6" + ], + "version": "==5.3.5" + }, + "pytest-cov": { + "hashes": [ + "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", + "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "markers": "implementation_name == 'cpython' and python_version < '3.8'", + "version": "==1.4.1" + }, + "wcwidth": { + "hashes": [ + "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603", + "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8" + ], + "version": "==0.1.8" + }, + "wrapt": { + "hashes": [ + "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" + ], + "version": "==1.11.2" + }, + "zipp": { + "hashes": [ + "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", + "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + ], + "version": "==3.1.0" + } + } +} diff --git a/README.md b/README.md index 02161e1..8e46fa4 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,56 @@ If you are using this package to build resource providers for CloudFormation, in **Installation** +Because this is a developer preview, you still need to install the plugin using [pip](https://pypi.org/project/pip/) from GitHub. + ```shell pip3 install git+https://github.com/eduardomourar/cloudformation-cli-typescript-plugin.git#egg=cloudformation-cli-typescript-plugin ``` 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. +** Howto** + +Example run: + +``` +$ cfn init +Initializing new project +What's the name of your resource type? +(Organization::Service::Resource) +>> Foo::Bar::Baz +Select a language for code generation: +[1] java +[2] typescript +(enter an integer): +>> 2 +Use docker for platform-independent packaging (Y/n)? +This is highly recommended unless you are experienced +with cross-platform Typescript packaging. +>> y +Initialized a new project in <> +$ cfn submit --dry-run +$ cat test.json +{ + "credentials": { + "accessKeyId": "", + "secretAccessKey": "", + "sessionToken": "" + }, + "action": "CREATE", + "request": { + "clientRequestToken": "ecba020e-b2e6-4742-a7d0-8a06ae7c4b2b", + "desiredResourceState": { + "Title": "foo", + "Description": "bar" + }, + "previousResourceState": null, + "logicalResourceIdentifier": null + }, + "callbackContext": null +} +$ sam local invoke TestEntrypoint --event test.json +``` Development ----------- diff --git a/python/rpdk/typescript/__init__.py b/python/rpdk/typescript/__init__.py new file mode 100644 index 0000000..0c85205 --- /dev/null +++ b/python/rpdk/typescript/__init__.py @@ -0,0 +1,5 @@ +import logging + +__version__ = "0.0.1" + +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/python/rpdk/typescript/codegen.py b/python/rpdk/typescript/codegen.py new file mode 100644 index 0000000..d97f2c2 --- /dev/null +++ b/python/rpdk/typescript/codegen.py @@ -0,0 +1,271 @@ +import logging +import shutil +import zipfile +from pathlib import PurePosixPath +from subprocess import PIPE, CalledProcessError, run as subprocess_run # nosec +from tempfile import TemporaryFile + +import docker +from docker.errors import APIError, ContainerError, ImageLoadError +from requests.exceptions import ConnectionError as RequestsConnectionError +from rpdk.core.data_loaders import resource_stream +from rpdk.core.exceptions import DownstreamError, SysExitRecommendedError +from rpdk.core.init import input_with_validation +from rpdk.core.jsonutils.resolver import ContainerType, resolve_models +from rpdk.core.plugin_base import LanguagePlugin + +from .resolver import translate_type +from .utils import safe_reserved + +LOG = logging.getLogger(__name__) + +EXECUTABLE = "cfn" +SUPPORT_LIB_NAME = "cfn-rpdk" + + +class StandardDistNotFoundError(SysExitRecommendedError): + pass + + +def validate_no(value): + return value.lower() not in ("n", "no") + + +class TypescriptLanguagePlugin(LanguagePlugin): + MODULE_NAME = __name__ + NAME = "typescript" + RUNTIME = "nodejs12.x" + ENTRY_POINT = "handlers.resource" + TEST_ENTRY_POINT = "handlers.testEntrypoint" + CODE_URI = "build/" + + def __init__(self): + self.env = self._setup_jinja_env( + trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True + ) + self.env.filters["translate_type"] = translate_type + self.env.filters["safe_reserved"] = safe_reserved + self.env.globals["ContainerType"] = ContainerType + self.namespace = None + self.package_name = None + self.package_root = None + self._use_docker = True + + def _init_from_project(self, project): + self.namespace = tuple(s.lower() for s in project.type_info) + self.package_name = "-".join(self.namespace) + self._use_docker = project.settings.get("use_docker", True) + self.package_root = project.root / "src" + + def _prompt_for_use_docker(self, project): + self._use_docker = input_with_validation( + "Use docker for platform-independent packaging (Y/n)?\n", + validate_no, + "This is highly recommended unless you are experienced \n" + "with cross-platform Typescript packaging.", + ) + project.settings["use_docker"] = self._use_docker + + def init(self, project): + LOG.debug("Init started") + + self._init_from_project(project) + self._prompt_for_use_docker(project) + + project.runtime = self.RUNTIME + project.entrypoint = self.ENTRY_POINT + project.test_entrypoint = self.TEST_ENTRY_POINT + + def _render_template(path, **kwargs): + LOG.debug("Writing '%s'", path) + template = self.env.get_template(path.name) + contents = template.render(**kwargs) + project.safewrite(path, contents) + + def _copy_resource(path, resource_name=None): + LOG.debug("Writing '%s'", path) + if not resource_name: + resource_name = path.name + contents = resource_stream(__name__, f"data/{resource_name}").read() + project.safewrite(path, contents) + + # handler Typescript package + handler_package_path = self.package_root + LOG.debug("Making folder '%s'", handler_package_path) + handler_package_path.mkdir(parents=True, exist_ok=True) + _render_template( + handler_package_path / "handlers.ts", + lib_name=SUPPORT_LIB_NAME, + type_name=project.type_name, + ) + # models.ts produced by generate + + # project support files + _copy_resource(project.root / ".gitignore", "typescript.gitignore") + _copy_resource(project.root / "tsconfig.json", "tsconfig.json") + _render_template( + project.root / "package.json", + name=project.hypenated_name, + description="AWS custom resource provider named {}.".format(project.type_name), + lib_name=SUPPORT_LIB_NAME, + ) + _render_template( + project.root / "README.md", + type_name=project.type_name, + schema_path=project.schema_path, + project_path=self.package_name, + executable=EXECUTABLE, + lib_name=SUPPORT_LIB_NAME, + ) + + # CloudFormation/SAM template for handler lambda + handler_params = { + "Handler": project.entrypoint, + "Runtime": project.runtime, + "CodeUri": self.CODE_URI, + } + _render_template( + project.root / "template.yml", + resource_type=project.type_name, + functions={ + "TypeFunction": handler_params, + "TestEntrypoint": { + **handler_params, + "Handler": project.test_entrypoint, + }, + }, + ) + + LOG.debug("Init complete") + + def generate(self, project): + LOG.debug("Generate started") + + self._init_from_project(project) + + models = resolve_models(project.schema) + + path = self.package_root / "models.ts" + LOG.debug("Writing file: %s", path) + template = self.env.get_template("models.ts") + contents = template.render( + lib_name=SUPPORT_LIB_NAME, + type_name=project.type_name, + models=models, + ) + project.overwrite(path, contents) + + LOG.debug("Generate complete") + + def _pre_package(self, build_path): + f = TemporaryFile("w+b") + + with zipfile.ZipFile(f, mode="w") as zip_file: + self._recursive_relative_write(build_path, build_path, zip_file) + f.seek(0) + + return f + + @staticmethod + def _recursive_relative_write(src_path, base_path, zip_file): + for path in src_path.rglob("*"): + if path.is_file(): + relative = path.relative_to(base_path) + zip_file.write(path.resolve(), str(relative)) + + def package(self, project, zip_file): + LOG.debug("Package started") + + self._init_from_project(project) + + handler_package_path = self.package_root + build_path = project.root / "build" + + self._remove_build_artifacts(build_path) + self._build(project.root) + shutil.copytree(str(handler_package_path), str(build_path / self.package_name)) + + inner_zip = self._pre_package(build_path) + zip_file.writestr("ResourceProvider.zip", inner_zip.read()) + self._recursive_relative_write(handler_package_path, project.root, zip_file) + + LOG.debug("Package complete") + + @staticmethod + def _remove_build_artifacts(deps_path): + try: + shutil.rmtree(deps_path) + except FileNotFoundError: + LOG.debug("'%s' not found, skipping removal", deps_path, exc_info=True) + + def _build(self, base_path): + LOG.debug("Dependencies build started from '%s'", base_path) + if self._use_docker: + self._docker_build(base_path) + else: + self._npm_build(base_path) + LOG.debug("Dependencies build finished") + + @staticmethod + def _make_npm_command(base_path): + return [ + "npm", + "install", + "--no-optional", + str(base_path), + ] + + @classmethod + def _docker_build(cls, external_path): + + internal_path = PurePosixPath("/project") + command = " ".join(cls._make_npm_command(internal_path)) + LOG.debug("command is '%s'", command) + + volumes = {str(external_path): {"bind": str(internal_path), "mode": "rw"}} + image = f"lambci/lambda:build-{cls.RUNTIME}" + LOG.warning( + "Starting Docker build. This may take several minutes if the " + "image '%s' needs to be pulled first.", + image, + ) + docker_client = docker.from_env() + try: + logs = docker_client.containers.run( + image=image, + command=command, + auto_remove=True, + volumes=volumes, + stream=True, + ) + except RequestsConnectionError as e: + # it seems quite hard to reliably extract the cause from + # ConnectionError. we replace it with a friendlier error message + # and preserve the cause for debug traceback + cause = RequestsConnectionError( + "Could not connect to docker - is it running?" + ) + cause.__cause__ = e + raise DownstreamError("Error running docker build") from cause + except (ContainerError, ImageLoadError, APIError) as e: + raise DownstreamError("Error running docker build") from e + LOG.debug("Build running. Output:") + for line in logs: + LOG.debug(line.rstrip(b"\n").decode("utf-8")) + + @classmethod + def _npm_build(cls, base_path): + + command = cls._make_npm_command(base_path) + LOG.debug("command is '%s'", command) + + LOG.warning("Starting npm build.") + try: + completed_proc = subprocess_run( # nosec + command, stdout=PIPE, stderr=PIPE, cwd=base_path, check=True + ) + except (FileNotFoundError, CalledProcessError) as e: + raise DownstreamError("npm build failed") from e + + LOG.debug("--- npm stdout:\n%s", completed_proc.stdout) + LOG.debug("--- npm stderr:\n%s", completed_proc.stderr) diff --git a/python/rpdk/typescript/data/__init__.py b/python/rpdk/typescript/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/rpdk/typescript/data/tsconfig.json b/python/rpdk/typescript/data/tsconfig.json new file mode 100644 index 0000000..487225a --- /dev/null +++ b/python/rpdk/typescript/data/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs", + "noImplicitAny": true, + "alwaysStrict": true, + "esModuleInterop": true, + "moduleResolution": "node", + "allowJs": true, + "experimentalDecorators": true, + "outDir": "build" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/python/rpdk/typescript/data/typescript.gitignore b/python/rpdk/typescript/data/typescript.gitignore new file mode 100644 index 0000000..36243a3 --- /dev/null +++ b/python/rpdk/typescript/data/typescript.gitignore @@ -0,0 +1,10 @@ +# Distribution / packaging +build/ +dist/ + +# RPDK logs +rpdk.log + +# Node.js +node_modules/ +coverage/ diff --git a/python/rpdk/typescript/resolver.py b/python/rpdk/typescript/resolver.py new file mode 100644 index 0000000..83434fe --- /dev/null +++ b/python/rpdk/typescript/resolver.py @@ -0,0 +1,28 @@ +from rpdk.core.jsonutils.resolver import UNDEFINED, ContainerType + +PRIMITIVE_TYPES = { + "string": "string", + "integer": "number", + "boolean": "boolean", + "number": "number", + UNDEFINED: "Object", +} + + +def translate_type(resolved_type): + if resolved_type.container == ContainerType.MODEL: + return resolved_type.type + if resolved_type.container == ContainerType.PRIMITIVE: + return PRIMITIVE_TYPES[resolved_type.type] + + item_type = translate_type(resolved_type.type) + + if resolved_type.container == ContainerType.DICT: + key_type = PRIMITIVE_TYPES["string"] + return f"Map<{key_type}, {item_type}>" + if resolved_type.container == ContainerType.LIST: + return f"Array<{item_type}>" + if resolved_type.container == ContainerType.SET: + return f"Set<{item_type}>" + + raise ValueError(f"Unknown container type {resolved_type.container}") diff --git a/python/rpdk/typescript/templates/README.md b/python/rpdk/typescript/templates/README.md new file mode 100644 index 0000000..895906b --- /dev/null +++ b/python/rpdk/typescript/templates/README.md @@ -0,0 +1,35 @@ +// {{ type_name }} + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `{{ schema_path.name }}` +2. Implement your resource handlers in `{{ project_path }}/handlers.ts` + +> Don't modify `models.ts` by hand, any modifications will be overwritten when the `generate` or `package` commands are run. + +Implement CloudFormation resource here. Each function must always return a ProgressEvent. + +```typescript +const built = ProgressEvent.builder({ + // Required + // Must be one of OperationStatus.InProgress, OperationStatus.Failed, OperationStatus.Success + status: OperationStatus.InProgress, + // Required on SUCCESS (except for LIST where resourceModels is required) + // The current resource model after the operation; instance of ResourceModel class + resourceModel: model, + resourceModels: null, + // Required on FAILED + // Customer-facing message, displayed in e.g. CloudFormation stack events + message: '', + // Required on FAILED a HandlerErrorCode + errorCode: HandlerErrorCode.InternalFailure, + // Optional + // Use to store any state between re-invocation via IN_PROGRESS + callbackContext: {}, + // Required on IN_PROGRESS + // The number of seconds to delay before re-invocation + callbackDelaySeconds: 0, +}).build() +``` + +Failures can be passed back to CloudFormation by either raising an exception from `{{ lib_name }}.exceptions`, or setting the ProgressEvent's `status` to `OperationStatus.Failed` and `errorCode` to one of `{{ lib_name }}.HandlerErrorCode`. There is a static helper function, `ProgressEvent.failed`, for this common case. diff --git a/python/rpdk/typescript/templates/handlers.ts b/python/rpdk/typescript/templates/handlers.ts new file mode 100644 index 0000000..e97ee54 --- /dev/null +++ b/python/rpdk/typescript/templates/handlers.ts @@ -0,0 +1,115 @@ +import { + Action, + BaseResource, + handlerAction, + HandlerErrorCode, + OperationStatus, + Optional, + ProgressEvent, + ResourceHandlerRequest, + SessionProxy, +} from '{{lib_name}}'; +import * as exceptions from '{{lib_name}}/dist/exceptions'; +import { ResourceModel } from './models'; + +// Use this logger to forward log messages to CloudWatch Logs. +const LOG = console; + +class Resource extends BaseResource { + + @handlerAction(Action.Create) + public create( + session: Optional, + request: ResourceHandlerRequest, + callbackContext: Map, + ): ProgressEvent { + const model: ResourceModel = request.desiredResourceState; + // @ts-ignore + const progress: ProgressEvent = ProgressEvent.builder({ + status: OperationStatus.InProgress, + resourceModel: model, + }).build(); + // TODO: put code here + + // Example: + try { + if (session instanceof SessionProxy) { + const client = session.client('s3'); + } + // Setting Status to success will signal to cfn that the operation is complete + progress.status = OperationStatus.Success; + } catch(err) { + LOG.log(err); + throw new exceptions.InternalFailure(err.message); + } + return progress; + } + + @handlerAction(Action.Update) + public update( + session: Optional, + request: ResourceHandlerRequest, + callbackContext: Map, + ): ProgressEvent { + const model: ResourceModel = request.desiredResourceState; + // @ts-ignore + const progress: ProgressEvent = ProgressEvent.builder({ + status: OperationStatus.InProgress, + resourceModel: model, + }).build(); + // TODO: put code here + return progress; + } + + @handlerAction(Action.Delete) + public delete( + session: Optional, + request: ResourceHandlerRequest, + callbackContext: Map, + ): ProgressEvent { + const model: ResourceModel = request.desiredResourceState; + // @ts-ignore + const progress: ProgressEvent = ProgressEvent.builder({ + status: OperationStatus.InProgress, + resourceModel: model, + }).build(); + // TODO: put code here + return progress; + } + + @handlerAction(Action.Read) + public read( + session: Optional, + request: ResourceHandlerRequest, + callbackContext: Map, + ): ProgressEvent { + const model: ResourceModel = request.desiredResourceState; + // TODO: put code here + // @ts-ignore + ProgressEvent.progress() + const progress: ProgressEvent = ProgressEvent.builder({ + status: OperationStatus.Success, + resourceModel: model, + }).build(); + return progress; + } + + @handlerAction(Action.List) + public list( + session: Optional, + request: ResourceHandlerRequest, + callbackContext: Map, + ): ProgressEvent { + // TODO: put code here + // @ts-ignore + const progress: ProgressEvent = ProgressEvent.builder({ + status: OperationStatus.Success, + resourceModels: [], + }).build(); + return progress; + } +} + +export const resource = new Resource(); + +export const testEntrypoint = resource.testEntrypoint; diff --git a/python/rpdk/typescript/templates/models.ts b/python/rpdk/typescript/templates/models.ts new file mode 100644 index 0000000..d927f28 --- /dev/null +++ b/python/rpdk/typescript/templates/models.ts @@ -0,0 +1,16 @@ +// This is a generated file. Modifications will be overwritten. +import { BaseResourceModel, Optional } from '{{lib_name}}'; +import { allArgsConstructor, builder } from 'tombok'; + +{% for model, properties in models.items() %} +@builder +@allArgsConstructor +export class {{ model|uppercase_first_letter }}{% if model == "ResourceModel" %} extends BaseResourceModel{% endif %} { + public static typeName: string = '{{ type_name }}'; + + {% for name, type in properties.items() %} + {{ name|lowercase_first_letter|safe_reserved }}: Optional<{{ type|translate_type }}>; + {% endfor %} +} + +{% endfor -%} diff --git a/python/rpdk/typescript/templates/package.json b/python/rpdk/typescript/templates/package.json new file mode 100644 index 0000000..bafa24b --- /dev/null +++ b/python/rpdk/typescript/templates/package.json @@ -0,0 +1,20 @@ +{ + "name": "{{ name }}", + "version": "1.0.0", + "description": "{{ description }}", + "main": "index.js", + "scripts": { + "build": "npx tsc", + "postinstall": "npm run build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "{{lib_name}}": "git+https://github.com/eduardomourar/cloudformation-cli-typescript-plugin.git" + }, + "devDependencies": { + "tombok": "git+https://github.com/eduardomourar/tombok.git#feature/basic-implementation", + "typescript": "^3.8.3" + } +} diff --git a/python/rpdk/typescript/utils.py b/python/rpdk/typescript/utils.py new file mode 100644 index 0000000..32ac1ce --- /dev/null +++ b/python/rpdk/typescript/utils.py @@ -0,0 +1,81 @@ +# https://github.com/Microsoft/TypeScript/issues/2536 +LANGUAGE_KEYWORDS = { + "abstract", + "any", + "as", + "async", + "await", + "boolean", + "break", + "case", + "catch", + "class", + "configurable", + "const", + "constructor", + "continue", + "debugger", + "declare", + "default", + "delete", + "do", + "else", + "enum", + "enumerable", + "export", + "extends", + "false", + "finally", + "for", + "from", + "function", + "get", + "if", + "in", + "implements", + "import", + "instanceof", + "interface", + "is", + "let", + "module", + "namespace", + "never", + "new", + "null", + "number", + "of", + "package", + "private", + "protected", + "public", + "readonly", + "require", + "return", + "set", + "static", + "string", + "super", + "switch", + "symbol", + "this", + "throw", + "true", + "try", + "type", + "typeof", + "undefined", + "value", + "var", + "void", + "while", + "with", + "writable", + "yield", +} + + +def safe_reserved(token): + if token in LANGUAGE_KEYWORDS: + return token + "_" + return token diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugin/__init__.py b/tests/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugin/codegen_test.py b/tests/plugin/codegen_test.py new file mode 100644 index 0000000..9c4fe29 --- /dev/null +++ b/tests/plugin/codegen_test.py @@ -0,0 +1,243 @@ +# pylint: disable=redefined-outer-name,protected-access +import ast +import importlib.util +from subprocess import CalledProcessError +from unittest.mock import ANY, patch, sentinel +from uuid import uuid4 +from zipfile import ZipFile + +import pytest + +from docker.errors import APIError, ContainerError, ImageLoadError +from requests.exceptions import ConnectionError as RequestsConnectionError +from rpdk.core.exceptions import DownstreamError +from rpdk.core.project import Project +from rpdk.typescript.codegen import ( + SUPPORT_LIB_NAME, + TypescriptLanguagePlugin, + StandardDistNotFoundError, + validate_no, +) + +TYPE_NAME = "foo::bar::baz" + + +@pytest.fixture +def plugin(): + return TypescriptLanguagePlugin() + + +@pytest.fixture +def project(tmp_path): + project = Project(root=tmp_path) + + patch_plugins = patch.dict( + "rpdk.core.plugin_registry.PLUGIN_REGISTRY", + {TypescriptLanguagePlugin.NAME: lambda: TypescriptLanguagePlugin}, + clear=True, + ) + patch_wizard = patch( + "rpdk.typescript.codegen.input_with_validation", autospec=True, side_effect=[False] + ) + with patch_plugins, patch_wizard: + project.init(TYPE_NAME, TypescriptLanguagePlugin.NAME) + return project + + +def get_files_in_project(project): + return { + str(child.relative_to(project.root)): child for child in project.root.rglob("*") + } + + +@pytest.mark.parametrize( + "value,result", + [ + ("y", True), + ("Y", True), + ("yes", True), + ("Yes", True), + ("YES", True), + ("asdf", True), + ("no", False), + ("No", False), + ("No", False), + ("n", False), + ("N", False), + ], +) +def test_validate_no(value, result): + assert validate_no(value) is result + + +def test__remove_build_artifacts_file_found(tmp_path): + deps_path = tmp_path / "build" + deps_path.mkdir() + TypescriptLanguagePlugin._remove_build_artifacts(deps_path) + + +def test__remove_build_artifacts_file_not_found(tmp_path): + deps_path = tmp_path / "build" + with patch("rpdk.typescript.codegen.LOG", autospec=True) as mock_log: + TypescriptLanguagePlugin._remove_build_artifacts(deps_path) + + mock_log.debug.assert_called_once() + + +def test_initialize(project): + assert project.settings == {"use_docker": False} + + files = get_files_in_project(project) + assert set(files) == { + ".gitignore", + ".rpdk-config", + "README.md", + "foo-bar-baz.json", + "package.json", + "tsconfig.json", + "src", + "src/handlers.ts", + "template.yml", + } + + assert "node_modules" in files[".gitignore"].read_text() + assert SUPPORT_LIB_NAME in files["package.json"].read_text() + + readme = files["README.md"].read_text() + assert project.type_name in readme + assert SUPPORT_LIB_NAME in readme + assert "handlers.ts" in readme + assert "models.ts" in readme + + assert project.entrypoint in files["template.yml"].read_text() + + +def test_generate(project): + project.load_schema() + before = get_files_in_project(project) + project.generate() + after = get_files_in_project(project) + files = after.keys() - before.keys() - {"resource-role.yaml"} + + assert files == {"src/models.ts"} + + models_path = after["src/models.ts"] + + +def test_package_npm(project): + project.load_schema() + project.generate() + + zip_path = project.root / "foo-bar-baz.zip" + + with zip_path.open("wb") as f, ZipFile(f, mode="w") as zip_file: + project._plugin.package(project, zip_file) + + with zip_path.open("rb") as f, ZipFile(f, mode="r") as zip_file: + assert sorted(zip_file.namelist()) == [ + "ResourceProvider.zip", + "src/handlers.ts", + "src/models.ts", + ] + + +def test__npm_build_executable_not_found(tmp_path): + executable_name = str(uuid4()) + patch_cmd = patch.object( + TypescriptLanguagePlugin, "_make_npm_command", return_value=[executable_name] + ) + + with patch_cmd as mock_cmd: + with pytest.raises(DownstreamError) as excinfo: + TypescriptLanguagePlugin._npm_build(tmp_path) + + mock_cmd.assert_called_once_with(tmp_path) + + assert isinstance(excinfo.value.__cause__, FileNotFoundError) + + +def test__npm_build_called_process_error(tmp_path): + patch_cmd = patch.object( + TypescriptLanguagePlugin, "_make_npm_command", return_value=["false"] + ) + + with patch_cmd as mock_cmd: + with pytest.raises(DownstreamError) as excinfo: + TypescriptLanguagePlugin._npm_build(tmp_path) + + mock_cmd.assert_called_once_with(tmp_path) + + assert isinstance(excinfo.value.__cause__, CalledProcessError) + + +def test__build_npm(plugin): + plugin._use_docker = False + + patch_npm = patch.object(plugin, "_npm_build", autospec=True) + patch_docker = patch.object(plugin, "_docker_build", autospec=True) + with patch_docker as mock_docker, patch_npm as mock_npm: + plugin._build(sentinel.base_path) + + mock_docker.assert_not_called() + mock_npm.assert_called_once_with(sentinel.base_path) + + +def test__build_docker(plugin): + plugin._use_docker = True + + patch_npm = patch.object(plugin, "_npm_build", autospec=True) + patch_docker = patch.object(plugin, "_docker_build", autospec=True) + with patch_docker as mock_docker, patch_npm as mock_npm: + plugin._build(sentinel.base_path) + + mock_npm.assert_not_called() + mock_docker.assert_called_once_with(sentinel.base_path) + + +def test__docker_build_good_path(plugin, tmp_path): + patch_from_env = patch("rpdk.typescript.codegen.docker.from_env", autospec=True) + + with patch_from_env as mock_from_env: + mock_run = mock_from_env.return_value.containers.run + mock_run.return_value = [b"output\n\n"] + plugin._docker_build(tmp_path) + + mock_from_env.assert_called_once_with() + mock_run.assert_called_once_with( + image=ANY, + command=ANY, + auto_remove=True, + volumes={str(tmp_path): {"bind": "/project", "mode": "rw"}}, + stream=True, + ) + + +@pytest.mark.parametrize( + "exception", + [ + lambda: ContainerError("abcde", 255, "/bin/false", "image", ""), + ImageLoadError, + lambda: APIError("500"), + lambda: RequestsConnectionError( + "Connection aborted.", ConnectionRefusedError(61, "Connection refused") + ), + ], +) +def test__docker_build_bad_path(plugin, tmp_path, exception): + patch_from_env = patch("rpdk.typescript.codegen.docker.from_env", autospec=True) + + with patch_from_env as mock_from_env: + mock_run = mock_from_env.return_value.containers.run + mock_run.side_effect = exception() + + with pytest.raises(DownstreamError): + plugin._docker_build(tmp_path) + + mock_from_env.assert_called_once_with() + mock_run.assert_called_once_with( + image=ANY, + command=ANY, + auto_remove=True, + volumes={str(tmp_path): {"bind": "/project", "mode": "rw"}}, + stream=True, + ) diff --git a/tests/plugin/resolver_test.py b/tests/plugin/resolver_test.py new file mode 100644 index 0000000..843d11e --- /dev/null +++ b/tests/plugin/resolver_test.py @@ -0,0 +1,43 @@ +import pytest +from rpdk.core.jsonutils.resolver import ContainerType, ResolvedType +from rpdk.typescript.resolver import PRIMITIVE_TYPES, translate_type + +RESOLVED_TYPES = [ + (ResolvedType(ContainerType.PRIMITIVE, item_type), native_type) + for item_type, native_type in PRIMITIVE_TYPES.items() +] + + +def test_translate_type_model_passthrough(): + item_type = object() + traslated = translate_type(ResolvedType(ContainerType.MODEL, item_type)) + assert traslated is item_type + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_translate_type_primitive(resolved_type, native_type): + assert translate_type(resolved_type) == native_type + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_translate_type_dict(resolved_type, native_type): + traslated = translate_type(ResolvedType(ContainerType.DICT, resolved_type)) + assert traslated == f"Map" + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_translate_type_list(resolved_type, native_type): + traslated = translate_type(ResolvedType(ContainerType.LIST, resolved_type)) + assert traslated == f"Array<{native_type}>" + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_translate_type_set(resolved_type, native_type): + traslated = translate_type(ResolvedType(ContainerType.SET, resolved_type)) + assert traslated == f"Set<{native_type}>" + + +@pytest.mark.parametrize("resolved_type,_typescript_type", RESOLVED_TYPES) +def test_translate_type_unknown(resolved_type, _typescript_type): + with pytest.raises(ValueError): + translate_type(ResolvedType("foo", resolved_type))