├── .github ├── FUNDING.yml ├── SECURITY.md └── workflows │ ├── publish.yml │ └── push.yml ├── .gitignore ├── LICENSE ├── README.md ├── _config.yml ├── doc ├── browse.png ├── clean.png ├── deploy.png ├── generate.png ├── help.png ├── init.png ├── tutorial.gif └── undeploy.png ├── package-lock.json ├── package.json ├── sample.md ├── src ├── cli.ts ├── cli │ ├── banner.ts │ ├── browse.ts │ ├── clean.ts │ ├── deploy.ts │ ├── files.ts │ ├── generate.test.ts │ ├── generate.ts │ ├── init.test.ts │ ├── init.ts │ ├── log.ts │ └── undeploy.ts ├── index.test.ts └── index.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: khalidx 4 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The following versions of this project are currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ----------------- | ------------------ | 9 | | 1.0.x | :white_check_mark: | 10 | | 1.0.0 (current) | :white_check_mark: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Please open an issue to report a known or possible vulnerability. 15 | 16 | If your vulnerability is accepted, I'll buy you a coffee, and will forever hold you in high regard. 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests, then publish the package to both the NPM and GitHub Packages registries when a release is published 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | - run: npm test 20 | - uses: actions/upload-artifact@v1 21 | with: 22 | name: dist 23 | path: dist 24 | 25 | publish-npm: 26 | needs: build 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | - uses: actions/setup-node@v1 31 | with: 32 | node-version: 12 33 | registry-url: https://registry.npmjs.org/ 34 | - uses: actions/download-artifact@v1 35 | with: 36 | name: dist 37 | - run: npm publish --access=public 38 | env: 39 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 40 | 41 | publish-gpr: 42 | needs: build 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v2 46 | - uses: actions/setup-node@v1 47 | with: 48 | node-version: 12 49 | registry-url: https://npm.pkg.github.com/ 50 | - uses: actions/download-artifact@v1 51 | with: 52 | name: dist 53 | - run: npm publish 54 | env: 55 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 56 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests on every push 2 | 3 | name: Push 4 | 5 | on: [push] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore generated directories 2 | dist/ 3 | exec/ 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Khalid Zoabi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # resource-x 2 | 3 | Resource and domain modeling for quick APIs, CMSs, and applications. 4 | 5 | ![GitHub package.json dynamic](https://img.shields.io/github/package-json/keywords/khalidx/resource-x.svg?style=flat-square) 6 | 7 | ![GitHub](https://img.shields.io/github/license/khalidx/resource-x.svg?style=flat-square) 8 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/khalidx/resource-x.svg?style=flat-square) 9 | ![GitHub top language](https://img.shields.io/github/languages/top/khalidx/resource-x.svg?style=flat-square) 10 | 11 | ![GitHub last commit](https://img.shields.io/github/last-commit/khalidx/resource-x.svg?style=flat-square) 12 | 13 | ## Quick start 14 | 15 | Deploy an API to the cloud **in under 30 seconds**, *in just 3 steps*. 16 | 17 | ```sh 18 | rx init 19 | rx generate sample.md 20 | rx deploy sample.md 21 | ``` 22 | 23 | Interested? Check out the easy [installation](#installation) instructions. 24 | 25 | ## Tutorial 26 | 27 | ![tutorial](./doc/tutorial.gif) 28 | 29 | 1) Build your domain objects as JSON Schemas, all in the same Markdown document. Alternatively, run `rx init` to get a [ready-to-use document](./sample.md) with two sample schemas. 30 | 31 | 2) When you run `rx generate sample.md`, you'll get a full CRUD (create-read-update-delete) Swagger specification for your API. 32 | 33 | 3) You can then deploy your specification to AWS API Gateway, complete with request validation and mock responses, with a single `rx deploy sample.md` command. 34 | 35 | How easy was that? 36 | 37 | ## Features 38 | 39 | - Domain modeling with simple schema objects 40 | - Markdown support for easy writing, easy sharing, and good documentation 41 | - Generate a full CRUD Swagger REST API with a single command 42 | - Deploy a fully mocked API to AWS API gateway with a single command 43 | - Request validation based on your schema objects 44 | - Generates useful files that you can use with other tools, like `terraform` and `postman` 45 | - CLI application works on Windows, Mac, and Linux, and everywhere node is supported 46 | - Open source + free forever, with excellent support 47 | 48 | ## Installation 49 | 50 | Installing is easy with [npm](https://www.npmjs.com/package/@khalidx/resource-x). 51 | 52 | ```sh 53 | npm install -g @khalidx/resource-x 54 | ``` 55 | 56 | Alternatively, you can also [download a binary](https://github.com/khalidx/resource-x/releases/latest) for your operating system. 57 | 58 | Windows, Mac, and Linux are all supported. 59 | 60 | ## Usage 61 | 62 | Initialize a new sample project in the current directory. 63 | 64 | ```sh 65 | rx init 66 | ``` 67 | 68 | ![init](./doc/init.png) 69 | 70 | Generate an API specification from the document file. 71 | 72 | ```sh 73 | rx generate 74 | ``` 75 | 76 | ![generate](./doc/generate.png) 77 | 78 | Opens the browser to view the resources in the document file. 79 | 80 | ```sh 81 | rx browse 82 | ``` 83 | 84 | ![browse](./doc/browse.png) 85 | 86 | Deploy the API with mock integration to AWS API Gateway. 87 | 88 | ```sh 89 | rx deploy 90 | ``` 91 | 92 | ![deploy](./doc/deploy.png) 93 | 94 | Undeploy the API from AWS API Gateway. 95 | 96 | ```sh 97 | rx undeploy 98 | ``` 99 | 100 | ![undeploy](./doc/undeploy.png) 101 | 102 | Remove the generated .rx/ directory. 103 | 104 | ```sh 105 | rx clean 106 | ``` 107 | 108 | ![clean](./doc/clean.png) 109 | 110 | See help and usage information about all available commands. 111 | 112 | ```sh 113 | rx --help 114 | ``` 115 | 116 | ![help](./doc/help.png) 117 | 118 | ## Pro tips and tricks 119 | 120 | - Commit the `.rx/**/deploy.json` files. These track your AWS API Gateway deployments, so that you don't end up creating a new API every time you check out from git and deploy. 121 | 122 | - If you've already deployed your API, then later decide to rename it (by changing the heading in the Markdown document), make sure you also rename the corresponding `.rx/` directory for the API. This will ensure that you deploy an update to the same API rather than creating a new one. 123 | 124 | - Make sure you only use AWS API Gateway compatible schema definitions. AWS does not support the full Swagger definition language. [Read more](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-known-issues.html#api-gateway-known-issues-rest-apis) about what is supported (and what isn't) in the AWS documentation. 125 | 126 | - You may want to do more advanced things with your API that this tool does not support. You can still use the tool to get started and generate a Swagger definition, then modify your definition by hand or with other tools before uploading to AWS manually. This will still save you some time, since writing the initial Swagger with all operations and AWS support is very time consuming. 127 | 128 | - The module generates a `main.tf` file for your Swagger specification. This lets you import and continue your workflow in terraform! 129 | 130 | ## Support 131 | 132 | Open a GitHub issue to ask a question, report a bug, raise a concern, or request a new feature. 133 | 134 | Also, your question may already be answered on the following [Hacker News thread](https://news.ycombinator.com/item?id=20322759). 135 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: pmarsceill/just-the-docs 2 | 3 | aux_links: 4 | "resource-x on GitHub": 5 | - "//github.com/khalidx/resource-x" 6 | 7 | footer_content: "Copyright © 2019-2020 Khalid Zoabi. Distributed with an MIT license." 8 | -------------------------------------------------------------------------------- /doc/browse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/browse.png -------------------------------------------------------------------------------- /doc/clean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/clean.png -------------------------------------------------------------------------------- /doc/deploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/deploy.png -------------------------------------------------------------------------------- /doc/generate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/generate.png -------------------------------------------------------------------------------- /doc/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/help.png -------------------------------------------------------------------------------- /doc/init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/init.png -------------------------------------------------------------------------------- /doc/tutorial.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/tutorial.gif -------------------------------------------------------------------------------- /doc/undeploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khalidx/resource-x/98c401f89a986924222488cb5d6852d25f300bc0/doc/undeploy.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@khalidx/resource-x", 3 | "version": "1.4.1", 4 | "description": "Resource and domain modeling for quick APIs, CMSs, and applications.", 5 | "author": "Khalid Zoabi ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "api", 9 | "markdown", 10 | "json-schema", 11 | "swagger", 12 | "aws-api-gateway" 13 | ], 14 | "homepage": "https://khalidx.github.io/resource-x/", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/khalidx/resource-x.git" 18 | }, 19 | "main": "dist/index.js", 20 | "bin": { 21 | "rx": "dist/cli.js" 22 | }, 23 | "files": [ 24 | "sample.md", 25 | "dist/**/*", 26 | "!dist/**/*.test.js" 27 | ], 28 | "scripts": { 29 | "dev": "ts-node src/cli.ts", 30 | "clean": "rimraf dist/ exec/", 31 | "build": "npm run clean && tsc", 32 | "test": "npm run build && ava --verbose", 33 | "bundle-windows": "nexe --resource \"./sample.md\" --input dist/cli.js --name exec/rx-windows --target windows-10.16.0", 34 | "bundle-macos": "nexe --resource \"./sample.md\" --input dist/cli.js --name exec/rx-macos --target macos-10.16.0", 35 | "bundle-linux": "nexe --resource \"./sample.md\" --input dist/cli.js --name exec/rx-linux --target linux-10.16.0", 36 | "bundle": "npm run build && npm run bundle-windows && npm run bundle-macos && npm run bundle-linux" 37 | }, 38 | "dependencies": { 39 | "aws-sdk": "^2.488.0", 40 | "camelcase": "^5.3.1", 41 | "chalk": "^2.4.2", 42 | "commander": "^2.20.0", 43 | "debug": "^4.1.1", 44 | "express": "^4.17.1", 45 | "figlet": "^1.2.3", 46 | "fs-extra": "^8.1.0", 47 | "inquirer": "^6.4.1", 48 | "js-yaml": "^3.13.1", 49 | "json-schema-faker": "^0.5.0-rc17", 50 | "lodash": "^4.17.11", 51 | "marked": "^0.7.0", 52 | "open": "^6.4.0", 53 | "pluralize": "^8.0.0", 54 | "proxy-agent": "^3.1.1", 55 | "swagger-parser": "^8.0.0", 56 | "swagger-ui-express": "^4.0.7", 57 | "swagger2-to-postmanv2": "^1.0.1" 58 | }, 59 | "devDependencies": { 60 | "@types/express": "^4.17.0", 61 | "@types/figlet": "^1.2.0", 62 | "@types/fs-extra": "^8.0.0", 63 | "@types/inquirer": "^6.0.3", 64 | "@types/js-yaml": "^3.12.1", 65 | "@types/lodash": "^4.14.135", 66 | "@types/marked": "^0.6.5", 67 | "@types/node": "^12.0.12", 68 | "@types/pluralize": "0.0.29", 69 | "@types/swagger-ui-express": "^3.0.1", 70 | "@types/yamljs": "^0.2.30", 71 | "ava": "^2.1.0", 72 | "nexe": "^3.3.2", 73 | "rimraf": "^2.6.3", 74 | "ts-node": "^8.3.0", 75 | "typescript": "^3.5.2" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /sample.md: -------------------------------------------------------------------------------- 1 | # A Sample API 2 | 3 | This sample API is for managing people and teams. 4 | 5 | ## The person model 6 | 7 | ```json 8 | { 9 | "person": { 10 | "type": "object", 11 | "required": [ "name", "age" ], 12 | "properties": { 13 | "name": { 14 | "type": "string", 15 | "description": "The person's full name." 16 | }, 17 | "age": { 18 | "description": "The person's age in years, which must be equal to or greater than zero.", 19 | "type": "integer", 20 | "minimum": 0 21 | } 22 | } 23 | } 24 | } 25 | ``` 26 | 27 | ## The team model 28 | 29 | A team is a group of people. 30 | 31 | *Yes, you can also write your JSON Schemas in YAML (easier to read).* 32 | *Yes, you can refer to other schemas (the team schema below refers to the person schema above).* 33 | 34 | ```yaml 35 | team: 36 | type: object 37 | required: 38 | - name 39 | - people 40 | properties: 41 | name: 42 | type: string 43 | description: 'The name of the team' 44 | people: 45 | type: array 46 | description: 'The people on the team' 47 | items: 48 | $ref: '#/definitions/person' 49 | ``` 50 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import program from 'commander' 4 | import debug from 'debug' 5 | 6 | import { log } from './cli/log' 7 | import { showBanner } from './cli/banner' 8 | import { init } from './cli/init' 9 | import { generate } from './cli/generate' 10 | import { browse } from './cli/browse' 11 | import { deploy } from './cli/deploy' 12 | import { undeploy } from './cli/undeploy' 13 | import { clean } from './cli/clean' 14 | 15 | /** 16 | * Logs and exits with a non-zero status code on error. 17 | * @param error the error object 18 | */ 19 | async function onError (error: any): Promise { 20 | // If debug is on, log the entire error object, otherwise log just the message 21 | log('error', debug.enabled('rx:cli') ? error : error.message) 22 | process.exit(1) 23 | } 24 | 25 | program 26 | .version(require('../package.json').version) 27 | 28 | program 29 | .option('-d, --debug', 'Show debug-level "verbose" output while running commands') 30 | .on('option:debug', () => debug.enable('rx:cli')) 31 | 32 | program 33 | .command('init') 34 | .description('initialize a new sample project in the current directory') 35 | .action((cmd) => showBanner().then(() => init(process.cwd()).catch(onError))) 36 | 37 | program 38 | .command('generate ') 39 | .description('generate an API specification from the document file') 40 | .action((file, cmd) => showBanner().then(() => generate(process.cwd(), file).catch(onError))) 41 | 42 | program 43 | .command('browse ') 44 | .description('opens the browser to view the resources in the document file') 45 | .action((file, cmd) => showBanner().then(() => browse(process.cwd(), file).catch(onError))) 46 | 47 | program 48 | .command('deploy ') 49 | .description('deploy the API with mock integration to AWS API Gateway') 50 | .action((file, cmd) => showBanner().then(() => deploy(process.cwd(), file).catch(onError))) 51 | 52 | program 53 | .command('undeploy ') 54 | .description('undeploy the API from AWS API Gateway') 55 | .action((file, cmd) => showBanner().then(() => undeploy(process.cwd(), file).catch(onError))) 56 | 57 | program 58 | .command('clean') 59 | .description('remove the generated .rx/ directory') 60 | .action((cmd) => showBanner().then(() => clean(process.cwd()).catch(onError))) 61 | 62 | program.on('command:*', function () { 63 | onError(new Error(`Invalid command: ${program.args.join(' ')}\nSee --help for a list of available commands.`)) 64 | }) 65 | 66 | program 67 | .parse(process.argv) 68 | -------------------------------------------------------------------------------- /src/cli/banner.ts: -------------------------------------------------------------------------------- 1 | import figlet from 'figlet' 2 | 3 | import { log } from './log' 4 | 5 | /** 6 | * Clears the console and shows the banner 7 | */ 8 | export async function showBanner (): Promise { 9 | return new Promise(function (resolve, reject) { 10 | process.stdout.write('\x1b[2J') 11 | process.stdout.write('\x1b[0f') 12 | figlet.text('resource-x', function (error, result) { 13 | if (error) reject(error) 14 | else { 15 | log('message', result) 16 | resolve() 17 | } 18 | }) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/cli/browse.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import swaggerUi from 'swagger-ui-express' 3 | import open from 'open' 4 | 5 | import { log } from './log' 6 | import * as files from './files' 7 | 8 | /** 9 | * Opens the browser to view the generated Swagger API specification. 10 | * @param directory the absolute path of the directory to use 11 | * @param file the path of the document file to use 12 | */ 13 | export const browse = async (directory: string, file: string): Promise => { 14 | // Ensure the corresponding swagger file for the provided document file exists 15 | let exists = await files.exists(files.swaggerFile(directory, file)) 16 | if (!exists) { 17 | log('error', 'The .rx/swagger.json file does not exist. Run the generate command first.') 18 | return 19 | } 20 | // Read the swagger file 21 | let specification = await files.readSwaggerFile(directory, file) 22 | // Serve the Swagger file in the Swagger UI, and open the browser 23 | let app = express() 24 | let port = 8080 25 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specification)) 26 | app.listen(port, () => { 27 | log('message', `swagger-ui listening on port ${port}`) 28 | open(`http://localhost:${port}/api-docs`) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/cli/clean.ts: -------------------------------------------------------------------------------- 1 | import { remove } from 'fs-extra' 2 | import inquirer from 'inquirer' 3 | 4 | import { log } from './log' 5 | import { rxDirectory } from './files' 6 | 7 | /** 8 | * Removes the generated .rx/ directory 9 | * @param directory the absolute path of the directory to use 10 | */ 11 | export const clean = async (directory: string): Promise => { 12 | log('message', 'Cleaning the .rx/ directory will remove any AWS API ID tracker (deploy.json) files.') 13 | log('message', 'The next time you deploy, a new API will be created.') 14 | let { proceed } = await inquirer.prompt<{ proceed: boolean }>([ 15 | { 16 | name: 'proceed', 17 | message: 'Are you sure you would like to continue?', 18 | type: 'confirm', 19 | default: false 20 | } 21 | ]) 22 | if (!proceed) { 23 | log('message', 'No changes made.') 24 | return 25 | } 26 | await remove(rxDirectory(directory)) 27 | log('success', 'Directory removed.') 28 | } 29 | -------------------------------------------------------------------------------- /src/cli/deploy.ts: -------------------------------------------------------------------------------- 1 | import { log } from './log' 2 | import * as files from './files' 3 | import * as rx from '../index' 4 | 5 | /** 6 | * Deploys the API with mock integration to the AWS API Gateway 7 | * @param directory the absolute path of the directory to use 8 | * @param file the path of the document file to use 9 | */ 10 | export const deploy = async (directory: string, file: string): Promise => { 11 | // Ensure the corresponding swagger file for the provided document file exists 12 | let exists = await files.exists(files.swaggerFile(directory, file)) 13 | if (!exists) { 14 | log('error', 'The swagger.json file does not exist. Run the generate command first.') 15 | return 16 | } 17 | // Read the swagger file 18 | let specification = await files.readSwaggerFile(directory, file) 19 | // Add mocks, and save the new specification file 20 | let specificationWithMocks = await rx.mocks(specification) 21 | await files.writeSwaggerWithMocksFile(directory, file, specificationWithMocks) 22 | // Check to see if an existing deployment exists 23 | let deployExists = await files.exists(files.deployFile(directory, file)) 24 | if (deployExists) { 25 | // Read the deploy.json 26 | let { id } = await files.readDeployFile(directory, file) 27 | // Update the deployment, with the specification with mocks 28 | log('message', 'Updating the existing deployment ...') 29 | let deploy = await rx.deploy(specificationWithMocks, id) 30 | log('success', `Deployed successfully. Url:\n${deploy.url}`) 31 | } else { 32 | log('message', 'Creating a new deployment ...') 33 | // Deploy the specification with mocks 34 | let deploy = await rx.deploy(specificationWithMocks) 35 | // Write the deploy.json (so that new deploys will deploy to the same API) 36 | await files.writeDeployFile(directory, file, deploy) 37 | log('success', `Deployed successfully. Url:\n${deploy.url}`) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/cli/files.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fse from 'fs-extra' 3 | import yaml from 'js-yaml' 4 | import { OpenAPIV2 } from 'openapi-types' 5 | 6 | import { Deploy } from '../index' 7 | 8 | /** 9 | * Returns whether the provided path exists 10 | * @param path the path to a file or directory 11 | */ 12 | export const exists = (path: string) => fse.pathExists(path) 13 | 14 | /** 15 | * Returns the resolved path to the document file 16 | * @param file the path of the document file to use 17 | */ 18 | export const documentFile = (file: string): string => path.resolve(file) 19 | 20 | /** 21 | * Returns the name of the document file without the extension 22 | * @param file the path of the document file to use 23 | */ 24 | export const documentFileName = (file: string): string => path.basename(file, path.extname(file)) 25 | 26 | /** 27 | * Returns the resolved path to the `.rx/` directory 28 | * @param directory the absolute path of the directory to use 29 | */ 30 | export const rxDirectory = (directory: string): string => path.join(directory, '.rx/') 31 | 32 | /** 33 | * Returns the resolved path to the `rx//` subdirectory 34 | * @param directory the absolute path of the directory to use 35 | * @param file the path of the document file to use 36 | */ 37 | export const rxSubdirectory = (directory: string, file: string): string => path.join(rxDirectory(directory), documentFileName(file)) 38 | 39 | /** 40 | * Returns the resolved path to the Swagger file 41 | * @param directory the absolute path of the directory to use 42 | * @param file the path of the document file to use 43 | */ 44 | export const swaggerFile = (directory: string, file: string): string => path.join(rxSubdirectory(directory, file), 'swagger.yaml') 45 | 46 | /** 47 | * Returns the resolved path to the Postman file 48 | * @param directory the absolute path of the directory to use 49 | * @param file the path of the document file to use 50 | */ 51 | export const postmanFile = (directory: string, file: string): string => path.join(rxSubdirectory(directory, file), 'postman.json') 52 | 53 | /** 54 | * Returns the resolved path to the Terraform file 55 | * @param directory the absolute path of the directory to use 56 | * @param file the path of the document file to use 57 | */ 58 | export const terraformFile = (directory: string, file: string): string => path.join(rxSubdirectory(directory, file), 'main.tf') 59 | 60 | /** 61 | * Returns the resolved path to the Swagger with mocks file 62 | * @param directory the absolute path of the directory to use 63 | * @param file the path of the document file to use 64 | */ 65 | export const swaggerWithMocksFile = (directory: string, file: string): string => path.join(rxSubdirectory(directory, file), 'swagger.mock.yaml') 66 | 67 | /** 68 | * Returns the resolved path to the deployment tracker file 69 | * @param directory the absolute path of the directory to use 70 | * @param file the path of the document file to use 71 | */ 72 | export const deployFile = (directory: string, file: string): string => path.join(rxSubdirectory(directory, file), 'deploy.yaml') 73 | 74 | /** 75 | * Returns the resolved path to the `.gitignore` file 76 | * @param directory the absolute path of the directory to use 77 | * @param file the path of the document file to use 78 | */ 79 | export const gitignoreFile = (directory: string, file: string): string => path.join(rxSubdirectory(directory, file), '.gitignore') 80 | 81 | /** 82 | * Reads the document file 83 | * @param file the path of the document file to use 84 | */ 85 | export const readDocumentFile = async (file: string): Promise => (await fse.readFile(documentFile(file))).toString() 86 | 87 | /** 88 | * Reads the Swagger file 89 | * @param directory the absolute path of the directory to use 90 | * @param file the path of the document file to use 91 | */ 92 | export const readSwaggerFile = async (directory: string, file: string): Promise => yaml.safeLoad((await fse.readFile(swaggerFile(directory, file))).toString()) 93 | 94 | /** 95 | * Writes the Swagger file 96 | * @param directory the absolute path of the directory to use 97 | * @param file the path of the document file to use 98 | * @param specification the Swagger specification object 99 | */ 100 | export const writeSwaggerFile = (directory: string, file: string, specification: OpenAPIV2.Document): Promise => fse.writeFile(swaggerFile(directory, file), yaml.safeDump(specification, { noRefs: true })) 101 | 102 | /** 103 | * Writes the Postman file 104 | * @param directory the absolute path of the directory to use 105 | * @param file the path of the document file to use 106 | * @param object the Postman Collections object 107 | */ 108 | export const writePostmanFile = (directory: string, file: string, object: object): Promise => fse.writeFile(postmanFile(directory, file), JSON.stringify(object, null, 2)) 109 | 110 | /** 111 | * Writes the Terraform file 112 | * @param directory the absolute path of the directory to use 113 | * @param file the path of the document file to use 114 | * @param string the Terraform string 115 | */ 116 | export const writeTerraformFile = (directory: string, file: string, string: string): Promise => fse.writeFile(terraformFile(directory, file), string) 117 | 118 | /** 119 | * Reads the Swagger with mocks file 120 | * @param directory the absolute path of the directory to use 121 | * @param file the path of the document file to use 122 | */ 123 | export const readSwaggerWithMocksFile = async (directory: string, file: string): Promise => yaml.safeLoad((await fse.readFile(swaggerWithMocksFile(directory, file))).toString()) 124 | 125 | /** 126 | * Writes the Swagger with mocks file 127 | * @param directory the absolute path of the directory to use 128 | * @param file the path of the document file to use 129 | * @param specification the Swagger specification object 130 | */ 131 | export const writeSwaggerWithMocksFile = (directory: string, file: string, specification: OpenAPIV2.Document): Promise => fse.writeFile(swaggerWithMocksFile(directory, file), yaml.safeDump(specification, { noRefs: true})) 132 | 133 | /** 134 | * Reads the deployment tracker file 135 | * @param directory the absolute path of the directory to use 136 | * @param file the path of the document file to use 137 | */ 138 | export const readDeployFile = async (directory: string, file: string): Promise => yaml.safeLoad((await fse.readFile(deployFile(directory, file))).toString()) 139 | 140 | /** 141 | * Writes the deployment tracker file 142 | * @param directory the absolute path of the directory to use 143 | * @param file the path of the document file to use 144 | * @param deploy the deploy object 145 | */ 146 | export const writeDeployFile = (directory: string, file: string, deploy: Deploy): Promise => fse.writeFile(deployFile(directory, file), yaml.safeDump(deploy, { noRefs: true })) 147 | 148 | /** 149 | * Writes the `.gitignore` file 150 | * @param directory the absolute path of the directory to use 151 | * @param file the path of the document file to use 152 | */ 153 | export const writeGitignoreFile = async (directory: string, file: string): Promise => fse.writeFile(gitignoreFile(directory, file), '# Ignoring this directory (generated by resource-x)\n*\n# Except for the deploy.yaml\n!deploy.yaml\n') 154 | -------------------------------------------------------------------------------- /src/cli/generate.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import path from 'path' 4 | import fse from 'fs-extra' 5 | 6 | import { init } from './init' 7 | import { generate } from './generate' 8 | 9 | let scratchDirectory = path.join(__dirname, '../scratch-generate/') 10 | 11 | test.before(async t => { 12 | // create a temporary scratch directory for test files 13 | await fse.ensureDir(scratchDirectory) 14 | // initialize 15 | await init(scratchDirectory) 16 | }) 17 | 18 | test('can generate an API specification', async t => { 19 | await generate(scratchDirectory, 'sample.md') 20 | t.true(await fse.pathExists(path.join(scratchDirectory, '.rx/sample/', 'swagger.yaml'))) 21 | }) 22 | 23 | test.after(async t => { 24 | // delete the temporary scratch directory 25 | await fse.remove(scratchDirectory) 26 | }) 27 | -------------------------------------------------------------------------------- /src/cli/generate.ts: -------------------------------------------------------------------------------- 1 | 2 | import { basename } from 'path' 3 | import { ensureDir } from 'fs-extra' 4 | import inquirer from 'inquirer' 5 | // @ts-ignore 6 | import postman from 'swagger2-to-postmanv2' 7 | 8 | import { log } from './log' 9 | import * as files from './files' 10 | import * as rx from '../index' 11 | 12 | /** 13 | * Generates files in the specified directory from the specified document file. 14 | * Currently, it generates: 15 | * - a Swagger API specification file 16 | * - a Postman Collection file 17 | * - a Terraform file 18 | * @param directory the absolute path of the directory to use 19 | * @param file the path of the document file to use 20 | */ 21 | export const generate = async (directory: string, file: string): Promise => { 22 | // Check if the corresponding swagger file for the provided document file exists 23 | await proceed(await files.exists(files.swaggerFile(directory, file)), basename(files.swaggerFile(directory, file))) 24 | // Read the document 25 | let document = await files.readDocumentFile(file) 26 | // Generate the API specification from the document 27 | let tokens = await rx.tokens(document) 28 | let specification = await rx.specification(await rx.schemas(tokens), await rx.title(tokens)) 29 | // Ensure the output .rx// directory is created 30 | await ensureDir(files.rxSubdirectory(directory, file)) 31 | // Write a .gitignore to ensure generated files are not committed 32 | await files.writeGitignoreFile(directory, file) 33 | // Write the API specification object to the file-specific directory 34 | await files.writeSwaggerFile(directory, file, specification) 35 | log('success', `Generated the ${basename(files.swaggerFile(directory, file))} file successfully.`) 36 | log('info', `path: ${files.swaggerFile(directory, file)}`) 37 | // Check if the corresponding postman file for the provided document file exists 38 | await proceed(await files.exists(files.postmanFile(directory, file)), basename(files.postmanFile(directory, file))) 39 | // Write the Postman collection object to the file-specific directory 40 | const collection = await new Promise((resolve, reject) => postman.convert( 41 | { type: 'json', data: specification }, 42 | {}, 43 | (error: Error, data: { result: boolean, reason?: string, output?: Array<{ type: 'collection', data: object }> }) => { 44 | if (error) reject(error) 45 | else if (!data.result || !data.output || data.output.length !== 1 || !data.output[0].data) reject(new Error(data.reason || 'Failed to generate the postman collection')) 46 | else resolve(data.output[0].data) 47 | })) 48 | await files.writePostmanFile(directory, file, collection) 49 | log('success', `Generated the ${basename(files.postmanFile(directory, file))} file successfully.`) 50 | log('info', `path: ${files.postmanFile(directory, file)}`) 51 | // Check if the corresponding terraform file for the provided document file exists 52 | await proceed(await files.exists(files.terraformFile(directory, file)), basename(files.terraformFile(directory, file))) 53 | let terraform = await rx.terraform(specification) 54 | // Write the Terraform string to the file-specific directory 55 | await files.writeTerraformFile(directory, file, terraform) 56 | log('success', `Generated the ${basename(files.terraformFile(directory, file))} file successfully.`) 57 | log('info', `path: ${files.terraformFile(directory, file)}`) 58 | } 59 | 60 | const proceed = async (exists: boolean, name: string): Promise => { 61 | if (exists) { 62 | log('message', `The ${name} file already exists. The generate command will overwrite this file.`) 63 | let { proceed } = await inquirer.prompt<{ proceed: boolean }>([ 64 | { 65 | name: 'proceed', 66 | message: 'Are you sure you would like to continue?', 67 | type: 'confirm', 68 | default: false 69 | } 70 | ]) 71 | if (!proceed) { 72 | log('message', 'No changes made.') 73 | throw new Error('Confirmation failed.') 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/cli/init.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import path from 'path' 4 | import fse from 'fs-extra' 5 | 6 | import { init } from './init' 7 | 8 | let scratchDirectory = path.join(__dirname, '../scratch-init/') 9 | 10 | test.before(async t => { 11 | // create a temporary scratch directory for test files 12 | await fse.ensureDir(scratchDirectory) 13 | }) 14 | 15 | test('can initialize a new project', async t => { 16 | await init(scratchDirectory) 17 | t.true(await fse.pathExists(path.join(scratchDirectory, 'sample.md'))) 18 | }) 19 | 20 | test.after(async t => { 21 | // delete the temporary scratch directory 22 | await fse.remove(scratchDirectory) 23 | }) 24 | -------------------------------------------------------------------------------- /src/cli/init.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fse from 'fs-extra' 3 | 4 | import { log } from './log' 5 | 6 | /** 7 | * Initializes a new sample project in the specified directory. 8 | * @param directory the absolute path of the directory to use 9 | */ 10 | export const init = async (directory: string): Promise => { 11 | // Get the sample document (this is read from the assets directory in the final bundle) 12 | let document = await fse.readFile(path.join(__dirname, '../../sample.md')) 13 | // Write the document to the specified directory 14 | await fse.writeFile(path.join(directory, 'sample.md'), document) 15 | log('success', 'Created the ./sample.md file successfully.') 16 | } 17 | -------------------------------------------------------------------------------- /src/cli/log.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | import chalk from 'chalk' 3 | 4 | const debugCli = debug('rx:cli') 5 | debugCli.log = console.info.bind(console) 6 | 7 | /** 8 | * Logs to the console with colorized output 9 | * @param message the data to log 10 | */ 11 | export const log = (type: 'info' | 'message' | 'success' | 'error', message: any) => { 12 | if (type === 'message') console.log(chalk.yellow(message)) 13 | else if (type === 'success') console.log(chalk.green(message)) 14 | else if (type === 'error') console.log(chalk.red(message)) 15 | else debugCli(chalk.italic.yellow(message)) 16 | } 17 | -------------------------------------------------------------------------------- /src/cli/undeploy.ts: -------------------------------------------------------------------------------- 1 | import { remove } from 'fs-extra' 2 | 3 | import { log } from './log' 4 | import * as files from './files' 5 | import * as rx from '../index' 6 | 7 | /** 8 | * Undeploys (removes) the API from AWS API Gateway 9 | * @param directory the absolute path of the directory to use 10 | * @param file the path of the document file to use 11 | */ 12 | export const undeploy = async (directory: string, file: string): Promise => { 13 | // Ensure the corresponding deploy.json file for the provided document file exists 14 | let exists = await files.exists(files.deployFile(directory, file)) 15 | if (!exists) { 16 | log('error', 'No deployment found. The deploy.json file does not exist.') 17 | return 18 | } 19 | // Read the deploy.json 20 | let deploy = await files.readDeployFile(directory, file) 21 | // Undeploy 22 | log('message', `Removing deployment ${deploy.id} ...`) 23 | await rx.undeploy(deploy.id) 24 | // Remove the deploy.json file for this document 25 | await remove(files.deployFile(directory, file)) 26 | log('success', 'Undeployed successfully.') 27 | } 28 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import path from 'path' 4 | import fse from 'fs-extra' 5 | 6 | import { 7 | tokens, 8 | schemas, 9 | title, 10 | specification, 11 | mocks 12 | } from './index' 13 | 14 | let document: string 15 | 16 | test.before(async t => { 17 | let file = path.join(__dirname, '../sample.md') 18 | document = (await fse.readFile(file)).toString() 19 | }) 20 | 21 | test('can extract tokens from the Markdown document', async t => { 22 | let actual = await tokens(document) 23 | t.true(actual.length > 0) 24 | }) 25 | 26 | test('can find Markdown code blocks and create a combined schemas string', async t => { 27 | let actual = await schemas(await tokens(document)) 28 | t.true(actual.length > 0) 29 | }) 30 | 31 | test('can create a Swagger API specification with CRUD operations for each schema', async t => { 32 | let tokenss = await tokens(document) 33 | let actual = await specification(await schemas(tokenss), await title(tokenss)) 34 | t.true(Object.keys(actual.paths).length > 0) 35 | }) 36 | 37 | test('can add the AWS API Gateway mock integrations to the Swagger API specification', async t => { 38 | let tokenss = await tokens(document) 39 | let actual = await mocks(await specification(await schemas(tokenss), await title(tokenss))) 40 | t.truthy(actual.paths[Object.keys(actual.paths)[0]].post['x-amazon-apigateway-integration']) 41 | t.is(actual.paths[Object.keys(actual.paths)[0]].post['x-amazon-apigateway-integration'].type, 'mock') 42 | }) 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import marked from 'marked' 2 | import yaml from 'js-yaml' 3 | import pluralize from 'pluralize' 4 | import swagger from 'swagger-parser' 5 | import { OpenAPIV2 } from 'openapi-types' 6 | import { cloneDeep } from 'lodash' 7 | import camelCase from 'camelcase' 8 | import jsf from 'json-schema-faker' 9 | import AWS from 'aws-sdk' 10 | import proxy from 'proxy-agent' 11 | 12 | /** 13 | * Uses the marked lexer to extract tokens from the Markdown document 14 | * @param document the Markdown document, as a string 15 | */ 16 | export const tokens = async (document: string): Promise => { 17 | try { 18 | return marked.lexer(document) 19 | } catch (error) { 20 | throw new Error(`Error while parsing the Markdown document: ${error.message}`) 21 | } 22 | } 23 | 24 | /** 25 | * Finds Markdown code blocks in the token list, and combines them into a YAML string containing schemas 26 | * @param tokens the Markdown tokens 27 | */ 28 | export const schemas = async (tokens: marked.TokensList): Promise => { 29 | try { 30 | return tokens.reduce((combined, token) => { 31 | if (token.type === 'code' && token.text.length > 0) { 32 | if (token.lang === 'json') { 33 | return combined += `${yaml.safeDump(JSON.parse(token.text), { noRefs: true })}\n` 34 | } 35 | if (token.lang === 'yaml') { 36 | return combined += `${token.text}\n` 37 | } 38 | } 39 | return combined 40 | }, '') 41 | } catch (error) { 42 | throw new Error(`Error while extracting schemas from the Markdown document: ${error.message}`) 43 | } 44 | } 45 | 46 | /** 47 | * Finds the first top-level (h1) Markdown heading in the token list, and normalizes it. 48 | * Normalization turns the title into all lowercase with dashes instead of spaces. 49 | * @param tokens the Markdown tokens 50 | */ 51 | export const title = async (tokens: marked.TokensList): Promise => { 52 | try { 53 | let heading = tokens.find(token => token.type === 'heading' && token.depth == 1) as marked.Tokens.Heading 54 | if (!heading || !heading.text || heading.text.length == 0) 55 | throw new Error('No heading found in the document. Specify a heading like "# Some heading"') 56 | return heading.text.toLowerCase().split(' ').join('-') 57 | } catch (error) { 58 | throw new Error(`Error while getting title from the Markdown document: ${error.message}`) 59 | } 60 | } 61 | 62 | /** 63 | * Creates a Swagger API specification with CRUD operations for each schema 64 | * @param schemas the combined schemas string 65 | */ 66 | export const specification = async (schemas: string, title: string): Promise => { 67 | try { 68 | // build a swagger definition from schemas 69 | let specification: OpenAPIV2.Document = { 70 | swagger: '2.0', 71 | info: { 72 | title, 73 | version: '1.0.0' 74 | }, 75 | consumes: [ 'application/json' ], 76 | produces: [ 'application/json' ], 77 | tags: [], 78 | paths: {}, 79 | definitions: yaml.safeLoad(schemas) 80 | } 81 | // validate schemas 82 | await swagger.validate(cloneDeep(specification)) 83 | // build all default routes for all resources 84 | for (let key of Object.keys(specification.definitions)) { 85 | let collection = pluralize(key) 86 | specification.tags.push({ name: collection }) 87 | specification.paths[`/${collection}`] = { 88 | get: { 89 | operationId: camelCase([ 'get', collection ]), 90 | tags: [ collection ], 91 | responses: { 92 | '200': { 93 | description: '200 OK', 94 | schema: { 95 | type: 'array', 96 | items: { 97 | $ref: `#/definitions/${key}` 98 | } 99 | } 100 | } 101 | } 102 | }, 103 | post: { 104 | operationId: camelCase([ 'post', collection ]), 105 | tags: [ collection ], 106 | parameters: [ 107 | { 108 | name: key, 109 | in: 'body', 110 | required: true, 111 | schema: { 112 | $ref: `#/definitions/${key}` 113 | } 114 | } 115 | ], 116 | responses: { 117 | '201': { 118 | description: '201 Created', 119 | schema: { 120 | $ref: `#/definitions/${key}` 121 | } 122 | } 123 | } 124 | }, 125 | options: { 126 | operationId: camelCase([ 'options', collection ]), 127 | tags: [ 'cors' ], 128 | responses: { 129 | '200': { 130 | description: '200 OK', 131 | headers: { 132 | 'Access-Control-Allow-Headers': { 133 | type: 'string' 134 | }, 135 | 'Access-Control-Allow-Methods': { 136 | type: 'string' 137 | }, 138 | 'Access-Control-Allow-Origin': { 139 | type: 'string' 140 | } 141 | } 142 | } 143 | } 144 | } 145 | } 146 | specification.paths[`/${collection}/{${key}Id}`] = { 147 | get: { 148 | operationId: camelCase([ 'get', key ]), 149 | tags: [ collection ], 150 | parameters: [ 151 | { 152 | name: `${key}Id`, 153 | in: 'path', 154 | required: true, 155 | type: 'integer', 156 | format: 'int64' 157 | } 158 | ], 159 | responses: { 160 | '200': { 161 | description: '200 OK', 162 | schema: { 163 | $ref: `#/definitions/${key}` 164 | } 165 | } 166 | } 167 | }, 168 | put: { 169 | operationId: camelCase([ 'put', key ]), 170 | tags: [ collection ], 171 | parameters: [ 172 | { 173 | name: `${key}Id`, 174 | in: 'path', 175 | required: true, 176 | type: 'integer', 177 | format: 'int64' 178 | }, 179 | { 180 | name: key, 181 | in: 'body', 182 | required: true, 183 | schema: { 184 | $ref: `#/definitions/${key}` 185 | } 186 | } 187 | ], 188 | responses: { 189 | '204': { 190 | description: '204 No Content' 191 | } 192 | } 193 | }, 194 | delete: { 195 | operationId: camelCase([ 'delete', key ]), 196 | tags: [ collection ], 197 | parameters: [ 198 | { 199 | name: `${key}Id`, 200 | in: 'path', 201 | required: true, 202 | type: 'integer', 203 | format: 'int64' 204 | } 205 | ], 206 | responses: { 207 | '204': { 208 | description: '204 No Content' 209 | } 210 | } 211 | }, 212 | options: { 213 | operationId: camelCase([ 'options', key ]), 214 | tags: [ 'cors' ], 215 | parameters: [ 216 | { 217 | name: `${key}Id`, 218 | in: 'path', 219 | required: true, 220 | type: 'integer', 221 | format: 'int64' 222 | } 223 | ], 224 | responses: { 225 | '200': { 226 | description: '200 OK', 227 | headers: { 228 | 'Access-Control-Allow-Headers': { 229 | type: 'string' 230 | }, 231 | 'Access-Control-Allow-Methods': { 232 | type: 'string' 233 | }, 234 | 'Access-Control-Allow-Origin': { 235 | type: 'string' 236 | } 237 | } 238 | } 239 | } 240 | } 241 | } 242 | } 243 | // validate swagger definition against the official swagger schema and spec 244 | await swagger.validate(cloneDeep(specification)) 245 | // bundle and use internal $refs 246 | specification = await swagger.bundle(specification) as OpenAPIV2.Document 247 | return specification 248 | } catch (error) { 249 | throw new Error(`Error while generating the swagger specification from the document: ${error.message}`) 250 | } 251 | } 252 | 253 | /** 254 | * Adds the AWS API Gateway request validation and mock integrations to the Swagger API specification 255 | * @param specification the Swagger API specification object 256 | */ 257 | export const mocks = async (specification: OpenAPIV2.Document): Promise => { 258 | try { 259 | // validate and dereference the specification 260 | specification = await swagger.validate(specification) as OpenAPIV2.Document 261 | specification['x-amazon-apigateway-request-validators'] = { 262 | validateBodyAndParameters: { 263 | validateRequestBody: true, 264 | validateRequestParameters: true 265 | } 266 | } 267 | specification['x-amazon-apigateway-request-validator'] = 'validateBodyAndParameters' 268 | for (let pathKey of Object.keys(specification.paths)) { 269 | for (let operationKey of Object.keys(specification.paths[pathKey])) { 270 | let responses = specification.paths[pathKey][operationKey].responses 271 | let status = Object.keys(responses)[0] 272 | let schema = responses[status].schema 273 | let mockData: any 274 | if (schema) mockData = await jsf.resolve(schema) 275 | specification.paths[pathKey][operationKey]['x-amazon-apigateway-integration'] = { 276 | type: 'mock', 277 | requestTemplates: { 278 | 'application/json': '{\"statusCode\": 200}' 279 | }, 280 | responses: { 281 | default: (operationKey === 'options') ? { 282 | statusCode: '200', 283 | responseParameters: { 284 | 'method.response.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", 285 | 'method.response.header.Access-Control-Allow-Methods': "'*'", 286 | 'method.response.header.Access-Control-Allow-Origin': "'*'" 287 | }, 288 | responseTemplates: { 289 | 'application/json': '{}' 290 | } 291 | } : (mockData) ? { 292 | statusCode: `${status}`, 293 | responseTemplates: { 294 | 'application/json': JSON.stringify(mockData, null, 2) 295 | } 296 | } : { 297 | statusCode: `${status}` 298 | } 299 | }, 300 | passthroughBehavior: 'when_no_match' 301 | } 302 | } 303 | } 304 | specification = await swagger.validate(specification) as OpenAPIV2.Document 305 | return specification 306 | } catch (error) { 307 | throw new Error(`Error while generating the mock integrations for the swagger specification: ${error.message}`) 308 | } 309 | } 310 | 311 | /** 312 | * Generates a Terraform string based on the provided Swagger API specification 313 | * @param specification the Swagger API specification object 314 | */ 315 | export const terraform = async (spec: OpenAPIV2.Document & { [key: string]: any }): Promise => { 316 | try { 317 | // validate and dereference the specification, and generate mock integrations 318 | const specification = await mocks(cloneDeep(spec)) 319 | // initialize the terraform string 320 | let terraformString = '' 321 | terraformString += `variable "title" {` + '\n' 322 | terraformString += ' type = string' + '\n' 323 | terraformString += ` description = "The title of the API"` + '\n' 324 | terraformString += ` default = "${specification.info.title}"` + '\n' 325 | terraformString += '}' + '\n' + '\n' 326 | terraformString += `variable "version" {` + '\n' 327 | terraformString += ' type = string' + '\n' 328 | terraformString += ` description = "The version of the API"` + '\n' 329 | terraformString += ` default = "${specification.info.version}"` + '\n' 330 | terraformString += '}' + '\n' + '\n' 331 | // add terraform interpolation support to specification fields 332 | specification.info.title = '${var.title}' 333 | specification.info.version = '${var.version}' 334 | // generate terraform variables for providing AWS API Gateway integrations for each operation 335 | for (let pathKey of Object.keys(specification.paths)) { 336 | for (let operationKey of Object.keys(specification.paths[pathKey])) { 337 | const operationId = specification.paths[pathKey][operationKey].operationId 338 | const integration = specification.paths[pathKey][operationKey]['x-amazon-apigateway-integration'] 339 | const description = `Provide the AWS API Gateway integration configuration for the ${operationId} operation` 340 | terraformString += `variable "${operationId}" {` + '\n' 341 | terraformString += ' type = string' + '\n' 342 | terraformString += ` description = "${description}"` + '\n' 343 | terraformString += ' default = < => { 376 | try { 377 | let gateway = await createAwsApiGatewayClient() 378 | let errorMessage = '' 379 | if (!gateway.config.region) errorMessage += 'Please specify an AWS_REGION as an environment variable or in the AWS config file.\n' 380 | if (!gateway.config.credentials) errorMessage += 'Please specify an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (or AWS_PROFILE) as environment variables.\n' 381 | if (!gateway.config.region || !gateway.config.credentials) throw new Error(`Missing AWS configuration.\n${errorMessage}`) 382 | if (id && id.length > 0) { 383 | await gateway.putRestApi({ 384 | restApiId: id, 385 | failOnWarnings: true, 386 | mode: 'overwrite', 387 | body: JSON.stringify(specification, null, 2) 388 | }).promise() 389 | } else { 390 | let importResponse = await gateway.importRestApi({ 391 | body: JSON.stringify(specification, null, 2), 392 | failOnWarnings: true 393 | }).promise() 394 | id = importResponse.id 395 | } 396 | let deploymentResponse = await gateway.createDeployment({ 397 | restApiId: id, 398 | stageName: 'dev' 399 | }).promise() 400 | return { 401 | id, 402 | url: `https://${id}.execute-api.${gateway.config.region}.amazonaws.com/dev` 403 | } 404 | } catch (error) { 405 | throw new Error(`Error while deploying the swagger specification to the AWS API Gateway: ${error.message}`) 406 | } 407 | } 408 | 409 | /** 410 | * Undeploys the API from the AWS API Gateway 411 | * @param id the id of the existing API to remove 412 | */ 413 | export const undeploy = async (id: string): Promise => { 414 | try { 415 | let gateway = await createAwsApiGatewayClient() 416 | let errorMessage = '' 417 | if (!gateway.config.region) errorMessage += 'Please specify an AWS_REGION as an environment variable or in the AWS config file.\n' 418 | if (!gateway.config.credentials) errorMessage += 'Please specify an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (or AWS_PROFILE) as environment variables.\n' 419 | if (!gateway.config.region || !gateway.config.credentials) throw new Error(`Missing AWS configuration.\n${errorMessage}`) 420 | await gateway.deleteRestApi({ restApiId: id }).promise() 421 | } catch (error) { 422 | throw new Error(`Error while undeploying the API from AWS API Gateway: ${error.message}`) 423 | } 424 | } 425 | 426 | /** 427 | * Creates an AWS API Gateway client, used when deploying/undeploying the API 428 | */ 429 | export const createAwsApiGatewayClient = async (): Promise => { 430 | const options: AWS.APIGateway.ClientConfiguration = { 431 | apiVersion: '2015-07-09', 432 | credentialProvider: new AWS.CredentialProviderChain([ 433 | () => new AWS.EnvironmentCredentials('AWS'), 434 | () => new AWS.SharedIniFileCredentials() 435 | ]) 436 | } 437 | const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY 438 | if (proxyUrl) { 439 | options.httpOptions = { 440 | // @ts-ignore 441 | agent: proxy(proxyUrl) 442 | } 443 | } 444 | return new AWS.APIGateway(options) 445 | } 446 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "module": "commonjs", 6 | "target": "es6", 7 | "esModuleInterop": true 8 | } 9 | } --------------------------------------------------------------------------------