├── .eslintignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ ├── build-and-test │ │ └── action.yml │ └── prepare-repository │ │ └── action.yml ├── dependabot.yml ├── issue_template.md ├── pull_request_template.md └── workflows │ └── build-and-test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── bin ├── run └── run.cmd ├── cli └── src │ ├── commands │ ├── checksum.ts │ ├── docs.ts │ ├── generate.ts │ ├── init.ts │ ├── lint.ts │ ├── mock.ts │ ├── validate.ts │ └── validation-server.ts │ ├── common │ ├── infer-proxy-config.spec.ts │ └── infer-proxy-config.ts │ └── index.ts ├── docs ├── App.tsx ├── index.css ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── index.ts ├── jest.ci.config.js ├── jest.config.js ├── lib └── src │ ├── checksum │ ├── __spec-examples__ │ │ └── contract.ts │ ├── hash.spec.ts │ └── hash.ts │ ├── core.ts │ ├── definitions.ts │ ├── errors.ts │ ├── generators │ ├── json-schema │ │ ├── __snapshots__ │ │ │ └── json-schema.spec.ts.snap │ │ ├── __spec-examples__ │ │ │ ├── contract-with-intersection-types.ts │ │ │ ├── contract-with-reference-types.ts │ │ │ └── minimal-contract.ts │ │ ├── index.ts │ │ ├── json-schema-specification.ts │ │ ├── json-schema-type-util.spec.ts │ │ ├── json-schema-type-util.ts │ │ ├── json-schema.spec.ts │ │ └── json-schema.ts │ ├── openapi2 │ │ ├── __snapshots__ │ │ │ └── openapi2.spec.ts.snap │ │ ├── __spec-examples__ │ │ │ ├── contract-with-array-query-param-and-comma-serialization-strategy.ts │ │ │ ├── contract-with-array-query-param.ts │ │ │ ├── contract-with-delete-endpoint.ts │ │ │ ├── contract-with-endpoint-metadata.ts │ │ │ ├── contract-with-get-endpoint.ts │ │ │ ├── contract-with-intersection-types.ts │ │ │ ├── contract-with-object-query-param.ts │ │ │ ├── contract-with-patch-endpoint.ts │ │ │ ├── contract-with-path-params.ts │ │ │ ├── contract-with-post-endpoint.ts │ │ │ ├── contract-with-put-endpoint.ts │ │ │ ├── contract-with-query-params.ts │ │ │ ├── contract-with-request-headers.ts │ │ │ ├── contract-with-response-headers.ts │ │ │ ├── contract-with-schemaprops.ts │ │ │ ├── contract-with-security-header.ts │ │ │ ├── contract-with-specific-and-default-responses.ts │ │ │ ├── minimal-contract.ts │ │ │ └── versioned-contract.ts │ │ ├── index.ts │ │ ├── openapi2-parameter-util-pathparam.spec.ts │ │ ├── openapi2-parameter-util-queryparam.spec.ts │ │ ├── openapi2-parameter-util-requestheader.spec.ts │ │ ├── openapi2-parameter-util-responseheader.spec.ts │ │ ├── openapi2-parameter-util.ts │ │ ├── openapi2-specification.ts │ │ ├── openapi2-type-util.spec.ts │ │ ├── openapi2-type-util.ts │ │ ├── openapi2.spec.ts │ │ ├── openapi2.ts │ │ └── spectral.ruleset.yml │ └── openapi3 │ │ ├── __snapshots__ │ │ └── openapi3.spec.ts.snap │ │ ├── __spec-examples__ │ │ ├── contract-with-array-query-param-and-comma-serialization-strategy.ts │ │ ├── contract-with-array-query-param.ts │ │ ├── contract-with-delete-endpoint.ts │ │ ├── contract-with-endpoint-metadata.ts │ │ ├── contract-with-examples.ts │ │ ├── contract-with-get-endpoint.ts │ │ ├── contract-with-head-endpoint.ts │ │ ├── contract-with-intersection-types.ts │ │ ├── contract-with-multiple-servers.ts │ │ ├── contract-with-object-query-param.ts │ │ ├── contract-with-one-server.ts │ │ ├── contract-with-patch-endpoint.ts │ │ ├── contract-with-path-params.ts │ │ ├── contract-with-post-endpoint.ts │ │ ├── contract-with-put-endpoint.ts │ │ ├── contract-with-query-params.ts │ │ ├── contract-with-request-headers.ts │ │ ├── contract-with-response-headers.ts │ │ ├── contract-with-schemaprops.ts │ │ ├── contract-with-security-header.ts │ │ ├── contract-with-specific-and-default-responses.ts │ │ ├── minimal-contract.ts │ │ └── versioned-contract.ts │ │ ├── index.ts │ │ ├── openapi3-specification.ts │ │ ├── openapi3-type-util.spec.ts │ │ ├── openapi3-type-util.ts │ │ ├── openapi3.spec.ts │ │ ├── openapi3.ts │ │ └── spectral.ruleset.yml │ ├── http.ts │ ├── io │ └── output.ts │ ├── lib.ts │ ├── linting │ ├── README.MD │ ├── find-lint-violations.spec.ts │ ├── find-lint-violations.ts │ ├── linter.ts │ ├── rule.ts │ ├── rules.ts │ └── rules │ │ ├── __spec-examples__ │ │ ├── has-discriminator │ │ │ ├── contract-with-union-violations-in-each-component.ts │ │ │ ├── multiple-homogeneous-primitive-type-or-null-union.ts │ │ │ ├── object-union-with-discriminator.ts │ │ │ ├── object-union-with-no-discriminator.ts │ │ │ └── single-type-or-null-union.ts │ │ ├── has-query-parameters │ │ │ ├── patch-endpoint-with-query-parameters.ts │ │ │ ├── patch-endpoint-without-query-parameters.ts │ │ │ ├── post-endpoint-with-query-parameters.ts │ │ │ ├── post-endpoint-without-query-parameters.ts │ │ │ ├── put-endpoint-with-query-parameters.ts │ │ │ └── put-endpoint-without-query-parameters.ts │ │ ├── has-request-payload │ │ │ ├── delete-endpoint-with-request-body.ts │ │ │ ├── delete-endpoint-without-request-body.ts │ │ │ ├── get-endpoint-with-request-body.ts │ │ │ ├── get-endpoint-without-request-body.ts │ │ │ ├── head-endpoint-with-request-body.ts │ │ │ ├── head-endpoint-without-request-body.ts │ │ │ ├── patch-endpoint-with-request-body.ts │ │ │ ├── patch-endpoint-without-request-body.ts │ │ │ ├── post-endpoint-with-request-body.ts │ │ │ ├── post-endpoint-without-request-body.ts │ │ │ ├── put-endpoint-with-request-body.ts │ │ │ └── put-endpoint-without-request-body.ts │ │ ├── has-response-payload │ │ │ ├── endpoint-default-response-without-body.ts │ │ │ ├── endpoint-specific-and-default-responses-with-body.ts │ │ │ └── endpoint-specific-response-without-body.ts │ │ ├── has-response │ │ │ ├── endpoint-with-default-response.ts │ │ │ ├── endpoint-with-no-responses.ts │ │ │ └── endpoint-with-specific-response.ts │ │ ├── no-inline-objects-within-unions │ │ │ ├── contract-inline-object-union-violations-in-each-query-param-and-body-component.ts │ │ │ ├── inline-object-or-null-union.ts │ │ │ ├── inline-objects-in-union.ts │ │ │ └── referenced-objects-in-union.ts │ │ ├── no-nullable-arrays │ │ │ ├── contract-with-nullable-violations-in-each-nullable-component.ts │ │ │ ├── non-nullable-array.ts │ │ │ └── nullable-array.ts │ │ ├── no-nullable-fields-within-request-bodies │ │ │ ├── non-nullable-field-in-request-body.ts │ │ │ └── nullable-field-in-request-body.ts │ │ ├── no-omittable-fields-within-response-bodies │ │ │ ├── no-omittable-field-in-response-body.ts │ │ │ └── omittable-field-in-response-body.ts │ │ ├── no-primitives-in-request │ │ │ ├── request-as-array.ts │ │ │ ├── request-as-object.ts │ │ │ └── request-with-primitives.ts │ │ └── no-trailing-forward-slash │ │ │ ├── no-trailing-forward-slash.ts │ │ │ └── trailing-forward-slash.ts │ │ ├── has-discriminator.spec.ts │ │ ├── has-discriminator.ts │ │ ├── has-query-parameters.spec.ts │ │ ├── has-query-parameters.ts │ │ ├── has-request-payload.spec.ts │ │ ├── has-request-payload.ts │ │ ├── has-response-payload.spec.ts │ │ ├── has-response-payload.ts │ │ ├── has-response.spec.ts │ │ ├── has-response.ts │ │ ├── no-inline-objects-within-unions.spec.ts │ │ ├── no-inline-objects-within-unions.ts │ │ ├── no-nullable-arrays.spec.ts │ │ ├── no-nullable-arrays.ts │ │ ├── no-nullable-fields-within-request-bodies.spec.ts │ │ ├── no-nullable-fields-within-request-bodies.ts │ │ ├── no-omittable-fields-within-response-bodies.spec.ts │ │ ├── no-omittable-fields-within-response-bodies.ts │ │ ├── no-primitives-in-request.spec.ts │ │ ├── no-primitives-in-request.ts │ │ ├── no-trailing-forward-slash.spec.ts │ │ └── no-trailing-forward-slash.ts │ ├── locations.ts │ ├── mock-server │ ├── dummy.spec.ts │ ├── dummy.ts │ ├── matcher.spec.ts │ ├── matcher.ts │ ├── proxy.ts │ ├── server.spec.ts │ └── server.ts │ ├── parser.ts │ ├── parsers │ ├── __spec-examples__ │ │ ├── body.ts │ │ ├── config.ts │ │ ├── contracts │ │ │ ├── contract-dependency.ts │ │ │ ├── contract.ts │ │ │ ├── duplicate-endpoint-name-contract-dependency.ts │ │ │ ├── duplicate-endpoint-name-contract.ts │ │ │ ├── empty-api-name-contract.ts │ │ │ ├── illegal-api-name-contract.ts │ │ │ ├── minimal-contract.ts │ │ │ └── not-contract.ts │ │ ├── decorators.ts │ │ ├── default-response.ts │ │ ├── endpoint.ts │ │ ├── examples.ts │ │ ├── headers.ts │ │ ├── oa3server.ts │ │ ├── path-params.ts │ │ ├── query-params.ts │ │ ├── recursive-imports │ │ │ ├── import-1-1-1.ts │ │ │ ├── import-1-1.ts │ │ │ ├── import-1-2.ts │ │ │ ├── import-1.ts │ │ │ ├── import-2.ts │ │ │ └── source.ts │ │ ├── request.ts │ │ ├── response.ts │ │ ├── schemaprops.ts │ │ ├── security-header.ts │ │ └── types.ts │ ├── body-parser.spec.ts │ ├── body-parser.ts │ ├── config-parser.spec.ts │ ├── config-parser.ts │ ├── contract-parser.spec.ts │ ├── contract-parser.ts │ ├── default-response-parser.spec.ts │ ├── default-response-parser.ts │ ├── endpoint-parser.spec.ts │ ├── endpoint-parser.ts │ ├── example-parser.spec.ts │ ├── example-parser.ts │ ├── headers-parser.spec.ts │ ├── headers-parser.ts │ ├── oa3server-parser.spec.ts │ ├── oa3server-parser.ts │ ├── parser-helpers.spec.ts │ ├── parser-helpers.ts │ ├── path-params-parser.spec.ts │ ├── path-params-parser.ts │ ├── query-params-parser.spec.ts │ ├── query-params-parser.ts │ ├── request-parser.spec.ts │ ├── request-parser.ts │ ├── response-parser.spec.ts │ ├── response-parser.ts │ ├── schemaprop-parser.spec.ts │ ├── schemaprop-parser.ts │ ├── security-header-parser.spec.ts │ ├── security-header-parser.ts │ ├── type-parser.spec.ts │ └── type-parser.ts │ ├── spec-helpers │ └── helper.ts │ ├── syntax │ ├── api.ts │ ├── body.ts │ ├── config.ts │ ├── default-response.ts │ ├── draft.ts │ ├── endpoint.ts │ ├── headers.ts │ ├── index.ts │ ├── oa3server.ts │ ├── oa3serverVariables.ts │ ├── path-params.ts │ ├── query-params.ts │ ├── request.ts │ ├── response.ts │ ├── security-header.ts │ └── types.ts │ ├── types.ts │ ├── util.ts │ ├── utilities │ ├── expand-path-with-tilde.spec.ts │ ├── expand-path-with-tilde.ts │ └── logger.ts │ └── validation-server │ ├── __spec-examples__ │ └── contract │ │ ├── contract-endpoint.ts │ │ ├── contract.ts │ │ └── models.ts │ ├── server.spec.ts │ ├── server.ts │ ├── spots │ ├── api.ts │ ├── health.ts │ ├── utils.ts │ └── validate.ts │ └── verifications │ ├── __spec-examples__ │ └── contract │ │ ├── contract-endpoint.ts │ │ ├── contract.ts │ │ └── models.ts │ ├── contract-mismatcher.spec.ts │ ├── contract-mismatcher.ts │ ├── mismatches.ts │ ├── string-validator.spec.ts │ ├── string-validator.ts │ ├── user-input-models.ts │ └── violations.ts ├── package.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | jest.config.js 4 | jest.ci.config.js 5 | webpack.config.js 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | parser: "@typescript-eslint/parser", 7 | plugins: ["@typescript-eslint", "jest"], 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:jest/recommended" 12 | ], 13 | rules: { 14 | "@typescript-eslint/ban-types": "off", 15 | "@typescript-eslint/explicit-module-boundary-types": "off", 16 | "@typescript-eslint/no-inferrable-types": "off", 17 | "@typescript-eslint/no-use-before-define": "off" 18 | }, 19 | overrides: [ 20 | { 21 | files: [ 22 | "**/__spec-examples__/**/*.ts", 23 | "lib/src/validation-server/spots/**/*.ts" 24 | ], 25 | rules: { 26 | "@typescript-eslint/ban-types": "off", 27 | "@typescript-eslint/no-empty-function": "off", 28 | "@typescript-eslint/explicit-function-return-type": "off", 29 | "@typescript-eslint/no-unused-vars": "off", 30 | "@typescript-eslint/no-explicit-any": "off" 31 | } 32 | }, 33 | { 34 | files: ["lib/src/syntax/**/*.ts"], 35 | rules: { 36 | "@typescript-eslint/no-unused-vars": "off", 37 | "@typescript-eslint/no-explicit-any": "off", 38 | "@typescript-eslint/explicit-function-return-type": "off", 39 | "@typescript-eslint/no-empty-function": "off" 40 | } 41 | }, 42 | { 43 | files: ["*.tsx"], 44 | rules: { 45 | "@typescript-eslint/no-unused-vars": "off" 46 | } 47 | }, 48 | { 49 | files: ["*.spec.ts"], 50 | rules: { 51 | "@typescript-eslint/explicit-function-return-type": "off" 52 | } 53 | } 54 | ] 55 | }; 56 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @airtasker/platform-services-core 2 | 3 | # Explicitly no codeowners for dependencies files. 4 | package.json 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS with version] 28 | - Yarn version 29 | - Node version 30 | - Spot version 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/build-and-test/action.yml: -------------------------------------------------------------------------------- 1 | author: airtasker 2 | name: build-and-test 3 | description: Build and test. 4 | 5 | inputs: 6 | node_version: 7 | required: true 8 | description: The version of Node.js to use. 9 | shard: 10 | required: true 11 | description: Which shard we're on. 12 | 13 | runs: 14 | using: composite 15 | steps: 16 | - uses: ./.github/actions/prepare-repository 17 | 18 | - name: Compile the repository on this version of Node. 19 | run: yarn build 20 | shell: bash 21 | 22 | - name: Create test reports directory 23 | run: mkdir -p ./test-reports/jest 24 | shell: bash 25 | 26 | - name: Find and run test files 27 | run: | 28 | TESTFILES=$(find lib -name '*.spec.ts') 29 | for file in $TESTFILES; do 30 | filename=$(basename "$file" .spec.ts) 31 | JEST_JUNIT_OUTPUT_NAME="${{ github.sha }}_${{ github.run_id }}_node${{ inputs.node_version }}_${{ inputs.shard }}_${filename}_results.xml" \ 32 | yarn test $file --ci --reporters=default --reporters=jest-junit --shard=${{ inputs.shard }} 33 | done 34 | env: 35 | JEST_JUNIT_OUTPUT_DIR: ./test-reports/jest 36 | JEST_JUNIT_CLASSNAME: "{filepath}" 37 | JEST_JUNIT_UNIQUE_OUTPUT_NAME: "true" 38 | shell: bash 39 | 40 | - name: Build the documentation. 41 | run: |- 42 | set -uex 43 | if [ ${{ inputs.node_version }} -gt 16 ]; then 44 | # Without this, we get `Error: error:0308010C:digital envelope routines::unsupported` on Node >= 17. 45 | export NODE_OPTIONS="--openssl-legacy-provider" 46 | fi 47 | yarn build-docs 48 | shell: bash -------------------------------------------------------------------------------- /.github/actions/prepare-repository/action.yml: -------------------------------------------------------------------------------- 1 | author: airtasker 2 | name: prepare-repository 3 | description: Prepare the repository for building and testing. 4 | 5 | runs: 6 | using: composite 7 | 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: restore_cache 11 | uses: actions/cache@v4 12 | with: 13 | key: v3-dependencies-{{ checksum "yarn.lock" }} 14 | path: node_modules 15 | restore-keys: |- 16 | v3-dependencies-{{ checksum "yarn.lock" }} 17 | v3-dependencies 18 | 19 | - name: Install main dependencies 20 | run: yarn install --frozen-lockfile 21 | shell: bash 22 | 23 | - name: save_cache 24 | uses: actions/cache@v4 25 | with: 26 | path: node_modules 27 | key: v3-dependencies-{{ checksum "yarn.lock" }} 28 | 29 | - name: restore_cache 30 | uses: actions/cache@v4 31 | with: 32 | key: v3-docs-dependencies-{{ checksum "docs/yarn.lock" }} 33 | path: docs/node_modules 34 | restore-keys: |- 35 | v3-docs-dependencies-{{ checksum "docs/yarn.lock" }} 36 | v3-docs-dependencies 37 | 38 | - name: Install docs dependencies 39 | run: yarn install --frozen-lockfile 40 | working-directory: docs/ 41 | shell: bash 42 | 43 | - name: save_cache 44 | uses: actions/cache@v4 45 | with: 46 | path: docs/node_modules 47 | key: v3-docs-dependencies-{{ checksum "docs/yarn.lock" }} -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: npm 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | day: monday 9 | time: "05:00" 10 | timezone: Australia/Sydney 11 | open-pull-requests-limit: 99 12 | - package-ecosystem: npm 13 | directory: "/docs" 14 | schedule: 15 | interval: weekly 16 | day: monday 17 | time: "05:00" 18 | timezone: Australia/Sydney 19 | open-pull-requests-limit: 99 20 | - package-ecosystem: github-actions 21 | directory: "/" 22 | schedule: 23 | interval: daily 24 | time: "09:00" 25 | timezone: Australia/Sydney 26 | open-pull-requests-limit: 10 27 | - package-ecosystem: github-actions 28 | directory: "/.github/actions/build-and-test" 29 | schedule: 30 | interval: daily 31 | time: "09:00" 32 | timezone: Australia/Sydney 33 | open-pull-requests-limit: 10 34 | - package-ecosystem: github-actions 35 | directory: "/.github/actions/prepare-repository" 36 | schedule: 37 | interval: daily 38 | time: "09:00" 39 | timezone: Australia/Sydney 40 | open-pull-requests-limit: 10 41 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Current behavior: 2 | 3 | ### Desired behavior: 4 | 5 | ### Steps to reproduce: 6 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description, Motivation and Context 2 | 3 | - Why is this change required? 4 | - What does it do? 5 | 6 | ## Checklist: 7 | 8 | - [ ] I've added/updated tests to cover my changes 9 | - [ ] I've created an issue associated with this PR 10 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: airtasker/spot/build-and-test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | release: 11 | types: [published] 12 | 13 | env: 14 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 15 | 16 | jobs: 17 | test: 18 | name: test-node-${{ matrix.node-version }} 19 | runs-on: runs-on,runner=4cpu-linux-x64 20 | strategy: 21 | fail-fast: true # if one job fails, stop the rest 22 | matrix: 23 | node-version: [14, 16, 18, 20] 24 | shard: 25 | [ 26 | "1", 27 | "2", 28 | "3", 29 | "4", 30 | ] 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Setup NodeJS ${{ matrix.node-version }} 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | 40 | - name: Install dependencies 41 | run: yarn install --frozen-lockfile 42 | 43 | - name: Run tests 44 | uses: "./.github/actions/build-and-test" 45 | with: 46 | node_version: ${{ matrix.node-version }} 47 | shard: ${{ matrix.shard }} 48 | 49 | - name: Upload test results 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: test-results-node-${{ matrix.node-version }}-${{ matrix.shard }} 53 | path: ./test-reports 54 | 55 | test-summary-publish: 56 | runs-on: runs-on,runner=4cpu-linux-x64 57 | needs: [test] 58 | steps: 59 | - name: Test summary 60 | uses: test-summary/action@v2 61 | with: 62 | paths: ./test-reports/**/*.xml 63 | 64 | lint-check: 65 | runs-on: runs-on,runner=4cpu-linux-x64 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: ./.github/actions/prepare-repository 69 | 70 | - name: Run lint checker 71 | run: yarn lint:check 72 | 73 | publish: 74 | runs-on: runs-on,runner=4cpu-linux-x64 75 | if: github.event_name == 'release' 76 | needs: 77 | - test 78 | - lint-check 79 | steps: 80 | - uses: actions/checkout@v4 81 | - uses: ./.github/actions/prepare-repository 82 | 83 | - name: Authenticate with NPM registry 84 | run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc 85 | 86 | - name: Publish 87 | run: npm publish --access=public 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *-debug.log 3 | *-error.log 4 | /.nyc_output 5 | /.vscode 6 | /build 7 | /dist 8 | /package-lock.json 9 | /tmp 10 | node_modules 11 | oclif.manifest.json 12 | *.tgz 13 | clients/**/sdk 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | trailingComma: "none" 2 | arrowParens: "avoid" 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Original work Copyright (c) 2018 Zenc Labs Pty Ltd 4 | Modified work Copyright (c) 2019 Airtasker Pty Ltd 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('@oclif/command').run() 4 | .then(require('@oclif/command/flush')) 5 | .catch(require('@oclif/errors/handle')) 6 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /cli/src/commands/checksum.ts: -------------------------------------------------------------------------------- 1 | import { Command, flags } from "@oclif/command"; 2 | import { hashContract } from "../../../lib/src/checksum/hash"; 3 | import { parse } from "../../../lib/src/parser"; 4 | 5 | const ARG_API = "spot_contract"; 6 | 7 | /** 8 | * oclif command to generate a checksum for a Spot contract 9 | */ 10 | export default class Checksum extends Command { 11 | static description = "Generate a checksum for a Spot contract"; 12 | 13 | static examples = ["$ spot checksum api.ts"]; 14 | 15 | static args = [ 16 | { 17 | name: ARG_API, 18 | required: true, 19 | description: "path to Spot contract", 20 | hidden: false 21 | } 22 | ]; 23 | 24 | static flags = { 25 | help: flags.help({ char: "h" }) 26 | }; 27 | 28 | async run(): Promise { 29 | const { args } = this.parse(Checksum); 30 | try { 31 | const contract = parse(args[ARG_API]); 32 | const hash = hashContract(contract); 33 | this.log(hash); 34 | } catch (e) { 35 | this.error(e as Error, { exit: 1 }); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cli/src/commands/docs.ts: -------------------------------------------------------------------------------- 1 | import { Command, flags } from "@oclif/command"; 2 | import express from "express"; 3 | import path from "path"; 4 | import { generateOpenAPI3 } from "../../../lib/src/generators/openapi3/openapi3"; 5 | import { parse } from "../../../lib/src/parser"; 6 | 7 | const ARG_API = "spot_contract"; 8 | 9 | export default class Docs extends Command { 10 | static description = 11 | "Preview Spot contract as OpenAPI3 documentation. The documentation server will start on http://localhost:8080."; 12 | 13 | static examples = ["$ spot docs api.ts"]; 14 | 15 | static args = [ 16 | { 17 | name: ARG_API, 18 | required: true, 19 | description: "path to Spot contract", 20 | hidden: false 21 | } 22 | ]; 23 | 24 | static flags = { 25 | help: flags.help({ char: "h" }), 26 | port: flags.integer({ 27 | char: "p", 28 | description: "Documentation server port", 29 | default: 8080 30 | }) 31 | }; 32 | 33 | async run(): Promise { 34 | const { args, flags } = this.parse(Docs); 35 | const { port } = flags; 36 | 37 | const server = express(); 38 | const docsDir = path.join(__dirname, "docs", "public"); 39 | server.use(express.static(docsDir)); 40 | 41 | /** 42 | * This endpoint is used by the following React Component: 43 | * 44 | * The contract is regenerated on each invocation (browser refresh) 45 | */ 46 | server.get("/contract-openapi3", (req, res) => { 47 | const contract = parse(args[ARG_API]); 48 | const openApiObj = generateOpenAPI3(contract); 49 | res.send(openApiObj); 50 | }); 51 | 52 | const start = async (): Promise => { 53 | try { 54 | this.log(`Documentation server started on port ${port}`); 55 | this.log(`Open http://localhost:${port} to view documentation`); 56 | await server.listen(port); 57 | } catch (err) { 58 | this.error(err as Error, { exit: 1 }); 59 | } 60 | }; 61 | start(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cli/src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { Command, flags } from "@oclif/command"; 2 | import { execSync } from "child_process"; 3 | import fs from "fs-extra"; 4 | import { outputFile } from "../../../lib/src/io/output"; 5 | 6 | export default class Init extends Command { 7 | static description = "Generates the boilerplate for an API."; 8 | 9 | static examples = [ 10 | `$ spot init 11 | Generated the following files: 12 | - api.ts 13 | - tsconfig.json 14 | - package.json 15 | ` 16 | ]; 17 | 18 | static flags = { 19 | help: flags.help({ char: "h" }) 20 | }; 21 | 22 | async run(): Promise { 23 | if (fs.existsSync("api.ts")) { 24 | this.error(`There is already an API here!`); 25 | } 26 | outputFile( 27 | ".", 28 | "api.ts", 29 | `import { api, body, endpoint, request, response, String } from "@airtasker/spot"; 30 | 31 | @api({ name: "my-api" }) 32 | class Api {} 33 | 34 | @endpoint({ 35 | method: "POST", 36 | path: "/users" 37 | }) 38 | class CreateUser { 39 | @request 40 | request( 41 | @body body: CreateUserRequest 42 | ) {} 43 | 44 | @response({ status: 201 }) 45 | successfulResponse( 46 | @body body: CreateUserResponse 47 | ) {} 48 | } 49 | 50 | interface CreateUserRequest { 51 | firstName: String; 52 | lastName: String; 53 | } 54 | 55 | interface CreateUserResponse { 56 | firstName: String; 57 | lastName: String; 58 | role: String; 59 | } 60 | `, 61 | false 62 | ); 63 | outputFile( 64 | ".", 65 | "tsconfig.json", 66 | JSON.stringify( 67 | { 68 | compilerOptions: { 69 | target: "esnext", 70 | module: "esnext", 71 | moduleResolution: "node", 72 | experimentalDecorators: true 73 | } 74 | }, 75 | null, 76 | 2 77 | ), 78 | false 79 | ); 80 | outputFile(".", "package.json", JSON.stringify({}, null, 2), false); 81 | execSync(`yarn add @airtasker/spot`, { 82 | stdio: "inherit" 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /cli/src/commands/lint.ts: -------------------------------------------------------------------------------- 1 | import { Command, flags } from "@oclif/command"; 2 | import { lint } from "../../../lib/src/linting/linter"; 3 | import { parse } from "../../../lib/src/parser"; 4 | import { findLintViolations } from "../../../lib/src/linting/find-lint-violations"; 5 | import { availableRules } from "../../../lib/src/linting/rules"; 6 | 7 | const ARG_API = "spot_contract"; 8 | 9 | export interface LintConfig { 10 | rules: Record; 11 | } 12 | 13 | const lintConfig: LintConfig = { 14 | rules: { 15 | "no-omittable-fields-within-response-bodies": "warn", 16 | "no-trailing-forward-slash": "warn" 17 | } 18 | }; 19 | 20 | /** 21 | * oclif command to lint a spot contract 22 | */ 23 | export default class Lint extends Command { 24 | static description = "Lint a Spot contract"; 25 | 26 | static examples = [ 27 | "$ spot lint api.ts", 28 | "$ spot lint --has-descriminator=error", 29 | "$ spot lint --no-nullable-arrays=off" 30 | ]; 31 | 32 | static args = [ 33 | { 34 | name: ARG_API, 35 | required: true, 36 | description: "path to Spot contract", 37 | hidden: false 38 | } 39 | ]; 40 | 41 | static flags = this.buildFlags(); 42 | 43 | static buildFlags() { 44 | // Arguments depend on the list of available rules, it cannot be typed ahead of time. 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | const finalFlags: flags.Input = { 47 | help: flags.help({ char: "h" }) 48 | }; 49 | 50 | Object.keys(availableRules).forEach((rule: string) => { 51 | finalFlags[rule] = flags.enum({ 52 | description: `Setting for ${rule}`, 53 | options: ["error", "warn", "off"] 54 | }); 55 | }); 56 | 57 | return finalFlags; 58 | } 59 | 60 | async run(): Promise { 61 | const { args, flags } = this.parse(Lint); 62 | const contractPath = args[ARG_API]; 63 | const contract = parse(contractPath); 64 | const groupedLintErrors = lint(contract); 65 | 66 | Object.keys(availableRules).forEach((rule: string) => { 67 | if (flags[rule] !== undefined) { 68 | lintConfig.rules[rule] = flags[rule]; 69 | } 70 | }); 71 | 72 | const { errorCount, warningCount } = findLintViolations( 73 | groupedLintErrors, 74 | lintConfig, 75 | { 76 | error: (msg: string) => { 77 | this.error(msg, { exit: false }); 78 | }, 79 | warn: this.warn 80 | } 81 | ); 82 | 83 | this.log(`Found ${errorCount} errors and ${warningCount} warnings`); 84 | 85 | if (errorCount > 0) { 86 | process.exit(1); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /cli/src/commands/mock.ts: -------------------------------------------------------------------------------- 1 | import { Command, flags } from "@oclif/command"; 2 | import { runMockServer } from "../../../lib/src/mock-server/server"; 3 | import { parse } from "../../../lib/src/parser"; 4 | import inferProxyConfig from "../common/infer-proxy-config"; 5 | 6 | const ARG_API = "spot_contract"; 7 | 8 | /** 9 | * oclif command to run a mock server based on a Spot contract 10 | */ 11 | export default class Mock extends Command { 12 | static description = "Run a mock server based on a Spot contract"; 13 | 14 | static examples = ["$ spot mock api.ts"]; 15 | 16 | static args = [ 17 | { 18 | name: ARG_API, 19 | required: true, 20 | description: "path to Spot contract", 21 | hidden: false 22 | } 23 | ]; 24 | 25 | static flags = { 26 | help: flags.help({ char: "h" }), 27 | proxyBaseUrl: flags.string({ 28 | description: 29 | "If set, the server will act as a proxy and fetch data from the given remote server instead of mocking it" 30 | }), 31 | proxyFallbackBaseUrl: flags.string({ 32 | description: 33 | "Like proxyBaseUrl, except used when the requested API does not match defined SPOT contract. If unset, 404 will always be returned." 34 | }), 35 | proxyMockBaseUrl: flags.string({ 36 | description: 37 | "Like proxyBaseUrl, except used to proxy draft endpoints instead of returning mocked responses." 38 | }), 39 | port: flags.integer({ 40 | char: "p", 41 | description: "Port on which to run the mock server", 42 | default: 3010, 43 | required: true 44 | }), 45 | pathPrefix: flags.string({ 46 | description: "Prefix to prepend to each endpoint path" 47 | }) 48 | }; 49 | 50 | async run(): Promise { 51 | const { 52 | args, 53 | flags: { 54 | port, 55 | pathPrefix, 56 | proxyBaseUrl, 57 | proxyMockBaseUrl, 58 | proxyFallbackBaseUrl = "" 59 | } 60 | } = this.parse(Mock); 61 | try { 62 | const proxyConfig = inferProxyConfig(proxyBaseUrl || ""); 63 | const proxyMockConfig = inferProxyConfig(proxyMockBaseUrl || ""); 64 | const proxyFallbackConfig = inferProxyConfig(proxyFallbackBaseUrl || ""); 65 | const contract = parse(args[ARG_API]); 66 | await runMockServer(contract, { 67 | port, 68 | pathPrefix: pathPrefix ?? "", 69 | proxyConfig, 70 | proxyMockConfig, 71 | proxyFallbackConfig, 72 | logger: this 73 | }).defer(); 74 | this.log(`Mock server is running on port ${port}.`); 75 | } catch (e) { 76 | this.error(e as Error, { exit: 1 }); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cli/src/commands/validate.ts: -------------------------------------------------------------------------------- 1 | import { Command, flags } from "@oclif/command"; 2 | import { parse } from "../../../lib/src/parser"; 3 | 4 | const ARG_API = "spot_contract"; 5 | 6 | /** 7 | * oclif command to validate a spot contract 8 | */ 9 | export default class Validate extends Command { 10 | static description = "Validate a Spot contract"; 11 | 12 | static examples = ["$ spot validate api.ts"]; 13 | 14 | static args = [ 15 | { 16 | name: ARG_API, 17 | required: true, 18 | description: "path to Spot contract", 19 | hidden: false 20 | } 21 | ]; 22 | 23 | static flags = { 24 | help: flags.help({ char: "h" }) 25 | }; 26 | 27 | async run(): Promise { 28 | const { args } = this.parse(Validate); 29 | try { 30 | parse(args[ARG_API]); 31 | this.log("Contract is valid"); 32 | } catch (e) { 33 | this.error(e as Error); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cli/src/commands/validation-server.ts: -------------------------------------------------------------------------------- 1 | import { Command, flags } from "@oclif/command"; 2 | import { parse } from "../../../lib/src/parser"; 3 | import { runValidationServer } from "../../../lib/src/validation-server/server"; 4 | 5 | const ARG_API = "spot_contract"; 6 | 7 | /** 8 | * oclif command to start the spot contract validation server 9 | */ 10 | export default class ValidationServer extends Command { 11 | static description = "Start the spot contract validation server"; 12 | 13 | static examples = ["$ spot validation-server api.ts"]; 14 | 15 | static args = [ 16 | { 17 | name: ARG_API, 18 | required: true, 19 | description: "path to Spot contract", 20 | hidden: false 21 | } 22 | ]; 23 | 24 | static flags = { 25 | help: flags.help({ char: "h" }), 26 | port: flags.integer({ 27 | char: "p", 28 | default: 5907, 29 | description: "The port where application will be available" 30 | }) 31 | }; 32 | 33 | async run(): Promise { 34 | const { args, flags } = this.parse(ValidationServer); 35 | const contractPath = args[ARG_API]; 36 | const { port } = flags; 37 | 38 | try { 39 | this.log("Parsing contract..."); 40 | const contract = parse(contractPath); 41 | 42 | this.log("Starting validation server..."); 43 | await runValidationServer(port, contract).defer(); 44 | this.log(`Validation server running on port ${port}`); 45 | } catch (e) { 46 | this.error(e as Error, { exit: 1 }); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cli/src/common/infer-proxy-config.spec.ts: -------------------------------------------------------------------------------- 1 | import inferProxyConfig from "./infer-proxy-config"; 2 | 3 | describe("inferProxyConfig", () => { 4 | it("returns null when no inputs are provided", () => { 5 | expect(inferProxyConfig("")).toBe(null); 6 | }); 7 | 8 | it("throws an error when non-HTTP or HTTPS protocols are provided", () => { 9 | expect(() => { 10 | inferProxyConfig("chicken"); 11 | }).toThrow(/Invalid URL/); 12 | 13 | expect(() => { 14 | inferProxyConfig("ftp://127.0.0.1/foo/bar/baz"); 15 | }).toThrow(/Could not infer protocol/); 16 | }); 17 | 18 | it("returns the expected value for proxy servers on the default port", () => { 19 | expect(inferProxyConfig("http://example.com/foo")).toEqual({ 20 | isHttps: false, 21 | host: "example.com", 22 | port: null, 23 | path: "/foo" 24 | }); 25 | expect(inferProxyConfig("http://example.com")).toEqual({ 26 | isHttps: false, 27 | host: "example.com", 28 | port: null, 29 | path: "/" 30 | }); 31 | expect(inferProxyConfig("https://api.dev.mycompany.com/api/v1")).toEqual({ 32 | isHttps: true, 33 | host: "api.dev.mycompany.com", 34 | port: null, 35 | path: "/api/v1" 36 | }); 37 | }); 38 | 39 | it("returns the expected value for proxy servers on an explicit port", () => { 40 | expect(inferProxyConfig("http://example.com:80/foo")).toEqual({ 41 | isHttps: false, 42 | host: "example.com", 43 | port: null, 44 | path: "/foo" 45 | }); 46 | expect( 47 | inferProxyConfig("https://api.dev.mycompany.com:443/api/v1") 48 | ).toEqual({ 49 | isHttps: true, 50 | host: "api.dev.mycompany.com", 51 | port: null, 52 | path: "/api/v1" 53 | }); 54 | expect(inferProxyConfig("http://localhost:3000/api/v1")).toEqual({ 55 | isHttps: false, 56 | host: "localhost", 57 | port: 3000, 58 | path: "/api/v1" 59 | }); 60 | expect( 61 | inferProxyConfig("https://api.dev.mycompany.com:8443/api/v1") 62 | ).toEqual({ 63 | isHttps: true, 64 | host: "api.dev.mycompany.com", 65 | port: 8443, 66 | path: "/api/v1" 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /cli/src/common/infer-proxy-config.ts: -------------------------------------------------------------------------------- 1 | import { ProxyConfig } from "../../../lib/src/mock-server/server"; 2 | 3 | export default function inferProxyConfig( 4 | proxyBaseUrl: string 5 | ): ProxyConfig | null { 6 | if (!proxyBaseUrl) { 7 | return null; 8 | } 9 | 10 | const url = new URL(proxyBaseUrl); 11 | if (url.protocol !== "http:" && url.protocol !== "https:") { 12 | throw new Error( 13 | 'Could not infer protocol from proxy base url, should be either "http" or "https".' 14 | ); 15 | } 16 | 17 | return { 18 | isHttps: url.protocol === "https:", 19 | host: url.hostname, 20 | port: url.port ? parseInt(url.port, 10) : null, 21 | path: url.pathname 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /cli/src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from "@oclif/command"; 2 | -------------------------------------------------------------------------------- /docs/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RedocStandalone } from "redoc"; 3 | 4 | class App extends React.Component { 5 | render(): JSX.Element { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | } 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | Redoc 14 | 15 | 16 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.render(, document.getElementById("root")); 7 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@airtasker/spot-docs", 3 | "version": "0.0.1", 4 | "author": "Francois Wouts, Leslie Fung", 5 | "bugs": "https://github.com/airtasker/spot/issues", 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "@types/react": "^18.3.3", 9 | "@types/react-dom": "^18.2.18", 10 | "@types/react-is": "^18.3.0", 11 | "core-js": "^3.37.1", 12 | "css-loader": "^5.2.7", 13 | "html-webpack-plugin": "^5.6.0", 14 | "mini-css-extract-plugin": "^2.9.0", 15 | "mobx": "^6.12.4", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.2.0", 18 | "react-is": "^18.3.1", 19 | "redoc": "^2.1.3", 20 | "styled-components": "^5.3.1", 21 | "ts-loader": "^9.5.1", 22 | "typescript": "^5.4.5", 23 | "webpack": "^5.92.0", 24 | "webpack-cli": "^5.1.4" 25 | }, 26 | "engines": { 27 | "node": ">=12.0.0" 28 | }, 29 | "homepage": "https://github.com/airtasker/spot", 30 | "license": "MIT", 31 | "repository": "airtasker/spot", 32 | "scripts": { 33 | "build": "webpack --config webpack.config.js" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "commonjs", 6 | "target": "es5", 7 | "jsx": "react", 8 | "allowJs": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | 5 | module.exports = { 6 | mode: "production", 7 | context: path.resolve(__dirname), 8 | entry: "./index", 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | use: "ts-loader", 14 | exclude: /node_modules/ 15 | }, 16 | { 17 | test: /\.css$/i, 18 | use: [ 19 | { 20 | loader: MiniCssExtractPlugin.loader, 21 | options: { 22 | publicPath: "../" 23 | } 24 | }, 25 | "css-loader" 26 | ] 27 | } 28 | ] 29 | }, 30 | resolve: { 31 | extensions: [".tsx", ".ts", ".js"] 32 | }, 33 | output: { 34 | filename: "bundle.js", 35 | path: path.resolve( 36 | __dirname, 37 | "..", 38 | "build", 39 | "cli", 40 | "src", 41 | "commands", 42 | "docs", 43 | "public" 44 | ) 45 | }, 46 | plugins: [ 47 | new HtmlWebpackPlugin({ 48 | inject: true, 49 | hash: true, 50 | template: "./index.html", 51 | filename: "index.html" 52 | }), 53 | new MiniCssExtractPlugin({ 54 | filename: "[name].css", 55 | chunkFilename: "[id].css", 56 | ignoreOrder: false // Enable to remove warnings about conflicting order 57 | }) 58 | ] 59 | }; 60 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib/src/lib"; 2 | -------------------------------------------------------------------------------- /jest.ci.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | reporters: ["default", "jest-junit"] 5 | }; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | testMatch: ["**/?(*.)+(spec).ts"], 5 | testPathIgnorePatterns: ["/node_modules/"] 6 | }; 7 | -------------------------------------------------------------------------------- /lib/src/checksum/__spec-examples__/contract.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: String | null; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/checksum/hash.spec.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from "../definitions"; 2 | import { parseContract } from "../parsers/contract-parser"; 3 | import { createProjectFromExistingSourceFile } from "../spec-helpers/helper"; 4 | import { TypeKind } from "../types"; 5 | import { hashContract } from "./hash"; 6 | 7 | describe("Hash", () => { 8 | describe("hashContract", () => { 9 | it("returns a consistent hash", () => { 10 | const file = createProjectFromExistingSourceFile( 11 | `${__dirname}/__spec-examples__/contract.ts` 12 | ).file; 13 | 14 | const { contract } = parseContract(file).unwrapOrThrow(); 15 | 16 | const hash0 = hashContract(contract); 17 | const hash1 = hashContract(contract); 18 | 19 | expect(hash0).toEqual(hash1); 20 | }); 21 | 22 | it("returns a new hash when a new endpoint is added", () => { 23 | const file = createProjectFromExistingSourceFile( 24 | `${__dirname}/__spec-examples__/contract.ts` 25 | ).file; 26 | 27 | const { contract } = parseContract(file).unwrapOrThrow(); 28 | 29 | const hash0 = hashContract(contract); 30 | 31 | const endpointDefinition: Endpoint = { 32 | name: "testEndpoint", 33 | description: "test endpoint", 34 | draft: false, 35 | tags: ["test"], 36 | method: "GET", 37 | path: "/test", 38 | request: { 39 | headers: [], 40 | pathParams: [], 41 | queryParams: [], 42 | body: { 43 | type: { 44 | kind: TypeKind.STRING 45 | } 46 | } 47 | }, 48 | responses: [] 49 | }; 50 | 51 | contract.endpoints.push(endpointDefinition); 52 | 53 | const hash1 = hashContract(contract); 54 | 55 | expect(hash0).not.toEqual(hash1); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /lib/src/checksum/hash.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | import { Contract } from "../definitions"; 3 | 4 | export function hashContract(contract: Contract): string { 5 | const contractDefinitionString = JSON.stringify(contract); 6 | 7 | return createHash("sha1").update(contractDefinitionString).digest("hex"); 8 | } 9 | -------------------------------------------------------------------------------- /lib/src/core.ts: -------------------------------------------------------------------------------- 1 | import * as JsonSchema from "./generators/json-schema"; 2 | import * as OpenApi2 from "./generators/openapi2"; 3 | import * as OpenApi3 from "./generators/openapi3"; 4 | import { parse } from "./parser"; 5 | 6 | export { parse as parseContract, OpenApi2, OpenApi3, JsonSchema }; 7 | -------------------------------------------------------------------------------- /lib/src/definitions.ts: -------------------------------------------------------------------------------- 1 | import { Type, TypeDef } from "./types"; 2 | 3 | export interface Contract { 4 | name: string; 5 | description?: string; 6 | version?: string; 7 | config: Config; 8 | types: { name: string; typeDef: TypeDef }[]; 9 | security?: SecurityHeader; 10 | endpoints: Endpoint[]; 11 | oa3servers?: Oa3Server[]; 12 | } 13 | 14 | export interface Config { 15 | paramSerializationStrategy: { 16 | query: { 17 | array: QueryParamArrayStrategy; 18 | }; 19 | }; 20 | } 21 | 22 | export interface SecurityHeader { 23 | name: string; 24 | description?: string; 25 | type: Type; 26 | } 27 | 28 | export interface Endpoint { 29 | name: string; 30 | description?: string; 31 | summary?: string; 32 | tags: string[]; 33 | method: HttpMethod; 34 | path: string; 35 | request?: Request; 36 | responses: Response[]; 37 | defaultResponse?: DefaultResponse; 38 | draft: boolean; 39 | } 40 | 41 | export interface Request { 42 | headers: Header[]; 43 | pathParams: PathParam[]; 44 | queryParams: QueryParam[]; 45 | body?: Body; 46 | } 47 | 48 | export interface Response { 49 | status: number; 50 | description?: string; 51 | headers: Header[]; 52 | body?: Body; 53 | } 54 | 55 | export type DefaultResponse = Omit; 56 | 57 | export interface Header { 58 | name: string; 59 | description?: string; 60 | type: Type; 61 | optional: boolean; 62 | examples?: Example[]; 63 | } 64 | 65 | export interface PathParam { 66 | name: string; 67 | description?: string; 68 | type: Type; 69 | examples?: Example[]; 70 | } 71 | 72 | export interface Example { 73 | name: string; 74 | value: any; // TODO: encapsulate type information 75 | } 76 | 77 | export interface QueryParam { 78 | name: string; 79 | description?: string; 80 | type: Type; 81 | optional: boolean; 82 | examples?: Example[]; 83 | } 84 | 85 | export interface Body { 86 | type: Type; 87 | } 88 | 89 | export interface Oa3Server { 90 | url: string; 91 | description?: string; 92 | oa3ServerVariables: Oa3ServerVariable[]; 93 | } 94 | 95 | export interface Oa3ServerVariable { 96 | type: Type; 97 | description?: string; 98 | defaultValue: string; 99 | parameterName: string; 100 | } 101 | 102 | /** 103 | * Supported serialization strategies for arrays in query parameters 104 | * 105 | * "ampersand": ?id=3&id=4&id=5 106 | * "comma": ?id=3,4,5 107 | */ 108 | export type QueryParamArrayStrategy = "ampersand" | "comma"; 109 | 110 | /** Supported HTTP methods */ 111 | export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"; 112 | 113 | /** Type guards */ 114 | export function isSpecificResponse( 115 | response: DefaultResponse 116 | ): response is Response { 117 | return "status" in response; 118 | } 119 | -------------------------------------------------------------------------------- /lib/src/errors.ts: -------------------------------------------------------------------------------- 1 | export class ParserError extends Error { 2 | readonly locations: { file: string; position: number }[]; 3 | 4 | constructor( 5 | readonly message: string, 6 | ...locations: { file: string; position: number }[] 7 | ) { 8 | super(message); // 'Error' breaks prototype chain here 9 | Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain 10 | this.locations = locations; 11 | } 12 | } 13 | 14 | export class OptionalNotAllowedError extends ParserError {} 15 | export class TypeNotAllowedError extends ParserError {} 16 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/__snapshots__/json-schema.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`JSON Schema generator evaluates intersection type 1`] = ` 4 | Object { 5 | "$schema": "http://json-schema.org/draft-07/schema#", 6 | "definitions": Object { 7 | "IntersectionResponse": Object { 8 | "allOf": Array [ 9 | Object { 10 | "additionalProperties": true, 11 | "properties": Object { 12 | "id": Object { 13 | "type": "string", 14 | }, 15 | }, 16 | "required": Array [ 17 | "id", 18 | ], 19 | "type": "object", 20 | }, 21 | Object { 22 | "additionalProperties": true, 23 | "properties": Object { 24 | "name": Object { 25 | "type": "string", 26 | }, 27 | }, 28 | "required": Array [ 29 | "name", 30 | ], 31 | "type": "object", 32 | }, 33 | ], 34 | }, 35 | }, 36 | } 37 | `; 38 | 39 | exports[`JSON Schema generator produces definitions 1`] = ` 40 | Object { 41 | "$schema": "http://json-schema.org/draft-07/schema#", 42 | "definitions": Object { 43 | "User": Object { 44 | "additionalProperties": true, 45 | "properties": Object { 46 | "id": Object { 47 | "type": "string", 48 | }, 49 | "name": Object { 50 | "type": "string", 51 | }, 52 | }, 53 | "required": Array [ 54 | "id", 55 | "name", 56 | ], 57 | "type": "object", 58 | }, 59 | "Users": Object { 60 | "items": Object { 61 | "$ref": "#/definitions/User", 62 | }, 63 | "type": "array", 64 | }, 65 | }, 66 | } 67 | `; 68 | 69 | exports[`JSON Schema generator produces minimal json schema 1`] = ` 70 | Object { 71 | "$schema": "http://json-schema.org/draft-07/schema#", 72 | "definitions": Object {}, 73 | } 74 | `; 75 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/__spec-examples__/contract-with-intersection-types.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class IntersectionType { 11 | @response({ status: 200 }) 12 | successResponse(@body body: IntersectionResponse) {} 13 | } 14 | 15 | type IntersectionResponse = { id: String } & { name: String }; 16 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/__spec-examples__/contract-with-reference-types.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class GetEndpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Users) {} 13 | } 14 | 15 | type Users = User[]; 16 | 17 | interface User { 18 | id: String; 19 | name: String; 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/__spec-examples__/minimal-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/index.ts: -------------------------------------------------------------------------------- 1 | export { generateJsonSchema } from "./json-schema"; 2 | export * as Specification from "./json-schema-specification"; 3 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/json-schema-specification.ts: -------------------------------------------------------------------------------- 1 | export interface JsonSchema { 2 | $schema: "http://json-schema.org/draft-07/schema#"; 3 | definitions: { 4 | [typeName: string]: JsonSchemaType; 5 | }; 6 | } 7 | 8 | export type JsonSchemaType = 9 | | JsonSchemaObject 10 | | JsonSchemaArray 11 | | JsonSchemaOneOf 12 | | JsonSchemaAllOf 13 | | JsonSchemaNull 14 | | JsonSchemaString 15 | | JsonSchemaNumber 16 | | JsonSchemaInteger 17 | | JsonSchemaBoolean 18 | | JsonSchemaTypeReference; 19 | 20 | export interface JsonSchemaObject { 21 | type: "object"; 22 | properties: { 23 | [name: string]: JsonSchemaType; 24 | }; 25 | required?: string[]; 26 | additionalProperties: boolean; 27 | } 28 | 29 | export interface JsonSchemaArray { 30 | type: "array"; 31 | items: JsonSchemaType; 32 | } 33 | 34 | export interface JsonSchemaOneOf { 35 | oneOf: JsonSchemaType[]; 36 | discriminator?: { 37 | propertyName: string; 38 | mapping: { 39 | [value: string]: JsonSchemaType; 40 | }; 41 | }; 42 | } 43 | 44 | export interface JsonSchemaAllOf { 45 | allOf: JsonSchemaType[]; 46 | } 47 | 48 | export interface JsonSchemaNull { 49 | type: "null"; 50 | } 51 | 52 | export interface JsonSchemaString { 53 | type: "string"; 54 | format?: string; 55 | const?: string; 56 | enum?: string[]; 57 | } 58 | 59 | export interface JsonSchemaNumber { 60 | type: "number"; 61 | const?: number; 62 | enum?: number[]; 63 | } 64 | 65 | export interface JsonSchemaInteger { 66 | type: "integer"; 67 | const?: number; 68 | enum?: number[]; 69 | } 70 | 71 | export interface JsonSchemaBoolean { 72 | type: "boolean"; 73 | const?: boolean; 74 | enum?: boolean[]; 75 | } 76 | 77 | export interface JsonSchemaTypeReference { 78 | $ref: string; 79 | } 80 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/json-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { isJSONSchemaDraft7, Spectral } from "@stoplight/spectral"; 2 | import { parseContract } from "../../parsers/contract-parser"; 3 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 4 | import { generateJsonSchema } from "./json-schema"; 5 | 6 | describe("JSON Schema generator", () => { 7 | const spectral = new Spectral(); 8 | 9 | beforeAll(async () => { 10 | spectral.registerFormat("json-schema-draft7", isJSONSchemaDraft7); 11 | }); 12 | 13 | test("produces minimal json schema", async () => { 14 | const file = createProjectFromExistingSourceFile( 15 | `${__dirname}/__spec-examples__/minimal-contract.ts` 16 | ).file; 17 | 18 | const { contract } = parseContract(file).unwrapOrThrow(); 19 | const result = generateJsonSchema(contract); 20 | 21 | expect(result.$schema).toEqual("http://json-schema.org/draft-07/schema#"); 22 | expect(result.definitions).toEqual({}); 23 | expect(result).toMatchSnapshot(); 24 | const spectralResult = await spectral.run(result); 25 | expect(spectralResult).toHaveLength(0); 26 | }); 27 | 28 | test("produces definitions", async () => { 29 | const file = createProjectFromExistingSourceFile( 30 | `${__dirname}/__spec-examples__/contract-with-reference-types.ts` 31 | ).file; 32 | 33 | const { contract } = parseContract(file).unwrapOrThrow(); 34 | const result = generateJsonSchema(contract); 35 | 36 | expect(result.definitions).toHaveProperty("User"); 37 | expect(result.definitions).toHaveProperty("Users"); 38 | expect(result).toMatchSnapshot(); 39 | const spectralResult = await spectral.run(result); 40 | expect(spectralResult).toHaveLength(0); 41 | }); 42 | 43 | test("evaluates intersection type", async () => { 44 | const file = createProjectFromExistingSourceFile( 45 | `${__dirname}/__spec-examples__/contract-with-intersection-types.ts` 46 | ).file; 47 | 48 | const { contract } = parseContract(file).unwrapOrThrow(); 49 | const result = generateJsonSchema(contract); 50 | 51 | expect(result).toMatchSnapshot(); 52 | const spectralResult = await spectral.run(result); 53 | expect(spectralResult).toHaveLength(0); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /lib/src/generators/json-schema/json-schema.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "../../definitions"; 2 | import { JsonSchema, JsonSchemaType } from "./json-schema-specification"; 3 | import { typeToJsonSchemaType } from "./json-schema-type-util"; 4 | 5 | export function generateJsonSchema(contract: Contract): JsonSchema { 6 | return { 7 | $schema: "http://json-schema.org/draft-07/schema#", 8 | definitions: contract.types.reduce<{ 9 | [typeName: string]: JsonSchemaType; 10 | }>((acc, typeNode) => { 11 | acc[typeNode.name] = typeToJsonSchemaType(typeNode.typeDef.type); 12 | return acc; 13 | }, {}) 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-array-query-param-and-comma-serialization-strategy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | config, 5 | endpoint, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @config({ 13 | paramSerializationStrategy: { 14 | query: { 15 | array: "comma" 16 | } 17 | } 18 | }) 19 | @api({ name: "contract" }) 20 | class Contract {} 21 | 22 | @endpoint({ 23 | method: "GET", 24 | path: "/companies" 25 | }) 26 | class EndpointWithArrayQueryParam { 27 | @request 28 | request( 29 | @queryParams 30 | queryParams: { 31 | countries: String[]; 32 | } 33 | ) {} 34 | 35 | @response({ status: 200 }) 36 | successResponse(@body body: { id: String; name: String }[]) {} 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-array-query-param.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | queryParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/companies" 17 | }) 18 | class EndpointWithArrayQueryParam { 19 | @request 20 | request( 21 | @queryParams 22 | queryParams: { 23 | countries: String[]; 24 | } 25 | ) {} 26 | 27 | @response({ status: 200 }) 28 | successResponse(@body body: { id: String; name: String }[]) {} 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-delete-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | endpoint, 4 | pathParams, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "DELETE", 15 | path: "/users/:id" 16 | }) 17 | class DeleteEndpoint { 18 | @request 19 | request( 20 | @pathParams 21 | pathParams: { 22 | id: String; 23 | } 24 | ) {} 25 | 26 | @response({ status: 204 }) 27 | successResponse() {} 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-endpoint-metadata.ts: -------------------------------------------------------------------------------- 1 | import { api, endpoint, response, String, body } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | /** 7 | * My description 8 | * 9 | * @summary 10 | * My summary 11 | */ 12 | @endpoint({ 13 | method: "GET", 14 | path: "/users" 15 | }) 16 | class GetEndpoint { 17 | @response({ status: 200 }) 18 | successResponse(@body body: { id: String; name: String }) {} 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-get-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class GetEndpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: { id: String; name: String }[]) {} 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-intersection-types.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class IntersectionType { 11 | @response({ status: 200 }) 12 | successResponse(@body body: { id: String } & { name: String }) {} 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-object-query-param.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | Int32, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "GET", 17 | path: "/companies" 18 | }) 19 | class EndpointWithArrayQueryParam { 20 | @request 21 | request( 22 | @queryParams 23 | queryParams: { 24 | pagination: { 25 | page: Int32; 26 | order: "desc" | "asc"; 27 | }; 28 | } 29 | ) {} 30 | 31 | @response({ status: 200 }) 32 | successResponse(@body body: { id: String; name: String }[]) {} 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-patch-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PATCH", 16 | path: "/users/:id" 17 | }) 18 | class PatchEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | }, 25 | @body body: { name: String } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-path-params.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/companies/:companyId/users/:userId" 17 | }) 18 | class EndpointWithPathParams { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | companyId: String; 24 | userId: String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-post-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "POST", 15 | path: "/users" 16 | }) 17 | class PostEndpoint { 18 | @request 19 | request(@body body: { name: String }) {} 20 | 21 | @response({ status: 201 }) 22 | successResponse(@body body: { id: String; name: String }) {} 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-put-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PUT", 16 | path: "/users/:id" 17 | }) 18 | class PutEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | }, 25 | @body body: { name: String } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-query-params.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | queryParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/companies" 17 | }) 18 | class EndpointWithQueryParams { 19 | @request 20 | request( 21 | @queryParams 22 | queryParams: { 23 | country: String; 24 | "post.code"?: String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }[]) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-request-headers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | headers, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/users" 17 | }) 18 | class EndpointWithRequestHeaders { 19 | @request 20 | request( 21 | @headers 22 | headers: { 23 | "Accept-Encoding"?: String; 24 | "Accept-Language": String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }[]) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-response-headers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | headers, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "POST", 16 | path: "/users" 17 | }) 18 | class EndpointWithResponseHeaders { 19 | @request 20 | request(@body body: { name: String }) {} 21 | 22 | @response({ status: 201 }) 23 | successResponse( 24 | @headers 25 | headers: { 26 | Location: String; 27 | Link?: String; 28 | }, 29 | @body body: { id: String; name: String } 30 | ) {} 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-security-header.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | response, 6 | securityHeader, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract { 12 | @securityHeader 13 | "security-header": String; 14 | } 15 | 16 | @endpoint({ 17 | method: "GET", 18 | path: "/users" 19 | }) 20 | class GetEndpoint { 21 | @response({ status: 200 }) 22 | successResponse(@body body: { id: String; name: String }[]) {} 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/contract-with-specific-and-default-responses.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | pathParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "GET", 17 | path: "/users/:id" 18 | }) 19 | class EndpointWithResponses { 20 | @request 21 | request( 22 | @pathParams 23 | pathParams: { 24 | id: String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | 31 | @response({ status: 404 }) 32 | notFoundResponse(@body body: { message: String; status: String }) {} 33 | 34 | @defaultResponse 35 | defaultResponse(@body body: { message: String }) {} 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/minimal-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/__spec-examples__/versioned-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: "versioned-contract", version: "0.1.0" }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/index.ts: -------------------------------------------------------------------------------- 1 | export { generateOpenAPI2 } from "./openapi2"; 2 | export * as Specification from "./openapi2-specification"; 3 | -------------------------------------------------------------------------------- /lib/src/generators/openapi2/spectral.ruleset.yml: -------------------------------------------------------------------------------- 1 | # https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md 2 | extends: [[spectral:oas, off]] 3 | rules: 4 | operation-2xx-response: true 5 | operation-operationId-unique: true 6 | operation-parameters: true 7 | path-params: true 8 | example-value-or-externalValue: true 9 | no-eval-in-markdown: true 10 | no-script-tags-in-markdown: true 11 | openapi-tags-alphabetical: true 12 | operation-operationId-valid-in-url: true 13 | path-declarations-must-exist: true 14 | path-keys-no-trailing-slash: true 15 | path-not-include-query: true 16 | typed-enum: true 17 | oas2-operation-formData-consume-check: true 18 | oas2-operation-security-defined: true 19 | oas2-valid-example: true 20 | oas2-anyOf: true 21 | oas2-oneOf: true 22 | oas2-schema: true 23 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-array-query-param-and-comma-serialization-strategy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | config, 5 | endpoint, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @config({ 13 | paramSerializationStrategy: { 14 | query: { 15 | array: "comma" 16 | } 17 | } 18 | }) 19 | @api({ name: "contract" }) 20 | class Contract {} 21 | 22 | @endpoint({ 23 | method: "GET", 24 | path: "/companies" 25 | }) 26 | class EndpointWithArrayQueryParam { 27 | @request 28 | request( 29 | @queryParams 30 | queryParams: { 31 | countries: String[]; 32 | } 33 | ) {} 34 | 35 | @response({ status: 200 }) 36 | successResponse(@body body: { id: String; name: String }[]) {} 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-array-query-param.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | queryParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/companies" 17 | }) 18 | class EndpointWithArrayQueryParam { 19 | @request 20 | request( 21 | @queryParams 22 | queryParams: { 23 | countries: String[]; 24 | } 25 | ) {} 26 | 27 | @response({ status: 200 }) 28 | successResponse(@body body: { id: String; name: String }[]) {} 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-delete-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | endpoint, 4 | pathParams, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "DELETE", 15 | path: "/users/:id" 16 | }) 17 | class DeleteEndpoint { 18 | @request 19 | request( 20 | @pathParams 21 | pathParams: { 22 | id: String; 23 | } 24 | ) {} 25 | 26 | @response({ status: 204 }) 27 | successResponse() {} 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-endpoint-metadata.ts: -------------------------------------------------------------------------------- 1 | import { api, endpoint, response, String, body } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | /** 7 | * My description 8 | * 9 | * @summary 10 | * My summary 11 | */ 12 | @endpoint({ 13 | method: "GET", 14 | path: "/users" 15 | }) 16 | class GetEndpoint { 17 | @response({ status: 200 }) 18 | successResponse(@body body: { id: String; name: String }) {} 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-examples.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | headers, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/users" 17 | }) 18 | class EndpointWithExampleOnHeaders { 19 | @request 20 | request( 21 | @headers 22 | headers: { 23 | /** property-example description 24 | * @example property-example 25 | * "property-example-value" 26 | * */ 27 | "Accept-Language": String; 28 | } 29 | ) {} 30 | 31 | @response({ status: 200 }) 32 | successResponse(@body body: { id: String; name: String }[]) {} 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-get-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class GetEndpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: { id: String; name: String }[]) {} 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-head-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { api, headers, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "HEAD", 8 | path: "/users" 9 | }) 10 | class HeadEndpoint { 11 | @response({ status: 204 }) 12 | successResponse( 13 | @headers 14 | headers: { 15 | Location: String; 16 | Link?: String; 17 | } 18 | ) {} 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-intersection-types.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class IntersectionType { 11 | @response({ status: 200 }) 12 | successResponse(@body body: { id: String } & { name: String }) {} 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-multiple-servers.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | import { oa3server } from "../../../syntax/oa3server"; 3 | import { oa3serverVariables } from "../../../syntax/oa3serverVariables"; 4 | import { String } from "@airtasker/spot"; 5 | 6 | @api({ name: "contract" }) 7 | class Contract { 8 | @oa3server({ 9 | url: "https://{username}.gigantic-server.com:{port}/{basePath}" 10 | }) 11 | productionServer( 12 | @oa3serverVariables 13 | variables: { 14 | /** 15 | * this value is assigned by the service provider, in this example `gigantic-server.com` 16 | * 17 | * @default "demo" 18 | */ 19 | username: String; 20 | /** 21 | * @default "8443" 22 | */ 23 | port: "8443" | "443"; 24 | /** 25 | * @default "v2" 26 | */ 27 | basePath: String; 28 | } 29 | ) {} 30 | 31 | @oa3server({ 32 | url: "https://{username}.gigantic-server.com:{port}/{basePath}" 33 | }) 34 | devServer( 35 | @oa3serverVariables 36 | variables: { 37 | /** 38 | * this value is assigned by the service provider, in this example `gigantic-server.com` 39 | * 40 | * @default "dev" 41 | */ 42 | username: String; 43 | /** 44 | * @default "8080" 45 | */ 46 | port: "8080" | "8081"; 47 | /** 48 | * @default "v2" 49 | */ 50 | basePath: String; 51 | } 52 | ) {} 53 | } 54 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-object-query-param.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | Int32, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "GET", 17 | path: "/companies" 18 | }) 19 | class EndpointWithArrayQueryParam { 20 | @request 21 | request( 22 | @queryParams 23 | queryParams: { 24 | pagination: { 25 | page: Int32; 26 | order: "desc" | "asc"; 27 | }; 28 | } 29 | ) {} 30 | 31 | @response({ status: 200 }) 32 | successResponse(@body body: { id: String; name: String }[]) {} 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-one-server.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | import { oa3server } from "../../../syntax/oa3server"; 3 | import { oa3serverVariables } from "../../../syntax/oa3serverVariables"; 4 | import { String } from "@airtasker/spot"; 5 | 6 | @api({ name: "contract" }) 7 | class Contract { 8 | /** 9 | * Production server 10 | */ 11 | @oa3server({ 12 | url: "https://{username}.gigantic-server.com:{port}/{basePath}" 13 | }) 14 | productionServer( 15 | @oa3serverVariables 16 | variables: { 17 | /** 18 | * this value is assigned by the service provider, in this example `gigantic-server.com` 19 | * 20 | * @default "demo" 21 | */ 22 | username: String; 23 | /** 24 | * @default "8443" 25 | */ 26 | port: "8443" | "443"; 27 | /** 28 | * @default "v2" 29 | */ 30 | basePath: String; 31 | } 32 | ) {} 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-patch-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PATCH", 16 | path: "/users/:id" 17 | }) 18 | class PatchEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | }, 25 | @body body: { name: String } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-path-params.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/companies/:companyId/users/:userId" 17 | }) 18 | class EndpointWithPathParams { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | companyId: String; 24 | userId: String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-post-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "POST", 15 | path: "/users" 16 | }) 17 | class PostEndpoint { 18 | @request 19 | request(@body body: { name: String }) {} 20 | 21 | @response({ status: 201 }) 22 | successResponse(@body body: { id: String; name: String }) {} 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-put-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PUT", 16 | path: "/users/:id" 17 | }) 18 | class PutEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | }, 25 | @body body: { name: String } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-query-params.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | queryParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/companies" 17 | }) 18 | class EndpointWithQueryParams { 19 | @request 20 | request( 21 | @queryParams 22 | queryParams: { 23 | country: String; 24 | "post.code"?: String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }[]) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-request-headers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | headers, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/users" 17 | }) 18 | class EndpointWithRequestHeaders { 19 | @request 20 | request( 21 | @headers 22 | headers: { 23 | "Accept-Encoding"?: String; 24 | "Accept-Language": String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }[]) {} 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-response-headers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | headers, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "POST", 16 | path: "/users" 17 | }) 18 | class EndpointWithResponseHeaders { 19 | @request 20 | request(@body body: { name: String }) {} 21 | 22 | @response({ status: 201 }) 23 | successResponse( 24 | @headers 25 | headers: { 26 | Location: String; 27 | Link?: String; 28 | }, 29 | @body body: { id: String; name: String } 30 | ) {} 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-security-header.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | response, 6 | securityHeader, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract { 12 | @securityHeader 13 | "security-header": String; 14 | } 15 | 16 | @endpoint({ 17 | method: "GET", 18 | path: "/users" 19 | }) 20 | class GetEndpoint { 21 | @response({ status: 200 }) 22 | successResponse(@body body: { id: String; name: String }[]) {} 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/contract-with-specific-and-default-responses.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | pathParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "GET", 17 | path: "/users/:id" 18 | }) 19 | class EndpointWithResponses { 20 | @request 21 | request( 22 | @pathParams 23 | pathParams: { 24 | id: String; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: { id: String; name: String }) {} 30 | 31 | @response({ status: 404 }) 32 | notFoundResponse(@body body: { message: String; status: String }) {} 33 | 34 | @defaultResponse 35 | defaultResponse(@body body: { message: String }) {} 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/minimal-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/__spec-examples__/versioned-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: "versioned-contract", version: "0.1.0" }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/index.ts: -------------------------------------------------------------------------------- 1 | export { generateOpenAPI3 } from "./openapi3"; 2 | export * as Specification from "./openapi3-specification"; 3 | -------------------------------------------------------------------------------- /lib/src/generators/openapi3/spectral.ruleset.yml: -------------------------------------------------------------------------------- 1 | # https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md 2 | extends: [[spectral:oas, off]] 3 | rules: 4 | operation-2xx-response: true 5 | operation-operationId-unique: true 6 | operation-parameters: true 7 | path-params: true 8 | example-value-or-externalValue: true 9 | no-eval-in-markdown: true 10 | no-script-tags-in-markdown: true 11 | openapi-tags-alphabetical: true 12 | operation-operationId-valid-in-url: true 13 | path-declarations-must-exist: true 14 | path-keys-no-trailing-slash: true 15 | path-not-include-query: true 16 | typed-enum: true 17 | oas3-operation-security-defined: true 18 | oas3-server-not-example.com: true 19 | oas3-server-trailing-slash: true 20 | oas3-unused-components-schema: true 21 | oas3-valid-example: true 22 | oas3-schema: true 23 | -------------------------------------------------------------------------------- /lib/src/io/output.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import { expandPathWithTilde } from "../utilities/expand-path-with-tilde"; 4 | 5 | export function outputFile( 6 | outDir: string, 7 | relativePath: string, 8 | content: string, 9 | override = true 10 | ): boolean { 11 | const destinationPath = path.join(expandPathWithTilde(outDir), relativePath); 12 | fs.mkdirpSync(path.dirname(destinationPath)); 13 | if (!override && fs.existsSync(destinationPath)) { 14 | // Skip. 15 | return false; 16 | } 17 | fs.writeFileSync(destinationPath, content, "utf8"); 18 | return true; 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/lib.ts: -------------------------------------------------------------------------------- 1 | export * as Spot from "./core"; 2 | export * from "./syntax"; 3 | -------------------------------------------------------------------------------- /lib/src/linting/find-lint-violations.ts: -------------------------------------------------------------------------------- 1 | import { GroupedLintRuleViolations } from "./rule"; 2 | import { LintConfig } from "../../../cli/src/commands/lint"; 3 | 4 | interface FindLintViolationsDependencies { 5 | error: (msg: string) => void; 6 | warn: (msg: string) => void; 7 | } 8 | 9 | export interface FindLintViolationsResult { 10 | errorCount: number; 11 | warningCount: number; 12 | } 13 | 14 | /** 15 | * Responsible for triggering error or warn depending on whether the lint rule 16 | * violation setting is 'off', 'warn' or 'error'. 17 | * 18 | * By default, if a lint rule setting is not set in lintConfig, 19 | * then it will be considered a error. 20 | */ 21 | export const findLintViolations = ( 22 | groupedLintErrors: GroupedLintRuleViolations[], 23 | lintConfig: LintConfig, 24 | { error, warn }: FindLintViolationsDependencies 25 | ): FindLintViolationsResult => { 26 | let errorCount = 0; 27 | let warningCount = 0; 28 | 29 | groupedLintErrors.forEach(lintingErrors => { 30 | const ruleSetting = lintConfig["rules"][lintingErrors.name] ?? "error"; 31 | 32 | switch (ruleSetting) { 33 | case "error": { 34 | lintingErrors.violations.forEach(lintError => { 35 | error(lintError.message); 36 | errorCount++; 37 | }); 38 | break; 39 | } 40 | 41 | case "warn": { 42 | lintingErrors.violations.forEach(lintWarning => { 43 | warn(lintWarning.message); 44 | warningCount++; 45 | }); 46 | break; 47 | } 48 | 49 | case "off": { 50 | break; 51 | } 52 | 53 | default: { 54 | error( 55 | `Unknown lint rule setting for ${lintingErrors.name}: ${ruleSetting}` 56 | ); 57 | errorCount++; 58 | } 59 | } 60 | }); 61 | 62 | return { 63 | errorCount, 64 | warningCount 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /lib/src/linting/linter.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "../definitions"; 2 | import { GroupedLintRuleViolations } from "./rule"; 3 | import { availableRules } from "./rules"; 4 | 5 | export function lint(contract: Contract): GroupedLintRuleViolations[] { 6 | return Object.keys(availableRules).reduce( 7 | (acc, ruleName) => 8 | acc.concat({ 9 | name: ruleName, 10 | violations: availableRules[ruleName](contract) 11 | }), 12 | [] 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/src/linting/rule.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "../definitions"; 2 | 3 | /** 4 | * A linting rule is a function that returns a list of violations, which will 5 | * be empty when the rule is complied with. 6 | */ 7 | export type LintingRule = (contract: Contract) => LintingRuleViolation[]; 8 | 9 | export interface LintingRuleViolation { 10 | message: string; 11 | } 12 | 13 | export interface GroupedLintRuleViolations { 14 | name: string; 15 | violations: LintingRuleViolation[]; 16 | } 17 | -------------------------------------------------------------------------------- /lib/src/linting/rules.ts: -------------------------------------------------------------------------------- 1 | import { LintingRule } from "./rule"; 2 | import { hasDiscriminator } from "./rules/has-discriminator"; 3 | import { hasRequestPayload } from "./rules/has-request-payload"; 4 | import { hasResponse } from "./rules/has-response"; 5 | import { hasResponsePayload } from "./rules/has-response-payload"; 6 | import { noInlineObjectsWithinUnions } from "./rules/no-inline-objects-within-unions"; 7 | import { noNullableArrays } from "./rules/no-nullable-arrays"; 8 | import { noNullableFieldsWithinRequestBodies } from "./rules/no-nullable-fields-within-request-bodies"; 9 | import { noOmittableFieldsWithinResponseBodies } from "./rules/no-omittable-fields-within-response-bodies"; 10 | import { noTrailingForwardSlash } from "./rules/no-trailing-forward-slash"; 11 | 12 | export const availableRules: LintingRules = { 13 | "has-discriminator": hasDiscriminator, 14 | "has-request-payload": hasRequestPayload, 15 | "has-response-payload": hasResponsePayload, 16 | "has-response": hasResponse, 17 | "no-inline-objects-within-unions": noInlineObjectsWithinUnions, 18 | "no-nullable-arrays": noNullableArrays, 19 | "no-nullable-fields-within-request-bodies": 20 | noNullableFieldsWithinRequestBodies, 21 | "no-omittable-fields-within-response-bodies": 22 | noOmittableFieldsWithinResponseBodies, 23 | "no-trailing-forward-slash": noTrailingForwardSlash 24 | }; 25 | 26 | interface LintingRules { 27 | [rule: string]: LintingRule; 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-discriminator/contract-with-union-violations-in-each-component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | Float, 7 | headers, 8 | pathParams, 9 | queryParams, 10 | request, 11 | response, 12 | String 13 | } from "@airtasker/spot"; 14 | 15 | @api({ name: "contract" }) 16 | class Contract {} 17 | 18 | @endpoint({ 19 | method: "POST", 20 | path: "/companies/:companyId/users" 21 | }) 22 | class Endpoint { 23 | @request 24 | request( 25 | @headers 26 | headers: { 27 | header: String | Float; 28 | }, 29 | @pathParams 30 | pathParams: { 31 | companyId: String | Float; 32 | }, 33 | @queryParams 34 | queryParams: { 35 | query: String | Float; 36 | }, 37 | @body 38 | body: RequestBody 39 | ) {} 40 | 41 | @response({ status: 200 }) 42 | successResponse( 43 | @headers 44 | headers: { 45 | header: String | Float; 46 | }, 47 | @body body: SuccessBody 48 | ) {} 49 | 50 | @defaultResponse 51 | defaultResponse( 52 | @headers 53 | headers: { 54 | header: String | Float; 55 | }, 56 | @body body: ErrorBody 57 | ) {} 58 | } 59 | 60 | interface RequestBody { 61 | requestUnion: RequestA | RequestB; 62 | } 63 | 64 | interface RequestA { 65 | type: "a"; 66 | requestA: String; 67 | } 68 | 69 | interface RequestB { 70 | requestB: String; 71 | } 72 | 73 | interface SuccessBody { 74 | successUnion: SuccessA | SuccessB; 75 | } 76 | 77 | interface SuccessA { 78 | type: "a"; 79 | successA: String; 80 | } 81 | 82 | interface SuccessB { 83 | successB: String; 84 | } 85 | 86 | interface ErrorBody { 87 | errorUnion: ErrorA | ErrorB; 88 | } 89 | 90 | interface ErrorA { 91 | type: "a"; 92 | errorA: String; 93 | } 94 | 95 | interface ErrorB { 96 | errorB: String; 97 | } 98 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-discriminator/multiple-homogeneous-primitive-type-or-null-union.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: "a" | "b" | "c" | null; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-discriminator/object-union-with-discriminator.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: TypeA | TypeB; 17 | } 18 | 19 | interface TypeA { 20 | type: "a"; 21 | a: String; 22 | } 23 | 24 | interface TypeB { 25 | type: "b"; 26 | b: String; 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-discriminator/object-union-with-no-discriminator.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: TypeA | TypeB; 17 | } 18 | 19 | interface TypeA { 20 | type: "a"; 21 | a: String; 22 | } 23 | 24 | interface TypeB { 25 | b: String; 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-discriminator/single-type-or-null-union.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: String | null; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-query-parameters/patch-endpoint-with-query-parameters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | Float, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "PATCH", 17 | path: "/users" 18 | }) 19 | class PatchEndpoint { 20 | @request 21 | request( 22 | @queryParams 23 | queryParams: { 24 | query: String | Float; 25 | } 26 | ) {} 27 | 28 | @response({ status: 200 }) 29 | successResponse(@body body: Body) {} 30 | } 31 | 32 | interface Body { 33 | body: String; 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-query-parameters/patch-endpoint-without-query-parameters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "PATCH", 15 | path: "/users" 16 | }) 17 | class PatchEndpoint { 18 | @request 19 | request() {} 20 | 21 | @response({ status: 200 }) 22 | successResponse(@body body: Body) {} 23 | } 24 | 25 | interface Body { 26 | body: String; 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-query-parameters/post-endpoint-with-query-parameters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | Float, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "POST", 17 | path: "/users" 18 | }) 19 | class PostEndpoint { 20 | @request 21 | request( 22 | @queryParams 23 | queryParams: { 24 | query: String | Float; 25 | }, 26 | @body 27 | body: Body 28 | ) {} 29 | 30 | @response({ status: 200 }) 31 | successResponse(@body body: Body) {} 32 | } 33 | 34 | interface Body { 35 | body: String; 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-query-parameters/post-endpoint-without-query-parameters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | @api({ name: "contract" }) 10 | class Contract {} 11 | 12 | @endpoint({ 13 | method: "POST", 14 | path: "/users" 15 | }) 16 | class PostEndpoint { 17 | @request 18 | request( 19 | @body 20 | body: Body 21 | ) {} 22 | 23 | @response({ status: 200 }) 24 | successResponse(@body body: Body) {} 25 | } 26 | 27 | interface Body { 28 | body: String; 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-query-parameters/put-endpoint-with-query-parameters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | Float, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "PUT", 17 | path: "/users" 18 | }) 19 | class PutEndpoint { 20 | @request 21 | request( 22 | @queryParams 23 | queryParams: { 24 | query: String | Float; 25 | }, 26 | @body 27 | body: Body 28 | ) {} 29 | 30 | @response({ status: 200 }) 31 | successResponse(@body body: Body) {} 32 | } 33 | 34 | interface Body { 35 | body: String; 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-query-parameters/put-endpoint-without-query-parameters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "PUT", 15 | path: "/users" 16 | }) 17 | class PutEndpoint { 18 | @request 19 | request( 20 | @body 21 | body: Body 22 | ) {} 23 | 24 | @response({ status: 200 }) 25 | successResponse(@body body: Body) {} 26 | } 27 | 28 | interface Body { 29 | body: String; 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/delete-endpoint-with-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "DELETE", 16 | path: "/users/:id" 17 | }) 18 | class DeleteEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | }, 25 | @body 26 | body: Body 27 | ) {} 28 | 29 | @response({ status: 200 }) 30 | successResponse(@body body: Body) {} 31 | } 32 | 33 | interface Body { 34 | body: String; 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/delete-endpoint-without-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "DELETE", 16 | path: "/users/:id" 17 | }) 18 | class DeleteEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | } 25 | ) {} 26 | 27 | @response({ status: 200 }) 28 | successResponse(@body body: Body) {} 29 | } 30 | 31 | interface Body { 32 | body: String; 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/get-endpoint-with-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "GET", 15 | path: "/users" 16 | }) 17 | class GetEndpoint { 18 | @request 19 | request( 20 | @body 21 | body: Body 22 | ) {} 23 | 24 | @response({ status: 200 }) 25 | successResponse(@body body: Body) {} 26 | } 27 | 28 | interface Body { 29 | body: String; 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/get-endpoint-without-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "GET", 15 | path: "/users" 16 | }) 17 | class GetEndpoint { 18 | @request 19 | request() {} 20 | 21 | @response({ status: 200 }) 22 | successResponse(@body body: Body) {} 23 | } 24 | 25 | interface Body { 26 | body: String; 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/head-endpoint-with-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "HEAD", 15 | path: "/users" 16 | }) 17 | class HeadEndpoint { 18 | @request 19 | request( 20 | @body 21 | body: Body 22 | ) {} 23 | 24 | @response({ status: 204 }) 25 | successResponse() {} 26 | } 27 | 28 | interface Body { 29 | body: String; 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/head-endpoint-without-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "HEAD", 15 | path: "/users" 16 | }) 17 | class HeadEndpoint { 18 | @request 19 | request() {} 20 | 21 | @response({ status: 204 }) 22 | successResponse() {} 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/patch-endpoint-with-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PATCH", 16 | path: "/users/:id" 17 | }) 18 | class PatchEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | }, 25 | @body 26 | body: Body 27 | ) {} 28 | 29 | @response({ status: 200 }) 30 | successResponse(@body body: Body) {} 31 | } 32 | 33 | interface Body { 34 | body: String; 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/patch-endpoint-without-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PATCH", 16 | path: "/users/:id" 17 | }) 18 | class PatchEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | } 25 | ) {} 26 | 27 | @response({ status: 200 }) 28 | successResponse(@body body: Body) {} 29 | } 30 | 31 | interface Body { 32 | body: String; 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/post-endpoint-with-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "POST", 15 | path: "/users" 16 | }) 17 | class PostEndpoint { 18 | @request 19 | request( 20 | @body 21 | body: Body 22 | ) {} 23 | 24 | @response({ status: 201 }) 25 | successResponse(@body body: Body) {} 26 | } 27 | 28 | interface Body { 29 | body: String; 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/post-endpoint-without-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | request, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "POST", 15 | path: "/users" 16 | }) 17 | class PostEndpoint { 18 | @request 19 | request() {} 20 | 21 | @response({ status: 201 }) 22 | successResponse(@body body: Body) {} 23 | } 24 | 25 | interface Body { 26 | body: String; 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/put-endpoint-with-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PUT", 16 | path: "/users/:id" 17 | }) 18 | class PutEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | }, 25 | @body 26 | body: Body 27 | ) {} 28 | 29 | @response({ status: 200 }) 30 | successResponse(@body body: Body) {} 31 | } 32 | 33 | interface Body { 34 | body: String; 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-request-payload/put-endpoint-without-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | endpoint, 5 | pathParams, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "PUT", 16 | path: "/users/:id" 17 | }) 18 | class PutEndpoint { 19 | @request 20 | request( 21 | @pathParams 22 | pathParams: { 23 | id: String; 24 | } 25 | ) {} 26 | 27 | @response({ status: 200 }) 28 | successResponse(@body body: Body) {} 29 | } 30 | 31 | interface Body { 32 | body: String; 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-response-payload/endpoint-default-response-without-body.ts: -------------------------------------------------------------------------------- 1 | import { api, defaultResponse, endpoint } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @defaultResponse 12 | defaultResponse() {} 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-response-payload/endpoint-specific-and-default-responses-with-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | response, 7 | String 8 | } from "@airtasker/spot"; 9 | 10 | @api({ name: "contract" }) 11 | class Contract {} 12 | 13 | @endpoint({ 14 | method: "GET", 15 | path: "/users" 16 | }) 17 | class Endpoint { 18 | @response({ status: 200 }) 19 | successResponse(@body body: Body) {} 20 | 21 | @defaultResponse 22 | defaultResponse(@body body: Body) {} 23 | } 24 | 25 | interface Body { 26 | body: String; 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-response-payload/endpoint-specific-response-without-body.ts: -------------------------------------------------------------------------------- 1 | import { api, endpoint, response } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse() {} 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-response/endpoint-with-default-response.ts: -------------------------------------------------------------------------------- 1 | import { api, body, defaultResponse, endpoint, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @defaultResponse 12 | defaultResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | body: String; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-response/endpoint-with-no-responses.ts: -------------------------------------------------------------------------------- 1 | import { api, endpoint } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint {} 11 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/has-response/endpoint-with-specific-response.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | body: String; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-inline-objects-within-unions/contract-inline-object-union-violations-in-each-query-param-and-body-component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | 12 | @api({ name: "contract" }) 13 | class Contract {} 14 | 15 | @endpoint({ 16 | method: "POST", 17 | path: "/users" 18 | }) 19 | class Endpoint { 20 | @request 21 | request( 22 | @queryParams 23 | queryParams: { 24 | query: InlineObjectUnion; 25 | }, 26 | @body 27 | body: Body 28 | ) {} 29 | 30 | @response({ status: 200 }) 31 | successResponse(@body body: Body) {} 32 | 33 | @defaultResponse 34 | defaultResponse(@body body: Body) {} 35 | } 36 | 37 | interface Body { 38 | field: InlineObjectUnion; 39 | } 40 | 41 | type InlineObjectUnion = { name: String } | { title: String }; 42 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-inline-objects-within-unions/inline-object-or-null-union.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: { name: String } | null; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-inline-objects-within-unions/inline-objects-in-union.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: { name: String } | { title: String }; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-inline-objects-within-unions/referenced-objects-in-union.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | union: ReferenceA | ReferenceB; 17 | } 18 | 19 | interface ReferenceA { 20 | name: String; 21 | } 22 | 23 | interface ReferenceB { 24 | title: String; 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-nullable-arrays/contract-with-nullable-violations-in-each-nullable-component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "POST", 16 | path: "/companies" 17 | }) 18 | class Endpoint { 19 | @request 20 | request( 21 | @body 22 | body: RequestBody 23 | ) {} 24 | 25 | @response({ status: 200 }) 26 | successResponse(@body body: SuccessBody) {} 27 | 28 | @defaultResponse 29 | defaultResponse(@body body: ErrorBody) {} 30 | } 31 | 32 | interface RequestBody { 33 | array: String[] | null; 34 | } 35 | 36 | interface SuccessBody { 37 | array: String[] | null; 38 | } 39 | 40 | interface ErrorBody { 41 | array: String[] | null; 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-nullable-arrays/non-nullable-array.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | array: String[]; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-nullable-arrays/nullable-array.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | array: String[] | null; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-nullable-fields-within-request-bodies/non-nullable-field-in-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "POST", 16 | path: "/users" 17 | }) 18 | class Endpoint { 19 | @request 20 | request( 21 | @body 22 | body: Body 23 | ) {} 24 | 25 | @response({ status: 200 }) 26 | successResponse(@body body: Body) {} 27 | 28 | @defaultResponse 29 | defaultResponse(@body body: Body) {} 30 | } 31 | 32 | interface Body { 33 | body: String; 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-nullable-fields-within-request-bodies/nullable-field-in-request-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "POST", 16 | path: "/users" 17 | }) 18 | class Endpoint { 19 | @request 20 | request( 21 | @body 22 | body: Body | null 23 | ) {} 24 | 25 | @response({ status: 200 }) 26 | successResponse(@body body: Body) {} 27 | 28 | @defaultResponse 29 | defaultResponse(@body body: Body) {} 30 | } 31 | 32 | interface Body { 33 | body: String | null; 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-omittable-fields-within-response-bodies/no-omittable-field-in-response-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/users" 17 | }) 18 | class Endpoint { 19 | @request 20 | request(@body body: Body) {} 21 | 22 | @response({ status: 200 }) 23 | successResponse(@body body: Body) {} 24 | 25 | @defaultResponse 26 | defaultResponse(@body body: Body) {} 27 | } 28 | 29 | interface Body { 30 | field: String; 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-omittable-fields-within-response-bodies/omittable-field-in-response-body.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | defaultResponse, 5 | endpoint, 6 | request, 7 | response, 8 | String 9 | } from "@airtasker/spot"; 10 | 11 | @api({ name: "contract" }) 12 | class Contract {} 13 | 14 | @endpoint({ 15 | method: "GET", 16 | path: "/users" 17 | }) 18 | class Endpoint { 19 | @request 20 | request(@body body: Body) {} 21 | 22 | @response({ status: 200 }) 23 | successResponse(@body body: Body) {} 24 | 25 | @defaultResponse 26 | defaultResponse(@body body: Body) {} 27 | } 28 | 29 | interface Body { 30 | field?: String; 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-primitives-in-request/request-as-array.ts: -------------------------------------------------------------------------------- 1 | import { api, body, request, endpoint } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @request 12 | request(@body body: UserName) {} 13 | } 14 | 15 | type UserName = string[]; 16 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-primitives-in-request/request-as-object.ts: -------------------------------------------------------------------------------- 1 | import { api, body, request, endpoint, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @request 12 | request(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | body: String; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-primitives-in-request/request-with-primitives.ts: -------------------------------------------------------------------------------- 1 | import { api, body, request, endpoint } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @request 12 | request(@body body: UserName) {} 13 | } 14 | 15 | type UserName = string; 16 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-trailing-forward-slash/no-trailing-forward-slash.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | body: String; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/__spec-examples__/no-trailing-forward-slash/trailing-forward-slash.ts: -------------------------------------------------------------------------------- 1 | import { api, body, endpoint, response, String } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | 6 | @endpoint({ 7 | method: "GET", 8 | path: "/users/" 9 | }) 10 | class Endpoint { 11 | @response({ status: 200 }) 12 | successResponse(@body body: Body) {} 13 | } 14 | 15 | interface Body { 16 | body: String; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/linting/rules/has-query-parameters.ts: -------------------------------------------------------------------------------- 1 | import assertNever from "assert-never"; 2 | import { Contract } from "../../definitions"; 3 | import { LintingRuleViolation } from "../rule"; 4 | 5 | /** 6 | * Checks that endpoint request payload conform to HTTP method semantics: 7 | * - PATCH | PUT | POST requests MUST NOT contain query parameters 8 | * 9 | * @param contract a contract 10 | */ 11 | export function hasQueryParameters(contract: Contract): LintingRuleViolation[] { 12 | const violations: LintingRuleViolation[] = []; 13 | 14 | contract.endpoints.forEach(endpoint => { 15 | switch (endpoint.method) { 16 | case "DELETE": 17 | case "GET": 18 | case "HEAD": 19 | break; 20 | case "PATCH": 21 | case "POST": 22 | case "PUT": 23 | if (endpoint.request && endpoint.request.queryParams.length > 0) { 24 | violations.push({ 25 | message: `Endpoint (${endpoint.name}) with HTTP method ${endpoint.method} must not contain query parameters` 26 | }); 27 | } 28 | break; 29 | default: 30 | assertNever(endpoint.method); 31 | } 32 | }); 33 | 34 | return violations; 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/linting/rules/has-request-payload.ts: -------------------------------------------------------------------------------- 1 | import assertNever from "assert-never"; 2 | import { Contract } from "../../definitions"; 3 | import { LintingRuleViolation } from "../rule"; 4 | 5 | /** 6 | * Checks that endpoint request body's conform to HTTP method semantics: 7 | * - GET requests MUST NOT contain a request body 8 | * - POST | PATCH | PUT requests MUST contain a request body 9 | * - DELETE requests MAY contain a request body 10 | * 11 | * @param contract a contract 12 | */ 13 | export function hasRequestPayload(contract: Contract): LintingRuleViolation[] { 14 | const violations: LintingRuleViolation[] = []; 15 | 16 | contract.endpoints.forEach(endpoint => { 17 | switch (endpoint.method) { 18 | case "GET": 19 | case "HEAD": 20 | if (endpoint.request?.body) { 21 | violations.push({ 22 | message: `Endpoint (${endpoint.name}) with HTTP method ${endpoint.method} must not contain a request body` 23 | }); 24 | } 25 | break; 26 | case "POST": 27 | case "PATCH": 28 | case "PUT": 29 | if (!endpoint.request?.body) { 30 | violations.push({ 31 | message: `Endpoint (${endpoint.name}) with HTTP method ${endpoint.method} must contain a request body` 32 | }); 33 | } 34 | break; 35 | case "DELETE": 36 | break; 37 | default: 38 | assertNever(endpoint.method); 39 | } 40 | }); 41 | 42 | return violations; 43 | } 44 | -------------------------------------------------------------------------------- /lib/src/linting/rules/has-response-payload.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { hasResponsePayload } from "./has-response-payload"; 4 | 5 | describe("has-response-payload linter rule", () => { 6 | test("returns violations for endpoint specific response with no response body", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/has-response-payload/endpoint-specific-response-without-body.ts` 9 | ).file; 10 | 11 | const { contract } = parseContract(file).unwrapOrThrow(); 12 | 13 | const result = hasResponsePayload(contract); 14 | 15 | expect(result).toHaveLength(1); 16 | expect(result[0].message).toEqual( 17 | "Endpoint (Endpoint) response for status 200 is missing a response body" 18 | ); 19 | }); 20 | 21 | test("returns violations for endpoint default response with no response body", () => { 22 | const file = createProjectFromExistingSourceFile( 23 | `${__dirname}/__spec-examples__/has-response-payload/endpoint-default-response-without-body.ts` 24 | ).file; 25 | 26 | const { contract } = parseContract(file).unwrapOrThrow(); 27 | 28 | const result = hasResponsePayload(contract); 29 | 30 | expect(result).toHaveLength(1); 31 | expect(result[0].message).toEqual( 32 | "Endpoint (Endpoint) default response is missing a response body" 33 | ); 34 | }); 35 | 36 | test("returns no violations for endpoint specific or default response with a response body", () => { 37 | const file = createProjectFromExistingSourceFile( 38 | `${__dirname}/__spec-examples__/has-response-payload/endpoint-specific-and-default-responses-with-body.ts` 39 | ).file; 40 | 41 | const { contract } = parseContract(file).unwrapOrThrow(); 42 | 43 | expect(hasResponsePayload(contract)).toHaveLength(0); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /lib/src/linting/rules/has-response-payload.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Contract, 3 | DefaultResponse, 4 | Endpoint, 5 | isSpecificResponse, 6 | Response 7 | } from "../../definitions"; 8 | import { LintingRuleViolation } from "../rule"; 9 | 10 | /** 11 | * Checks that all defined responses have a response body. 12 | * 13 | * @param contract a contract 14 | */ 15 | export function hasResponsePayload(contract: Contract): LintingRuleViolation[] { 16 | const violations: LintingRuleViolation[] = []; 17 | 18 | contract.endpoints.forEach(endpoint => { 19 | findResponses(endpoint) 20 | .filter(response => response.body === undefined) 21 | .forEach(responseWithNoBody => { 22 | const responseIdentifier = isSpecificResponse(responseWithNoBody) 23 | ? `response for status ${responseWithNoBody.status}` 24 | : "default response"; 25 | 26 | violations.push({ 27 | message: `Endpoint (${endpoint.name}) ${responseIdentifier} is missing a response body` 28 | }); 29 | }); 30 | }); 31 | 32 | return violations; 33 | } 34 | 35 | function findResponses(endpoint: Endpoint): (DefaultResponse | Response)[] { 36 | return [ 37 | ...endpoint.responses, 38 | ...(endpoint.defaultResponse ? [endpoint.defaultResponse] : []) 39 | ]; 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/linting/rules/has-response.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { hasResponse } from "./has-response"; 4 | 5 | describe("has-response linter rule", () => { 6 | test("returns violations for endpoint with no responses", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/has-response/endpoint-with-no-responses.ts` 9 | ).file; 10 | 11 | const { contract } = parseContract(file).unwrapOrThrow(); 12 | 13 | const result = hasResponse(contract); 14 | 15 | expect(result).toHaveLength(1); 16 | expect(result[0].message).toEqual( 17 | "Endpoint (Endpoint) does not declare any response" 18 | ); 19 | }); 20 | 21 | test("returns no violations for endpoint with only a specific response", () => { 22 | const file = createProjectFromExistingSourceFile( 23 | `${__dirname}/__spec-examples__/has-response/endpoint-with-specific-response.ts` 24 | ).file; 25 | 26 | const { contract } = parseContract(file).unwrapOrThrow(); 27 | 28 | expect(hasResponse(contract)).toHaveLength(0); 29 | }); 30 | 31 | test("returns no violations for endpoint with only a default response", () => { 32 | const file = createProjectFromExistingSourceFile( 33 | `${__dirname}/__spec-examples__/has-response/endpoint-with-default-response.ts` 34 | ).file; 35 | 36 | const { contract } = parseContract(file).unwrapOrThrow(); 37 | 38 | expect(hasResponse(contract)).toHaveLength(0); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/src/linting/rules/has-response.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "../../definitions"; 2 | import { LintingRuleViolation } from "../rule"; 3 | 4 | /** 5 | * Checks that all defined endpoints have at least one response. 6 | * 7 | * @param contract a contract 8 | */ 9 | export function hasResponse(contract: Contract): LintingRuleViolation[] { 10 | const violations: LintingRuleViolation[] = []; 11 | 12 | contract.endpoints 13 | .filter( 14 | endpoint => 15 | endpoint.responses.length === 0 && 16 | endpoint.defaultResponse === undefined 17 | ) 18 | .forEach(endpoint => { 19 | violations.push({ 20 | message: `Endpoint (${endpoint.name}) does not declare any response` 21 | }); 22 | }); 23 | 24 | return violations; 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-inline-objects-within-unions.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { noInlineObjectsWithinUnions } from "./no-inline-objects-within-unions"; 4 | 5 | describe("no-inline-objects-within-unions linter rule", () => { 6 | test("returns no violations for contract containing only referenced objects in unions", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/no-inline-objects-within-unions/referenced-objects-in-union.ts` 9 | ).file; 10 | 11 | const { contract } = parseContract(file).unwrapOrThrow(); 12 | 13 | expect(noInlineObjectsWithinUnions(contract)).toHaveLength(0); 14 | }); 15 | 16 | test("returns no violations for contract containing single inline object and null union", () => { 17 | const file = createProjectFromExistingSourceFile( 18 | `${__dirname}/__spec-examples__/no-inline-objects-within-unions/inline-object-or-null-union.ts` 19 | ).file; 20 | 21 | const { contract } = parseContract(file).unwrapOrThrow(); 22 | 23 | expect(noInlineObjectsWithinUnions(contract)).toHaveLength(0); 24 | }); 25 | 26 | test("returns a violation for contract containing inline objects in unions", () => { 27 | const file = createProjectFromExistingSourceFile( 28 | `${__dirname}/__spec-examples__/no-inline-objects-within-unions/inline-objects-in-union.ts` 29 | ).file; 30 | 31 | const { contract } = parseContract(file).unwrapOrThrow(); 32 | 33 | expect(noInlineObjectsWithinUnions(contract)).toHaveLength(1); 34 | }); 35 | 36 | test("returns violations for every component of a contract", () => { 37 | const file = createProjectFromExistingSourceFile( 38 | `${__dirname}/__spec-examples__/no-inline-objects-within-unions/contract-inline-object-union-violations-in-each-query-param-and-body-component.ts` 39 | ).file; 40 | 41 | const { contract } = parseContract(file).unwrapOrThrow(); 42 | 43 | const result = noInlineObjectsWithinUnions(contract); 44 | expect(result).toHaveLength(4); 45 | const messages = result.map(v => v.message); 46 | expect(messages).toContain( 47 | "Endpoint (Endpoint) request query parameter (query) contains a union type with an inlined object member: #/" 48 | ); 49 | expect(messages).toContain( 50 | "Endpoint (Endpoint) request body contains a union type with an inlined object member: #/field" 51 | ); 52 | expect(messages).toContain( 53 | "Endpoint (Endpoint) response (200) body contains a union type with an inlined object member: #/field" 54 | ); 55 | expect(messages).toContain( 56 | "Endpoint (Endpoint) response (default) body contains a union type with an inlined object member: #/field" 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-nullable-arrays.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { noNullableArrays } from "./no-nullable-arrays"; 4 | 5 | describe("no-nullable-arrays linter rule", () => { 6 | test("returns no violations for contract containing a non nullable array", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/no-nullable-arrays/non-nullable-array.ts` 9 | ).file; 10 | 11 | const { contract } = parseContract(file).unwrapOrThrow(); 12 | 13 | expect(noNullableArrays(contract)).toHaveLength(0); 14 | }); 15 | 16 | test("returns a violation for contract containing a nullable arrays", () => { 17 | const file = createProjectFromExistingSourceFile( 18 | `${__dirname}/__spec-examples__/no-nullable-arrays/nullable-array.ts` 19 | ).file; 20 | 21 | const { contract } = parseContract(file).unwrapOrThrow(); 22 | 23 | expect(noNullableArrays(contract)).toHaveLength(1); 24 | }); 25 | 26 | test("returns violations for every nullable component of a contract", () => { 27 | const file = createProjectFromExistingSourceFile( 28 | `${__dirname}/__spec-examples__/no-nullable-arrays/contract-with-nullable-violations-in-each-nullable-component.ts` 29 | ).file; 30 | 31 | const { contract } = parseContract(file).unwrapOrThrow(); 32 | 33 | const result = noNullableArrays(contract); 34 | expect(result).toHaveLength(3); 35 | const messages = result.map(v => v.message); 36 | expect(messages).toContain( 37 | "Endpoint (Endpoint) request body contains a nullable array type: #/array" 38 | ); 39 | expect(messages).toContain( 40 | "Endpoint (Endpoint) response (200) body contains a nullable array type: #/array" 41 | ); 42 | expect(messages).toContain( 43 | "Endpoint (Endpoint) response (default) body contains a nullable array type: #/array" 44 | ); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-nullable-fields-within-request-bodies.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { noNullableFieldsWithinRequestBodies } from "./no-nullable-fields-within-request-bodies"; 4 | 5 | describe("no-nullable-fields-within-request-bodies linter rule", () => { 6 | test("returns no violations for contract containing a nullable request body field", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/no-nullable-fields-within-request-bodies/non-nullable-field-in-request-body.ts` 9 | ).file; 10 | 11 | const { contract } = parseContract(file).unwrapOrThrow(); 12 | 13 | expect(noNullableFieldsWithinRequestBodies(contract)).toHaveLength(0); 14 | }); 15 | 16 | test("returns a violation for contract containing a nullable request body field", () => { 17 | const file = createProjectFromExistingSourceFile( 18 | `${__dirname}/__spec-examples__/no-nullable-fields-within-request-bodies/nullable-field-in-request-body.ts` 19 | ).file; 20 | 21 | const { contract } = parseContract(file).unwrapOrThrow(); 22 | 23 | const result = noNullableFieldsWithinRequestBodies(contract); 24 | expect(result).toHaveLength(2); 25 | const messages = result.map(v => v.message); 26 | expect(messages).toContain( 27 | "Endpoint (Endpoint) request body contains a nullable field: #/" 28 | ); 29 | expect(messages).toContain( 30 | "Endpoint (Endpoint) request body contains a nullable field: #/body" 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-nullable-fields-within-request-bodies.ts: -------------------------------------------------------------------------------- 1 | import assertNever from "assert-never"; 2 | import { Contract } from "../../definitions"; 3 | import { dereferenceType, Type, TypeKind, TypeTable } from "../../types"; 4 | import { LintingRuleViolation } from "../rule"; 5 | 6 | /** 7 | * Ensures nullable fields are not used in request components. 8 | * 9 | * @param contract a contract 10 | */ 11 | export function noNullableFieldsWithinRequestBodies( 12 | contract: Contract 13 | ): LintingRuleViolation[] { 14 | const typeTable = TypeTable.fromArray(contract.types); 15 | 16 | const violations: LintingRuleViolation[] = []; 17 | 18 | contract.endpoints.forEach(endpoint => { 19 | if (endpoint.request?.body) { 20 | findNullableFieldViolation(endpoint.request.body.type, typeTable).forEach( 21 | path => { 22 | violations.push({ 23 | message: `Endpoint (${endpoint.name}) request body contains a nullable field: #/${path}` 24 | }); 25 | } 26 | ); 27 | } 28 | }); 29 | 30 | return violations; 31 | } 32 | 33 | /** 34 | * Finds nullable field violations for a given type. The paths to the violations 35 | * will be returned. 36 | * 37 | * @param type current type to check 38 | * @param typeTable type reference table 39 | * @param typePath type path for context 40 | */ 41 | function findNullableFieldViolation( 42 | type: Type, 43 | typeTable: TypeTable, 44 | typePath: string[] = [] 45 | ): string[] { 46 | switch (type.kind) { 47 | case TypeKind.NULL: 48 | return [typePath.join("/")]; 49 | case TypeKind.BOOLEAN: 50 | case TypeKind.BOOLEAN_LITERAL: 51 | case TypeKind.STRING: 52 | case TypeKind.STRING_LITERAL: 53 | case TypeKind.FLOAT: 54 | case TypeKind.DOUBLE: 55 | case TypeKind.FLOAT_LITERAL: 56 | case TypeKind.INT32: 57 | case TypeKind.INT64: 58 | case TypeKind.INT_LITERAL: 59 | case TypeKind.DATE: 60 | case TypeKind.DATE_TIME: 61 | return []; 62 | case TypeKind.OBJECT: 63 | return type.properties.reduce((acc, prop) => { 64 | return acc.concat( 65 | findNullableFieldViolation( 66 | prop.type, 67 | typeTable, 68 | typePath.concat(prop.name) 69 | ) 70 | ); 71 | }, []); 72 | case TypeKind.ARRAY: 73 | return findNullableFieldViolation( 74 | type.elementType, 75 | typeTable, 76 | typePath.concat("[]") 77 | ); 78 | case TypeKind.INTERSECTION: 79 | case TypeKind.UNION: 80 | return type.types.reduce((acc, t) => { 81 | return acc.concat( 82 | findNullableFieldViolation(t, typeTable, typePath.concat()) 83 | ); 84 | }, []); 85 | case TypeKind.REFERENCE: 86 | return findNullableFieldViolation( 87 | dereferenceType(type, typeTable), 88 | typeTable, 89 | typePath 90 | ); 91 | default: 92 | throw assertNever(type); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-omittable-fields-within-response-bodies.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { noOmittableFieldsWithinResponseBodies } from "./no-omittable-fields-within-response-bodies"; 4 | 5 | describe("no-omittable-fields-within-response-bodies linter rule", () => { 6 | test("returns no violations for contract containing no omittable response body fields", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/no-omittable-fields-within-response-bodies/no-omittable-field-in-response-body.ts` 9 | ).file; 10 | 11 | const { contract } = parseContract(file).unwrapOrThrow(); 12 | 13 | expect(noOmittableFieldsWithinResponseBodies(contract)).toHaveLength(0); 14 | }); 15 | 16 | test("returns a violation for contract containing a omittable response body fields", () => { 17 | const file = createProjectFromExistingSourceFile( 18 | `${__dirname}/__spec-examples__/no-omittable-fields-within-response-bodies/omittable-field-in-response-body.ts` 19 | ).file; 20 | 21 | const { contract } = parseContract(file).unwrapOrThrow(); 22 | 23 | const result = noOmittableFieldsWithinResponseBodies(contract); 24 | expect(result).toHaveLength(2); 25 | const messages = result.map(v => v.message); 26 | expect(messages).toContain( 27 | "Endpoint (Endpoint) response (200) body contains an omittable field: #/field" 28 | ); 29 | expect(messages).toContain( 30 | "Endpoint (Endpoint) response (default) body contains an omittable field: #/field" 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-primitives-in-request.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { noPrimitivesInRequest } from "./no-primitives-in-request"; 4 | 5 | describe("no-primitives-in-request linter rule", () => { 6 | test("returns violations for endpoint with a request as primitives", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/no-primitives-in-request/request-with-primitives.ts` 9 | ).file; 10 | const { contract } = parseContract(file).unwrapOrThrow(); 11 | const result = noPrimitivesInRequest(contract); 12 | expect(result).toHaveLength(1); 13 | expect(result[0].message).toEqual( 14 | "Endpoint (Endpoint) must contain a request as an object" 15 | ); 16 | }); 17 | 18 | test("returns violations for endpoint with a request as array", () => { 19 | const file = createProjectFromExistingSourceFile( 20 | `${__dirname}/__spec-examples__/no-primitives-in-request/request-as-array.ts` 21 | ).file; 22 | const { contract } = parseContract(file).unwrapOrThrow(); 23 | const result = noPrimitivesInRequest(contract); 24 | expect(result).toHaveLength(1); 25 | expect(result[0].message).toEqual( 26 | "Endpoint (Endpoint) must contain a request as an object" 27 | ); 28 | }); 29 | 30 | test("returns no violations for endpoint with a request as object", () => { 31 | const file = createProjectFromExistingSourceFile( 32 | `${__dirname}/__spec-examples__/no-primitives-in-request/request-as-object.ts` 33 | ).file; 34 | const { contract } = parseContract(file).unwrapOrThrow(); 35 | expect(noPrimitivesInRequest(contract)).toHaveLength(0); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-primitives-in-request.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "../../definitions"; 2 | import { LintingRuleViolation } from "../rule"; 3 | import { dereferenceType, TypeTable, isObjectType } from "../../types"; 4 | 5 | /** 6 | * Request types should always be object types 7 | * 8 | * @param contract a contract 9 | */ 10 | export function noPrimitivesInRequest( 11 | contract: Contract 12 | ): LintingRuleViolation[] { 13 | const violations: LintingRuleViolation[] = []; 14 | const typeTable = TypeTable.fromArray(contract.types); 15 | 16 | contract.endpoints.forEach(endpoint => { 17 | const { request } = endpoint; 18 | const body = request && request.body; 19 | if (!body) { 20 | return; 21 | } 22 | const bodyType = dereferenceType(body.type, typeTable); 23 | 24 | if (!isObjectType(bodyType)) { 25 | violations.push({ 26 | message: `Endpoint (${endpoint.name}) must contain a request as an object` 27 | }); 28 | } 29 | }); 30 | 31 | return violations; 32 | } 33 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-trailing-forward-slash.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContract } from "../../parsers/contract-parser"; 2 | import { createProjectFromExistingSourceFile } from "../../spec-helpers/helper"; 3 | import { noTrailingForwardSlash } from "./no-trailing-forward-slash"; 4 | 5 | describe("no-trailing-forward-slash linter rule", () => { 6 | test("returns no violations for contract not containing a trailing forward slash", () => { 7 | const file = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/no-trailing-forward-slash/no-trailing-forward-slash.ts` 9 | ).file; 10 | 11 | const { contract } = parseContract(file).unwrapOrThrow(); 12 | 13 | expect(noTrailingForwardSlash(contract)).toHaveLength(0); 14 | }); 15 | 16 | test("returns a violation for contract containing a trailing forward slash", () => { 17 | const file = createProjectFromExistingSourceFile( 18 | `${__dirname}/__spec-examples__/no-trailing-forward-slash/trailing-forward-slash.ts` 19 | ).file; 20 | 21 | const { contract } = parseContract(file).unwrapOrThrow(); 22 | 23 | expect(noTrailingForwardSlash(contract)).toHaveLength(1); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /lib/src/linting/rules/no-trailing-forward-slash.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "../../definitions"; 2 | import { LintingRuleViolation } from "../rule"; 3 | 4 | /** 5 | * Checks that no endpoint is defined with a path that contains a trailing 6 | * forward slash. 7 | * 8 | * @param contract a contract 9 | */ 10 | export function noTrailingForwardSlash( 11 | contract: Contract 12 | ): LintingRuleViolation[] { 13 | const violations: LintingRuleViolation[] = []; 14 | 15 | contract.endpoints.forEach(endpoint => { 16 | const { path } = endpoint; 17 | 18 | if (path.match(/\/$/)) { 19 | violations.push({ 20 | message: `Endpoint (${endpoint.name} ${path}) contains a trailing forward slash` 21 | }); 22 | } 23 | }); 24 | 25 | return violations; 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/mock-server/matcher.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from "../definitions"; 2 | 3 | /** 4 | * Returns whether a given request should associated with an endpoint, as in the path and method match. 5 | * 6 | * @param req The incoming request. 7 | * @param pathPrefix The path prefix on which the API should be served (e.g. /api/v2). 8 | * @param endpoint The endpoint to match against. 9 | */ 10 | export function isRequestForEndpoint( 11 | req: { 12 | method: string; 13 | path: string; 14 | }, 15 | pathPrefix: string, 16 | endpoint: Endpoint 17 | ): boolean { 18 | const requestPath = normalisePath(req.path); 19 | if (requestPath.substr(0, pathPrefix.length) !== pathPrefix) { 20 | return false; 21 | } 22 | if (req.method.toUpperCase() !== endpoint.method) { 23 | return false; 24 | } 25 | const regexp = new RegExp( 26 | "^" + endpoint.path.replace(/:\w+/g, "[^/]+") + "$" 27 | ); 28 | return regexp.test(requestPath.substr(pathPrefix.length)); 29 | } 30 | 31 | /** 32 | * Normalises a given HTTP request path, by replacing all instances of two or more "/" in a row with a singular "/". 33 | * 34 | * @param path The path to normalise 35 | */ 36 | export function normalisePath(path: string): string { 37 | return path.replace(/[/]{2,}/g, "/"); 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/mock-server/proxy.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import http from "http"; 3 | import https from "https"; 4 | import { ProxyConfig } from "./server"; 5 | import { normalisePath } from "./matcher"; 6 | 7 | export function proxyRequest({ 8 | incomingRequest, 9 | response, 10 | proxyConfig 11 | }: { 12 | incomingRequest: Request; 13 | response: Response; 14 | proxyConfig: ProxyConfig; 15 | }): void { 16 | const requestHandler = proxyConfig.isHttps ? https : http; 17 | 18 | const options = { 19 | method: incomingRequest.method, 20 | host: proxyConfig.host, 21 | port: 22 | proxyConfig.port === null 23 | ? proxyConfig.isHttps 24 | ? 443 25 | : 80 26 | : proxyConfig.port, 27 | path: normalisePath(proxyConfig.path + incomingRequest.path), 28 | headers: { 29 | ...incomingRequest.headers, 30 | host: proxyConfig.host 31 | } 32 | }; 33 | 34 | const proxyRequest = requestHandler.request(options, res => { 35 | // Forward headers 36 | response.writeHead(res.statusCode ?? response.statusCode, res.headers); 37 | res.pipe(response); 38 | }); 39 | proxyRequest.on("error", e => { 40 | console.error(`Failed to proxy request: ${e}`, e.stack); 41 | response.statusCode = 500; 42 | response.send(); 43 | }); 44 | 45 | if (incomingRequest.body && Buffer.isBuffer(incomingRequest.body)) { 46 | proxyRequest.write(incomingRequest.body); 47 | } 48 | 49 | proxyRequest.end(); 50 | } 51 | -------------------------------------------------------------------------------- /lib/src/parser.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { CompilerOptions, Project, ts } from "ts-morph"; 3 | import { Contract } from "./definitions"; 4 | import { parseContract } from "./parsers/contract-parser"; 5 | 6 | export function parse(sourcePath: string): Contract { 7 | const project = createProject(); 8 | 9 | // Add all dependent files that the project requires 10 | const sourceFile = project.addSourceFileAtPath(sourcePath); 11 | project.resolveSourceFileDependencies(); 12 | 13 | // Validate that the project has no TypeScript syntax errors 14 | validateProject(project); 15 | 16 | const result = parseContract(sourceFile); 17 | 18 | // TODO: print human readable errors 19 | if (result.isErr()) throw result.unwrapErr(); 20 | 21 | return result.unwrap().contract; 22 | } 23 | 24 | /** 25 | * Create a new project configured for Spot 26 | */ 27 | function createProject(): Project { 28 | const compilerOptions: CompilerOptions = { 29 | target: ts.ScriptTarget.ESNext, 30 | module: ts.ModuleKind.CommonJS, 31 | strict: true, 32 | noImplicitAny: true, 33 | strictNullChecks: true, 34 | strictFunctionTypes: true, 35 | strictPropertyInitialization: true, 36 | noImplicitThis: true, 37 | resolveJsonModule: true, 38 | alwaysStrict: true, 39 | noImplicitReturns: true, 40 | noFallthroughCasesInSwitch: true, 41 | moduleResolution: ts.ModuleResolutionKind.NodeJs, 42 | experimentalDecorators: true, 43 | baseUrl: "./", 44 | paths: { 45 | "@airtasker/spot": [path.join(__dirname, "../lib")] 46 | } 47 | }; 48 | 49 | // Creates a new typescript program in memory 50 | return new Project({ compilerOptions }); 51 | } 52 | 53 | /** 54 | * Validate an AST project's correctness. 55 | * 56 | * @param project an AST project 57 | */ 58 | function validateProject(project: Project): void { 59 | const diagnostics = project.getPreEmitDiagnostics(); 60 | if (diagnostics.length > 0) { 61 | throw new Error( 62 | diagnostics 63 | .map(diagnostic => { 64 | const message = diagnostic.getMessageText(); 65 | return typeof message === "string" 66 | ? message 67 | : message.getMessageText(); 68 | }) 69 | .join("\n") 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/body.ts: -------------------------------------------------------------------------------- 1 | import { body } from "@airtasker/spot"; 2 | 3 | class BodyClass { 4 | bodyMethod( 5 | notBody: string, 6 | @body body: string, 7 | /** Body description */ 8 | @body bodyWithDescription: string, 9 | @body intersectionTypeBody: TypeAliasIntersection, 10 | @body optionalBody?: string 11 | ) {} 12 | } 13 | 14 | type TypeAliasTypeLiteral = { 15 | /** property description */ 16 | "property-with-description": string; 17 | }; 18 | 19 | type TypeAliasTypeLiteral2 = { 20 | /** property description */ 21 | "property-2-with-description": string; 22 | }; 23 | 24 | type TypeAliasIntersection = TypeAliasTypeLiteral & TypeAliasTypeLiteral2; 25 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/config.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@airtasker/spot"; 2 | 3 | class NotConfigClass {} 4 | 5 | @config({ 6 | paramSerializationStrategy: { 7 | query: { 8 | array: "comma" 9 | } 10 | } 11 | }) 12 | class ConfigClass {} 13 | 14 | @config({ 15 | paramSerializationStrategy: {} 16 | }) 17 | class MinimalConfigClass {} 18 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/contract-dependency.ts: -------------------------------------------------------------------------------- 1 | import { 2 | body, 3 | defaultResponse, 4 | endpoint, 5 | request, 6 | response 7 | } from "@airtasker/spot"; 8 | import { DefaultBody, SuccessBody } from "./contract"; 9 | 10 | @endpoint({ method: "POST", path: "/path" }) 11 | class PostEndpoint { 12 | @request 13 | request(@body body: RequestBody) {} 14 | 15 | @defaultResponse 16 | defaultResponse(@body body: DefaultBody) {} 17 | 18 | @response({ status: 200 }) 19 | successResponse(@body body: SuccessBody) {} 20 | } 21 | 22 | interface RequestBody { 23 | message: string; 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/contract.ts: -------------------------------------------------------------------------------- 1 | import { 2 | api, 3 | body, 4 | config, 5 | defaultResponse, 6 | endpoint, 7 | headers, 8 | pathParams, 9 | queryParams, 10 | request, 11 | response, 12 | securityHeader, 13 | String 14 | } from "@airtasker/spot"; 15 | import "./contract-dependency"; 16 | 17 | /** contract description */ 18 | @api({ name: "contract" }) 19 | @config({ 20 | paramSerializationStrategy: { 21 | query: { 22 | array: "comma" 23 | } 24 | } 25 | }) 26 | class Contract { 27 | @securityHeader 28 | "security-header": String; 29 | } 30 | 31 | @endpoint({ method: "GET", path: "/path/:param/nest" }) 32 | class GetEndpoint { 33 | @request 34 | request( 35 | @pathParams 36 | pathParams: { 37 | param: String; 38 | }, 39 | @headers 40 | headers: { 41 | header: String; 42 | }, 43 | @queryParams 44 | queryParams: { 45 | param: String; 46 | } 47 | ) {} 48 | 49 | @defaultResponse 50 | defaultResponse(@body body: DefaultBody) {} 51 | 52 | @response({ status: 200 }) 53 | successResponse( 54 | @headers headers: { responseHeader: String }, 55 | @body body: SuccessBody 56 | ) {} 57 | } 58 | 59 | export interface DefaultBody { 60 | message: String; 61 | } 62 | 63 | export interface SuccessBody { 64 | message: String; 65 | } 66 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/duplicate-endpoint-name-contract-dependency.ts: -------------------------------------------------------------------------------- 1 | import { endpoint } from "@airtasker/spot"; 2 | 3 | @endpoint({ method: "GET", path: "/anotherpath" }) 4 | class GetEndpoint {} 5 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/duplicate-endpoint-name-contract.ts: -------------------------------------------------------------------------------- 1 | import { api, endpoint } from "@airtasker/spot"; 2 | import "./duplicate-endpoint-name-contract-dependency"; 3 | 4 | /** contract description */ 5 | @api({ name: "contract" }) 6 | class Contract {} 7 | 8 | @endpoint({ method: "GET", path: "/path" }) 9 | class GetEndpoint {} 10 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/empty-api-name-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: " " }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/illegal-api-name-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract$!%" }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/minimal-contract.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@airtasker/spot"; 2 | 3 | @api({ name: "contract" }) 4 | class Contract {} 5 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/contracts/not-contract.ts: -------------------------------------------------------------------------------- 1 | // not contract 2 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/decorators.ts: -------------------------------------------------------------------------------- 1 | declare function decoratorPlain(target: any): void; 2 | 3 | function decoratorFactoryNotConfig(param: string) { 4 | return (target: any) => {}; 5 | } 6 | 7 | function decoratorFactoryConfig(config: Config) { 8 | return (target: any) => {}; 9 | } 10 | 11 | interface Config { 12 | testParam: string; 13 | } 14 | 15 | @decoratorPlain 16 | class DecoratorPlain {} 17 | 18 | @decoratorFactoryNotConfig("test") 19 | class DecoratorFactoryNotConfig {} 20 | 21 | @decoratorFactoryConfig({ testParam: "test" }) 22 | class DecoratorFactoryConfig {} 23 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/default-response.ts: -------------------------------------------------------------------------------- 1 | import { body, defaultResponse, headers } from "@airtasker/spot"; 2 | 3 | class DefaultResponseClass { 4 | notDefaultResponse() {} 5 | 6 | @defaultResponse 7 | parameterlessDefaultResponse() {} 8 | 9 | /** default response description */ 10 | @defaultResponse 11 | defaultResponse( 12 | @headers 13 | headers: { 14 | property: string; 15 | }, 16 | @body body: string 17 | ) {} 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | body, 3 | defaultResponse, 4 | draft, 5 | endpoint, 6 | headers, 7 | pathParams, 8 | queryParams, 9 | request, 10 | response 11 | } from "@airtasker/spot"; 12 | 13 | class NotEndpointClass {} 14 | 15 | /** endpoint description */ 16 | @endpoint({ 17 | method: "POST", 18 | path: "/path/:pathParam/nest", 19 | tags: ["tag1", "tag2"] 20 | }) 21 | class EndpointClass { 22 | @request 23 | request( 24 | @headers 25 | headers: { 26 | property: string; 27 | }, 28 | @pathParams 29 | pathParams: { 30 | pathParam: string; 31 | }, 32 | @queryParams 33 | queryParams: { 34 | property: string; 35 | }, 36 | @body 37 | body: string 38 | ) {} 39 | 40 | @defaultResponse 41 | defaultResponse( 42 | @headers 43 | headers: { 44 | property: string; 45 | }, 46 | @body body: string 47 | ) {} 48 | 49 | @response({ status: 200 }) 50 | response( 51 | @headers 52 | headers: { 53 | property: string; 54 | }, 55 | @body body: string 56 | ) {} 57 | } 58 | 59 | @endpoint({ 60 | method: "GET", 61 | path: "/path" 62 | }) 63 | class MinimalEndpointClass {} 64 | 65 | @draft 66 | @endpoint({ 67 | method: "GET", 68 | path: "/path" 69 | }) 70 | class DraftEndpointClass {} 71 | 72 | @endpoint({ 73 | method: "GET", 74 | path: "/path", 75 | tags: [" "] 76 | }) 77 | class EndpointWithEmptyTag {} 78 | 79 | @endpoint({ 80 | method: "GET", 81 | path: "/path", 82 | tags: ["tag", "tag"] 83 | }) 84 | class EndpointWithDuplicateTag {} 85 | 86 | @endpoint({ 87 | method: "GET", 88 | path: "/path/:dynamic/path/:dynamic" 89 | }) 90 | class EndpointWithDuplicateDynamicPathComponent {} 91 | 92 | @endpoint({ 93 | method: "GET", 94 | path: "/path/:dynamic/path/:nested" 95 | }) 96 | class EndpointWithMissingPathParam { 97 | @request 98 | request( 99 | @pathParams 100 | pathParams: { 101 | dynamic: string; 102 | } 103 | ) {} 104 | } 105 | 106 | @endpoint({ 107 | method: "GET", 108 | path: "/path/:dynamic" 109 | }) 110 | class EndpointWithExtraPathParam { 111 | @request 112 | request( 113 | @pathParams 114 | pathParams: { 115 | dynamic: string; 116 | nested: string; 117 | } 118 | ) {} 119 | } 120 | 121 | @endpoint({ 122 | method: "GET", 123 | path: "/path" 124 | }) 125 | class EndpointWithDuplicateResponseStatus { 126 | @response({ 127 | status: 200 128 | }) 129 | responseOne() {} 130 | 131 | @response({ 132 | status: 200 133 | }) 134 | responseTwo() {} 135 | } 136 | 137 | /** 138 | * My description 139 | * 140 | * @summary 141 | * My summary 142 | */ 143 | @endpoint({ 144 | method: "GET", 145 | path: "/path" 146 | }) 147 | class MinimalEndpointWithSummaryClass {} 148 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/examples.ts: -------------------------------------------------------------------------------- 1 | import { Integer } from "../../syntax"; 2 | 3 | type ExampleTests = { 4 | /** property-example description 5 | * @example property-example 6 | * "property-example-value" 7 | * */ 8 | "property-with-example": string; 9 | /** property-two-examples description 10 | * @example property-example-one 11 | * 123 12 | * @example property-example-two 13 | * 456 14 | * */ 15 | "property-with-examples": Integer; 16 | /** property-example description 17 | * @example property-example 18 | * false 19 | * */ 20 | "property-with-boolean": boolean; 21 | /** 22 | * @example name 23 | * This_is_not_an_integer 24 | */ 25 | "property-with-mistyped-example": Integer; 26 | /** 27 | * @example name 28 | * This_is_not_a_string_in_quotes 29 | */ 30 | "property-with-no-string-in-quotes": string; 31 | }; 32 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/headers.ts: -------------------------------------------------------------------------------- 1 | import { headers, Int64, Integer } from "@airtasker/spot"; 2 | 3 | class HeadersClass { 4 | headersMethod( 5 | notHeaders: { 6 | property: string; 7 | }, 8 | @headers 9 | headers: { 10 | property: string; 11 | /** property description */ 12 | "property-with-description": string; 13 | /** property-example description 14 | * @example property-example 15 | * "property-example-value" 16 | * */ 17 | "property-with-example": string; 18 | /** property-two-examples description 19 | * @example property-example-one 20 | * 123 21 | * @example property-example-two 22 | * 456 23 | * */ 24 | "property-with-examples": Integer; 25 | optionalProperty?: Int64; 26 | }, 27 | @headers 28 | interfaceHeaders: IHeader, 29 | @headers 30 | typeAliasTypeLiteralHeaders: TypeAliasTypeLiteral, 31 | @headers 32 | typeAliasTypeReferenceHeaders: TypeAliasTypeReference, 33 | @headers 34 | nonObjectHeaders: string, 35 | @headers 36 | headersWithIllegalPropertyName: { 37 | "illegal-field-name-header%$": string; 38 | }, 39 | @headers 40 | headersWithEmptyPropertyName: { 41 | "": string; 42 | }, 43 | @headers 44 | headersWithIllegalType: { 45 | property: boolean; 46 | }, 47 | @headers 48 | optionalHeaders?: { 49 | property: string; 50 | } 51 | ) {} 52 | } 53 | 54 | interface IHeader { 55 | /** property description */ 56 | "property-with-description": string; 57 | } 58 | 59 | type TypeAliasTypeLiteral = { 60 | /** property description */ 61 | "property-with-description": string; 62 | }; 63 | 64 | type TypeAliasTypeReference = IHeader; 65 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/query-params.ts: -------------------------------------------------------------------------------- 1 | import { Integer, queryParams } from "@airtasker/spot"; 2 | 3 | class QueryParamsClass { 4 | queryParamsMethod( 5 | notQueryParams: { 6 | property: string; 7 | }, 8 | @queryParams 9 | queryParams: { 10 | property: string; 11 | /** property description */ 12 | "property-with-description": string; 13 | /** property-example description 14 | * @example property-example 15 | * "property-example-value" 16 | * */ 17 | "property-with-example": string; 18 | /** property-two-examples description 19 | * @example property-example-one 20 | * 123 21 | * @example property-example-two 22 | * 456 23 | * */ 24 | "property-with-examples": Integer; 25 | optionalProperty?: string; 26 | objectProperty: { 27 | objectProp: string; 28 | }; 29 | arrayProperty: string[]; 30 | "property.with.dots": string; 31 | }, 32 | @queryParams 33 | interfaceQueryParams: IQueryParams, 34 | @queryParams 35 | typeAliasTypeLiteralQueryParams: TypeAliasTypeLiteral, 36 | @queryParams 37 | typeAliasTypeReferenceQueryParams: TypeAliasTypeReference, 38 | @queryParams 39 | nonObjectQueryParams: string, 40 | @queryParams 41 | queryParamsWithIllegalPropertyName: { 42 | "illegal-property-name-%$": string; 43 | }, 44 | @queryParams 45 | queryParamsWithEmptyPropertyName: { 46 | "": string; 47 | }, 48 | @queryParams 49 | queryParamsWithIllegalPropertyType: { 50 | property: null; 51 | }, 52 | @queryParams 53 | queryParamsWithIllegalPropertyArrayType: { 54 | property: null[]; 55 | }, 56 | @queryParams 57 | queryParamsWithIllegalPropertyObjectType: { 58 | property: { 59 | illegalNesting: { 60 | property: string; 61 | }; 62 | }; 63 | }, 64 | @queryParams 65 | optionalQueryParams?: { 66 | property: string; 67 | } 68 | ) {} 69 | } 70 | 71 | interface IQueryParams { 72 | /** property description */ 73 | "property-with-description": string; 74 | } 75 | 76 | type TypeAliasTypeLiteral = { 77 | /** property description */ 78 | "property-with-description": string; 79 | }; 80 | 81 | type TypeAliasTypeReference = IQueryParams; 82 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/recursive-imports/import-1-1-1.ts: -------------------------------------------------------------------------------- 1 | export class Import111 {} 2 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/recursive-imports/import-1-1.ts: -------------------------------------------------------------------------------- 1 | import "./import-1-1-1"; 2 | 3 | class Import11 {} 4 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/recursive-imports/import-1-2.ts: -------------------------------------------------------------------------------- 1 | export class Import12 {} 2 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/recursive-imports/import-1.ts: -------------------------------------------------------------------------------- 1 | import "./import-1-1"; 2 | import "./import-1-2"; 3 | 4 | class Import1 {} 5 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/recursive-imports/import-2.ts: -------------------------------------------------------------------------------- 1 | export class Import2 {} 2 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/recursive-imports/source.ts: -------------------------------------------------------------------------------- 1 | import "path"; 2 | import "./import-1"; 3 | import "./import-2"; 4 | 5 | class Source {} 6 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/request.ts: -------------------------------------------------------------------------------- 1 | import { 2 | body, 3 | headers, 4 | pathParams, 5 | queryParams, 6 | request 7 | } from "@airtasker/spot"; 8 | 9 | class RequestClass { 10 | notRequest() {} 11 | 12 | @request 13 | parameterlessRequest() {} 14 | 15 | @request 16 | request( 17 | @headers 18 | headers: { 19 | property: string; 20 | }, 21 | @pathParams 22 | pathParams: { 23 | property: string; 24 | }, 25 | @queryParams 26 | queryParams: { 27 | property: string; 28 | }, 29 | @body body: string 30 | ) {} 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/response.ts: -------------------------------------------------------------------------------- 1 | import { body, headers, response } from "@airtasker/spot"; 2 | 3 | class ResponseClass { 4 | notResponse() {} 5 | 6 | @response({ status: 200 }) 7 | parameterlessResponse() {} 8 | 9 | /** response description */ 10 | @response({ status: 200 }) 11 | response( 12 | @headers 13 | headers: { 14 | property: string; 15 | }, 16 | @body body: string 17 | ) {} 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/schemaprops.ts: -------------------------------------------------------------------------------- 1 | import { Date, Integer, String } from "../../syntax"; 2 | 3 | type SchemaPropTests = { 4 | /** property-schemaprop description 5 | * @oaSchemaProp pattern 6 | * "property-schemaprop-value" 7 | * */ 8 | "property-with-schemaprop": string; 9 | /** 10 | * @oaSchemaProp example 11 | * "123.3" 12 | */ 13 | "property-with-string": String; 14 | /** property-two-schemaprops description 15 | * @oaSchemaProp minimum 16 | * 123 17 | * @default 456 18 | * */ 19 | "property-with-schemaprops": Integer; 20 | /** property-schemaprop description 21 | * @oaSchemaProp example 22 | * false 23 | * */ 24 | "property-with-boolean": boolean; 25 | /** property-schemaprop date 26 | * @oaSchemaProp example 27 | * "1990-12-31" 28 | * */ 29 | "property-with-date": Date; 30 | /** property-schemaprop array of integer 31 | * @oaSchemaProp example 32 | * [1990,12,31] 33 | * */ 34 | "property-with-array": Integer[]; 35 | /** 36 | * @oaSchemaProp example 37 | * This_is_not_an_integer 38 | */ 39 | "property-with-mistyped-schemaprop": Integer; 40 | /** 41 | * @oaSchemaProp example 42 | * This_is_not_a_string_in_quotes 43 | */ 44 | "property-with-no-string-in-quotes": string; 45 | }; 46 | -------------------------------------------------------------------------------- /lib/src/parsers/__spec-examples__/security-header.ts: -------------------------------------------------------------------------------- 1 | import { securityHeader } from "@airtasker/spot"; 2 | 3 | class SecurityHeaderClass { 4 | "not-security-header": string; 5 | 6 | /** security header description */ 7 | @securityHeader 8 | "security-header": string; 9 | 10 | @securityHeader 11 | "optional-security-header"?: string; 12 | 13 | @securityHeader 14 | "illegal-field-name-security-header%$": string; 15 | 16 | @securityHeader 17 | "": string; 18 | 19 | @securityHeader 20 | "not-string-security-header": number; 21 | } 22 | -------------------------------------------------------------------------------- /lib/src/parsers/body-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { OptionalNotAllowedError } from "../errors"; 2 | import { LociTable } from "../locations"; 3 | import { createProjectFromExistingSourceFile } from "../spec-helpers/helper"; 4 | import { TypeKind, TypeTable } from "../types"; 5 | import { parseBody } from "./body-parser"; 6 | 7 | describe("body parser", () => { 8 | const exampleFile = createProjectFromExistingSourceFile( 9 | `${__dirname}/__spec-examples__/body.ts` 10 | ).file; 11 | const method = exampleFile 12 | .getClassOrThrow("BodyClass") 13 | .getMethodOrThrow("bodyMethod"); 14 | 15 | let typeTable: TypeTable; 16 | let lociTable: LociTable; 17 | 18 | beforeEach(() => { 19 | typeTable = new TypeTable(); 20 | lociTable = new LociTable(); 21 | }); 22 | 23 | test("parses @body decorated parameter", () => { 24 | const result = parseBody( 25 | method.getParameterOrThrow("body"), 26 | typeTable, 27 | lociTable 28 | ).unwrapOrThrow(); 29 | 30 | expect(result).toStrictEqual({ 31 | type: { 32 | kind: TypeKind.STRING 33 | } 34 | }); 35 | }); 36 | 37 | test("parses @body decorated parameter with description", () => { 38 | const result = parseBody( 39 | method.getParameterOrThrow("bodyWithDescription"), 40 | typeTable, 41 | lociTable 42 | ).unwrapOrThrow(); 43 | 44 | expect(result).toStrictEqual({ 45 | type: { 46 | kind: TypeKind.STRING 47 | } 48 | }); 49 | }); 50 | 51 | test("parses @body decorated parameter with intersection type", () => { 52 | const result = parseBody( 53 | method.getParameterOrThrow("intersectionTypeBody"), 54 | typeTable, 55 | lociTable 56 | ).unwrapOrThrow(); 57 | 58 | expect(result).toStrictEqual({ 59 | type: { 60 | kind: "reference", 61 | name: "TypeAliasIntersection" 62 | } 63 | }); 64 | }); 65 | 66 | test("fails to parse optional @body decorated parameter", () => { 67 | expect( 68 | parseBody( 69 | method.getParameterOrThrow("optionalBody"), 70 | typeTable, 71 | lociTable 72 | ).unwrapErrOrThrow() 73 | ).toBeInstanceOf(OptionalNotAllowedError); 74 | }); 75 | 76 | test("fails to parse non-@body decorated parameter", () => { 77 | expect(() => 78 | parseBody(method.getParameterOrThrow("notBody"), typeTable, lociTable) 79 | ).toThrow("Expected to find decorator named 'body'"); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /lib/src/parsers/body-parser.ts: -------------------------------------------------------------------------------- 1 | import { ParameterDeclaration } from "ts-morph"; 2 | import { Body } from "../definitions"; 3 | import { OptionalNotAllowedError, ParserError } from "../errors"; 4 | import { LociTable } from "../locations"; 5 | import { TypeTable } from "../types"; 6 | import { err, ok, Result } from "../util"; 7 | import { parseType } from "./type-parser"; 8 | 9 | export function parseBody( 10 | parameter: ParameterDeclaration, 11 | typeTable: TypeTable, 12 | lociTable: LociTable 13 | ): Result { 14 | // TODO: retrieve JsDoc as body description https://github.com/dsherret/ts-morph/issues/753 15 | parameter.getDecoratorOrThrow("body"); 16 | if (parameter.hasQuestionToken()) { 17 | return err( 18 | new OptionalNotAllowedError("@body parameter cannot be optional", { 19 | file: parameter.getSourceFile().getFilePath(), 20 | position: parameter.getQuestionTokenNodeOrThrow().getPos() 21 | }) 22 | ); 23 | } 24 | const typeResult = parseType( 25 | parameter.getTypeNodeOrThrow(), 26 | typeTable, 27 | lociTable 28 | ); 29 | if (typeResult.isErr()) return typeResult; 30 | // TODO: add loci information 31 | return ok({ type: typeResult.unwrap() }); 32 | } 33 | -------------------------------------------------------------------------------- /lib/src/parsers/config-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { createProjectFromExistingSourceFile } from "../spec-helpers/helper"; 2 | import { parseConfig } from "./config-parser"; 3 | 4 | describe("config parser", () => { 5 | const exampleFile = createProjectFromExistingSourceFile( 6 | `${__dirname}/__spec-examples__/config.ts` 7 | ).file; 8 | 9 | test("parses @config decorated class", () => { 10 | const result = parseConfig( 11 | exampleFile.getClassOrThrow("ConfigClass") 12 | ).unwrapOrThrow(); 13 | 14 | expect(result).toStrictEqual({ 15 | paramSerializationStrategy: { 16 | query: { 17 | array: "comma" 18 | } 19 | } 20 | }); 21 | }); 22 | 23 | test("parses minimal @config decorated class", () => { 24 | const result = parseConfig( 25 | exampleFile.getClassOrThrow("MinimalConfigClass") 26 | ).unwrapOrThrow(); 27 | 28 | expect(result).toStrictEqual({ 29 | paramSerializationStrategy: { query: { array: "ampersand" } } 30 | }); 31 | }); 32 | 33 | test("fails to parse non-@config decorated class", () => { 34 | expect(() => 35 | parseConfig(exampleFile.getClassOrThrow("NotConfigClass")) 36 | ).toThrow("Expected to find decorator named 'config'"); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /lib/src/parsers/config-parser.ts: -------------------------------------------------------------------------------- 1 | import { ClassDeclaration } from "ts-morph"; 2 | import { Config } from "../definitions"; 3 | import { ParserError } from "../errors"; 4 | import { ConfigConfig } from "../syntax"; 5 | import { ok, Result } from "../util"; 6 | import { 7 | getDecoratorConfigOrThrow, 8 | getObjLiteralProp, 9 | getObjLiteralPropOrThrow, 10 | getPropValueAsObjectOrThrow, 11 | getPropValueAsStringOrThrow, 12 | isQueryParamArrayStrategy 13 | } from "./parser-helpers"; 14 | 15 | export function parseConfig( 16 | klass: ClassDeclaration 17 | ): Result { 18 | const decorator = klass.getDecoratorOrThrow("config"); 19 | const decoratorConfig = getDecoratorConfigOrThrow(decorator); 20 | 21 | const paramStratProp = getObjLiteralPropOrThrow( 22 | decoratorConfig, 23 | "paramSerializationStrategy" 24 | ); 25 | 26 | const paramsStrat: Config = defaultConfig(); 27 | 28 | const paramStratLiteral = getPropValueAsObjectOrThrow(paramStratProp); 29 | const queryStrategyProp = getObjLiteralProp< 30 | ConfigConfig["paramSerializationStrategy"] 31 | >(paramStratLiteral, "query"); 32 | 33 | if (queryStrategyProp) { 34 | const queryStratLiteral = getPropValueAsObjectOrThrow(queryStrategyProp); 35 | const queryArrayStratProp = getObjLiteralProp< 36 | Required["query"] 37 | >(queryStratLiteral, "array"); 38 | 39 | if (queryArrayStratProp) { 40 | const queryArrayStratValue = 41 | getPropValueAsStringOrThrow(queryArrayStratProp).getLiteralText(); 42 | if (!isQueryParamArrayStrategy(queryArrayStratValue)) { 43 | throw new Error( 44 | `expected a QueryParamArrayStrategy, got ${queryArrayStratValue}` 45 | ); 46 | } 47 | paramsStrat.paramSerializationStrategy.query.array = queryArrayStratValue; 48 | } 49 | } 50 | 51 | return ok(paramsStrat); 52 | } 53 | 54 | export function defaultConfig(): Config { 55 | return { 56 | paramSerializationStrategy: { 57 | query: { 58 | array: "ampersand" 59 | } 60 | } 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/parsers/default-response-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { LociTable } from "../locations"; 2 | import { createProjectFromExistingSourceFile } from "../spec-helpers/helper"; 3 | import { TypeKind, TypeTable } from "../types"; 4 | import { parseDefaultResponse } from "./default-response-parser"; 5 | 6 | describe("default response parser", () => { 7 | const exampleFile = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/default-response.ts` 9 | ).file; 10 | const klass = exampleFile.getClassOrThrow("DefaultResponseClass"); 11 | 12 | let typeTable: TypeTable; 13 | let lociTable: LociTable; 14 | 15 | beforeEach(() => { 16 | typeTable = new TypeTable(); 17 | lociTable = new LociTable(); 18 | }); 19 | 20 | test("parses @defaultResponse decorated method", () => { 21 | const result = parseDefaultResponse( 22 | klass.getMethodOrThrow("defaultResponse"), 23 | typeTable, 24 | lociTable 25 | ).unwrapOrThrow(); 26 | 27 | expect(result).toStrictEqual({ 28 | body: { 29 | type: { kind: TypeKind.STRING } 30 | }, 31 | description: "default response description", 32 | headers: [ 33 | { 34 | description: undefined, 35 | examples: undefined, 36 | name: "property", 37 | optional: false, 38 | type: { kind: TypeKind.STRING, schemaProps: undefined } 39 | } 40 | ] 41 | }); 42 | }); 43 | 44 | test("parses parameterless @defaultResponse decorated method", () => { 45 | const result = parseDefaultResponse( 46 | klass.getMethodOrThrow("parameterlessDefaultResponse"), 47 | typeTable, 48 | lociTable 49 | ).unwrapOrThrow(); 50 | 51 | expect(result).toStrictEqual({ 52 | body: undefined, 53 | description: undefined, 54 | headers: [] 55 | }); 56 | }); 57 | 58 | test("fails to parse non-@defaultResponse decorated method", () => { 59 | expect(() => 60 | parseDefaultResponse( 61 | klass.getMethodOrThrow("notDefaultResponse"), 62 | typeTable, 63 | lociTable 64 | ) 65 | ).toThrow("Expected to find decorator named 'defaultResponse'"); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /lib/src/parsers/default-response-parser.ts: -------------------------------------------------------------------------------- 1 | import { MethodDeclaration } from "ts-morph"; 2 | import { DefaultResponse } from "../definitions"; 3 | import { ParserError } from "../errors"; 4 | import { LociTable } from "../locations"; 5 | import { TypeTable } from "../types"; 6 | import { ok, Result } from "../util"; 7 | import { parseBody } from "./body-parser"; 8 | import { parseHeaders } from "./headers-parser"; 9 | import { getJsDoc, getParamWithDecorator } from "./parser-helpers"; 10 | 11 | export function parseDefaultResponse( 12 | method: MethodDeclaration, 13 | typeTable: TypeTable, 14 | lociTable: LociTable 15 | ): Result { 16 | method.getDecoratorOrThrow("defaultResponse"); 17 | const headersParam = getParamWithDecorator(method, "headers"); 18 | const bodyParam = getParamWithDecorator(method, "body"); 19 | 20 | const headers = []; 21 | if (headersParam) { 22 | const headersResult = parseHeaders(headersParam, typeTable, lociTable); 23 | if (headersResult.isErr()) return headersResult; 24 | headers.push(...headersResult.unwrap()); 25 | } 26 | 27 | let body; 28 | if (bodyParam) { 29 | const bodyResult = parseBody(bodyParam, typeTable, lociTable); 30 | if (bodyResult.isErr()) return bodyResult; 31 | body = bodyResult.unwrap(); 32 | } 33 | 34 | return ok({ 35 | headers, 36 | description: getJsDoc(method)?.getDescription().trim(), 37 | body 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /lib/src/parsers/parser-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { createProjectFromExistingSourceFile } from "../spec-helpers/helper"; 2 | import { 3 | getDecoratorConfigOrThrow, 4 | getSelfAndLocalDependencies 5 | } from "./parser-helpers"; 6 | 7 | describe("parser-helpers", () => { 8 | describe("getSelfAndLocalDependencies", () => { 9 | test("resolves all local imports recursively", () => { 10 | const sourceFile = createProjectFromExistingSourceFile( 11 | `${__dirname}/__spec-examples__/recursive-imports/source.ts` 12 | ).file; 13 | const allFiles = getSelfAndLocalDependencies(sourceFile); 14 | const allFileNames = allFiles.map(f => f.getBaseNameWithoutExtension()); 15 | 16 | expect(allFileNames).toHaveLength(6); 17 | expect(allFileNames).toContain("source"); 18 | expect(allFileNames).toContain("import-1"); 19 | expect(allFileNames).toContain("import-2"); 20 | expect(allFileNames).toContain("import-1-1"); 21 | expect(allFileNames).toContain("import-1-2"); 22 | expect(allFileNames).toContain("import-1-1-1"); 23 | }); 24 | }); 25 | 26 | describe("getDecoratorConfigOrThrow", () => { 27 | const sourceFile = createProjectFromExistingSourceFile( 28 | `${__dirname}/__spec-examples__/decorators.ts` 29 | ).file; 30 | 31 | test("returns the first argument of a decorator factory that conforms to configuration", () => { 32 | const klass = sourceFile.getClassOrThrow("DecoratorFactoryConfig"); 33 | const decorator = klass.getDecoratorOrThrow("decoratorFactoryConfig"); 34 | expect(() => getDecoratorConfigOrThrow(decorator)).not.toThrow(); 35 | }); 36 | 37 | test("throws when given a decorator factory that does not conform to configuration", () => { 38 | const klass = sourceFile.getClassOrThrow("DecoratorFactoryNotConfig"); 39 | const decorator = klass.getDecoratorOrThrow("decoratorFactoryNotConfig"); 40 | expect(() => getDecoratorConfigOrThrow(decorator)).toThrow(); 41 | }); 42 | 43 | test("throws when given a plain decorator", () => { 44 | const klass = sourceFile.getClassOrThrow("DecoratorPlain"); 45 | const decorator = klass.getDecoratorOrThrow("decoratorPlain"); 46 | expect(() => getDecoratorConfigOrThrow(decorator)).toThrow(); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /lib/src/parsers/request-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { LociTable } from "../locations"; 2 | import { createProjectFromExistingSourceFile } from "../spec-helpers/helper"; 3 | import { TypeKind, TypeTable } from "../types"; 4 | import { parseRequest } from "./request-parser"; 5 | 6 | describe("request parser", () => { 7 | const exampleFile = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/request.ts` 9 | ).file; 10 | const klass = exampleFile.getClassOrThrow("RequestClass"); 11 | 12 | let typeTable: TypeTable; 13 | let lociTable: LociTable; 14 | 15 | beforeEach(() => { 16 | typeTable = new TypeTable(); 17 | lociTable = new LociTable(); 18 | }); 19 | 20 | test("parses @request decorated method", () => { 21 | const result = parseRequest( 22 | klass.getMethodOrThrow("request"), 23 | typeTable, 24 | lociTable 25 | ).unwrapOrThrow(); 26 | 27 | expect(result).toStrictEqual({ 28 | body: { 29 | type: { kind: TypeKind.STRING } 30 | }, 31 | headers: [ 32 | { 33 | description: undefined, 34 | examples: undefined, 35 | name: "property", 36 | optional: false, 37 | type: { kind: TypeKind.STRING, schemaProps: undefined } 38 | } 39 | ], 40 | pathParams: [ 41 | { 42 | description: undefined, 43 | examples: undefined, 44 | name: "property", 45 | type: { kind: TypeKind.STRING, schemaProps: undefined } 46 | } 47 | ], 48 | queryParams: [ 49 | { 50 | description: undefined, 51 | examples: undefined, 52 | name: "property", 53 | optional: false, 54 | type: { kind: TypeKind.STRING, schemaProps: undefined } 55 | } 56 | ] 57 | }); 58 | }); 59 | 60 | test("parses parameterless @request decorated method", () => { 61 | const result = parseRequest( 62 | klass.getMethodOrThrow("parameterlessRequest"), 63 | typeTable, 64 | lociTable 65 | ).unwrapOrThrow(); 66 | 67 | expect(result).toStrictEqual({ 68 | body: undefined, 69 | headers: [], 70 | pathParams: [], 71 | queryParams: [] 72 | }); 73 | }); 74 | 75 | test("fails to parse non-@request decorated method", () => { 76 | expect(() => 77 | parseRequest(klass.getMethodOrThrow("notRequest"), typeTable, lociTable) 78 | ).toThrow("Expected to find decorator named 'request'"); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /lib/src/parsers/request-parser.ts: -------------------------------------------------------------------------------- 1 | import { MethodDeclaration } from "ts-morph"; 2 | import { Request } from "../definitions"; 3 | import { ParserError } from "../errors"; 4 | import { LociTable } from "../locations"; 5 | import { TypeTable } from "../types"; 6 | import { ok, Result } from "../util"; 7 | import { parseBody } from "./body-parser"; 8 | import { parseHeaders } from "./headers-parser"; 9 | import { getParamWithDecorator } from "./parser-helpers"; 10 | import { parsePathParams } from "./path-params-parser"; 11 | import { parseQueryParams } from "./query-params-parser"; 12 | 13 | export function parseRequest( 14 | method: MethodDeclaration, 15 | typeTable: TypeTable, 16 | lociTable: LociTable 17 | ): Result { 18 | method.getDecoratorOrThrow("request"); 19 | const headersParam = getParamWithDecorator(method, "headers"); 20 | const pathParamsParam = getParamWithDecorator(method, "pathParams"); 21 | const queryParamsParam = getParamWithDecorator(method, "queryParams"); 22 | const bodyParam = getParamWithDecorator(method, "body"); 23 | 24 | const headers = []; 25 | if (headersParam) { 26 | const headersResult = parseHeaders(headersParam, typeTable, lociTable); 27 | if (headersResult.isErr()) return headersResult; 28 | headers.push(...headersResult.unwrap()); 29 | } 30 | 31 | const pathParams = []; 32 | if (pathParamsParam) { 33 | const pathParamsResult = parsePathParams( 34 | pathParamsParam, 35 | typeTable, 36 | lociTable 37 | ); 38 | if (pathParamsResult.isErr()) return pathParamsResult; 39 | pathParams.push(...pathParamsResult.unwrap()); 40 | } 41 | 42 | const queryParams = []; 43 | if (queryParamsParam) { 44 | const queryParamsResult = parseQueryParams( 45 | queryParamsParam, 46 | typeTable, 47 | lociTable 48 | ); 49 | if (queryParamsResult.isErr()) return queryParamsResult; 50 | queryParams.push(...queryParamsResult.unwrap()); 51 | } 52 | 53 | let body; 54 | if (bodyParam) { 55 | const bodyResult = parseBody(bodyParam, typeTable, lociTable); 56 | if (bodyResult.isErr()) return bodyResult; 57 | body = bodyResult.unwrap(); 58 | } 59 | 60 | return ok({ 61 | headers, 62 | pathParams, 63 | queryParams, 64 | body 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /lib/src/parsers/response-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { LociTable } from "../locations"; 2 | import { createProjectFromExistingSourceFile } from "../spec-helpers/helper"; 3 | import { TypeKind, TypeTable } from "../types"; 4 | import { parseResponse } from "./response-parser"; 5 | 6 | describe("response parser", () => { 7 | const exampleFile = createProjectFromExistingSourceFile( 8 | `${__dirname}/__spec-examples__/response.ts` 9 | ).file; 10 | const klass = exampleFile.getClassOrThrow("ResponseClass"); 11 | 12 | let typeTable: TypeTable; 13 | let lociTable: LociTable; 14 | 15 | beforeEach(() => { 16 | typeTable = new TypeTable(); 17 | lociTable = new LociTable(); 18 | }); 19 | 20 | test("parses @response decorated method", () => { 21 | const result = parseResponse( 22 | klass.getMethodOrThrow("response"), 23 | typeTable, 24 | lociTable 25 | ).unwrapOrThrow(); 26 | 27 | expect(result).toStrictEqual({ 28 | body: { 29 | type: { kind: TypeKind.STRING } 30 | }, 31 | description: "response description", 32 | headers: [ 33 | { 34 | description: undefined, 35 | examples: undefined, 36 | name: "property", 37 | optional: false, 38 | type: { kind: TypeKind.STRING, schemaProps: undefined } 39 | } 40 | ], 41 | status: 200 42 | }); 43 | }); 44 | 45 | test("parses parameterless @response decorated method", () => { 46 | const result = parseResponse( 47 | klass.getMethodOrThrow("parameterlessResponse"), 48 | typeTable, 49 | lociTable 50 | ).unwrapOrThrow(); 51 | 52 | expect(result).toStrictEqual({ 53 | body: undefined, 54 | description: undefined, 55 | headers: [], 56 | status: 200 57 | }); 58 | }); 59 | 60 | test("fails to parse non-@response decorated method", () => { 61 | expect(() => 62 | parseResponse(klass.getMethodOrThrow("notResponse"), typeTable, lociTable) 63 | ).toThrow("Expected to find decorator named 'response'"); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /lib/src/parsers/response-parser.ts: -------------------------------------------------------------------------------- 1 | import { MethodDeclaration } from "ts-morph"; 2 | import { Response } from "../definitions"; 3 | import { ParserError } from "../errors"; 4 | import { LociTable } from "../locations"; 5 | import { ResponseConfig } from "../syntax/response"; 6 | import { TypeTable } from "../types"; 7 | import { ok, Result } from "../util"; 8 | import { parseBody } from "./body-parser"; 9 | import { parseHeaders } from "./headers-parser"; 10 | import { 11 | getDecoratorConfigOrThrow, 12 | getJsDoc, 13 | getObjLiteralPropOrThrow, 14 | getParamWithDecorator, 15 | getPropValueAsNumberOrThrow 16 | } from "./parser-helpers"; 17 | 18 | export function parseResponse( 19 | method: MethodDeclaration, 20 | typeTable: TypeTable, 21 | lociTable: LociTable 22 | ): Result { 23 | const decorator = method.getDecoratorOrThrow("response"); 24 | const decoratorConfig = getDecoratorConfigOrThrow(decorator); 25 | const statusProp = getObjLiteralPropOrThrow( 26 | decoratorConfig, 27 | "status" 28 | ); 29 | const statusLiteral = getPropValueAsNumberOrThrow(statusProp); 30 | const headersParam = getParamWithDecorator(method, "headers"); 31 | const bodyParam = getParamWithDecorator(method, "body"); 32 | 33 | const headers = []; 34 | if (headersParam) { 35 | const headersResult = parseHeaders(headersParam, typeTable, lociTable); 36 | if (headersResult.isErr()) return headersResult; 37 | headers.push(...headersResult.unwrap()); 38 | } 39 | 40 | let body; 41 | if (bodyParam) { 42 | const bodyResult = parseBody(bodyParam, typeTable, lociTable); 43 | if (bodyResult.isErr()) return bodyResult; 44 | body = bodyResult.unwrap(); 45 | } 46 | 47 | return ok({ 48 | status: statusLiteral.getLiteralValue(), 49 | headers, 50 | description: getJsDoc(method)?.getDescription().trim(), 51 | body 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /lib/src/spec-helpers/helper.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { Project, SourceFile, ts } from "ts-morph"; 3 | 4 | /** 5 | * Create an AST source file. Any files imported from the main file must also be provided. 6 | * All files will be loaded into a virtual filesystem under a `test/` directory. 7 | * 8 | * @param mainFile details for main file 9 | * @param referencedContent details for referenced files 10 | * @returns the main source file 11 | */ 12 | export function createSourceFile( 13 | mainFile: FileDetail, 14 | ...referencedFiles: FileDetail[] 15 | ): SourceFile { 16 | const project = createProject(); 17 | referencedFiles.forEach(fileDetail => { 18 | project.createSourceFile(`test/${fileDetail.path}.ts`, fileDetail.content); 19 | }); 20 | const mainSource = project.createSourceFile( 21 | `test/${mainFile.path}.ts`, 22 | mainFile.content 23 | ); 24 | 25 | validateProject(project); 26 | 27 | return mainSource; 28 | } 29 | 30 | interface FileDetail { 31 | /** File path */ 32 | path: string; 33 | /** File content */ 34 | content: string; 35 | } 36 | 37 | /** 38 | * Create an AST project with the `@airtasker/spot` dependency loaded. 39 | */ 40 | export function createProject(): Project { 41 | return new Project({ 42 | compilerOptions: { 43 | target: ts.ScriptTarget.ESNext, 44 | module: ts.ModuleKind.CommonJS, 45 | strict: true, 46 | noImplicitAny: true, 47 | strictNullChecks: true, 48 | strictFunctionTypes: true, 49 | strictPropertyInitialization: true, 50 | noImplicitThis: true, 51 | alwaysStrict: true, 52 | noImplicitReturns: true, 53 | noFallthroughCasesInSwitch: true, 54 | moduleResolution: ts.ModuleResolutionKind.NodeJs, 55 | experimentalDecorators: true, 56 | baseUrl: "./", 57 | paths: { 58 | "@airtasker/spot": [path.join(__dirname, "../lib")] 59 | } 60 | } 61 | }); 62 | } 63 | 64 | export function createProjectFromExistingSourceFile(filePath: string): { 65 | project: Project; 66 | file: SourceFile; 67 | } { 68 | const project = createProject(); 69 | const file = project.addSourceFileAtPath(filePath); 70 | project.resolveSourceFileDependencies(); 71 | validateProject(project); 72 | return { project, file }; 73 | } 74 | 75 | /** 76 | * Validate an AST project's correctness. 77 | * 78 | * @param project an AST project 79 | */ 80 | export function validateProject(project: Project): void { 81 | const diagnostics = project.getPreEmitDiagnostics(); 82 | if (diagnostics.length > 0) { 83 | throw new Error( 84 | diagnostics 85 | .map(diagnostic => { 86 | const message = diagnostic.getMessageText(); 87 | return typeof message === "string" 88 | ? message 89 | : message.getMessageText(); 90 | }) 91 | .join("\n") 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/src/syntax/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class decorator factory for describing an API. 3 | * 4 | * ```ts 5 | * @api({ name: "Company API" }) 6 | * class CompanyApi {} 7 | * ``` 8 | * 9 | * @param config configuration 10 | */ 11 | export function api(config: ApiConfig) { 12 | return (target: any) => {}; 13 | } 14 | 15 | export interface ApiConfig { 16 | /** Name of the API. This should be the name of the service that is being documented */ 17 | name: string; 18 | /** Version of this document */ 19 | version?: string; 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/syntax/body.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing the body of requests and responses. This is used to decorate a parameter object in `@request` and `@response` decorated methods. 3 | * 4 | * @example 5 | ``` 6 | @endpoint({ 7 | // ... 8 | }) 9 | class CreateUserEndpoint { 10 | @request 11 | request( 12 | @body body: CreateUserBody 13 | // ... 14 | ) {} 15 | // ... 16 | 17 | @response( 18 | // ... 19 | ) 20 | successResponse( 21 | @body body: UserBody 22 | // ... 23 | ) {} 24 | // ... 25 | } 26 | 27 | interface CreateUserBody { 28 | firstName: string; 29 | lastName: string; 30 | } 31 | 32 | interface UserBody { 33 | firstName: string; 34 | lastName: string; 35 | } 36 | ``` 37 | */ 38 | export declare function body( 39 | target: any, 40 | propertyKey: string, 41 | parameterIndex: number 42 | ): void; 43 | -------------------------------------------------------------------------------- /lib/src/syntax/config.ts: -------------------------------------------------------------------------------- 1 | import { QueryParamArrayStrategy } from "../definitions"; 2 | 3 | /** 4 | * Class decorator factory for describing a configuration. 5 | * Should be used in conjunction with @api. 6 | * 7 | * @param config configuration 8 | * @example 9 | ``` 10 | @api({ name: "Company API" }) 11 | @config({ 12 | paramSerializationStrategy: { 13 | query: { 14 | array: "comma" 15 | } 16 | } 17 | }) 18 | class CompanyApi {} 19 | ``` 20 | */ 21 | export function config(config: ConfigConfig) { 22 | return (target: any) => {}; 23 | } 24 | export interface ConfigConfig { 25 | /** The global configuration for parameter serialization strategy */ 26 | paramSerializationStrategy: ParamSerializationStrategy; 27 | } 28 | 29 | interface ParamSerializationStrategy { 30 | query?: QueryParamSerializationStrategy; 31 | } 32 | 33 | interface QueryParamSerializationStrategy { 34 | array?: QueryParamArrayStrategy; 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/syntax/default-response.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing a default response. This should be used only once within an `@endpoint` decorated class. 3 | * 4 | * @example 5 | ``` 6 | @endpoint({ 7 | // ... 8 | }) 9 | class CreateUserEndpoint { 10 | // ... 11 | @defaultResponse 12 | successResponse( 13 | // ... 14 | ) {} 15 | // ... 16 | } 17 | ``` 18 | */ 19 | export declare function defaultResponse( 20 | target: any, 21 | propertyKey: string | symbol, 22 | descriptor: PropertyDescriptor 23 | ): void; 24 | -------------------------------------------------------------------------------- /lib/src/syntax/draft.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for marking endpoints as draft. This should be used only in conjunction with an `@endpoint` decorated class. 3 | * 4 | * @example 5 | ``` 6 | @draft 7 | @endpoint({ 8 | // ... 9 | }) 10 | class CreateUserEndpoint { 11 | // ... 12 | } 13 | ``` 14 | */ 15 | export declare function draft(target: any): void; 16 | -------------------------------------------------------------------------------- /lib/src/syntax/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from "../definitions"; 2 | 3 | /** 4 | * Endpoint decorator factory for describing an API. 5 | * 6 | * @param config configuration 7 | * @example 8 | ``` 9 | @endpoint({ 10 | method: "POST", 11 | path: "/users", 12 | tags: ["User"] 13 | }) 14 | class CreateUserEndpoint { 15 | // ... 16 | } 17 | ``` 18 | */ 19 | export function endpoint(config: EndpointConfig) { 20 | return (target: any) => {}; 21 | } 22 | 23 | export interface EndpointConfig { 24 | /** HTTP method */ 25 | method: HttpMethod; 26 | /** URL path */ 27 | path: string; 28 | /** Endpoint grouping tags */ 29 | tags?: string[]; 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/syntax/headers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing headers in requests and responses. This is used to decorate a parameter object in `@request` and `@response` decorated methods. 3 | * 4 | * @example 5 | ``` 6 | @endpoint({ 7 | // ... 8 | }) 9 | class CreateUserEndpoint { 10 | @request 11 | request( 12 | @headers 13 | headers: { 14 | "x-auth-token": string; 15 | "Content-Type": string; 16 | // ... 17 | } 18 | // ... 19 | ) {} 20 | // ... 21 | 22 | @response( 23 | // ... 24 | ) 25 | successResponse( 26 | @headers 27 | headers: { 28 | Location: string; 29 | // ... 30 | }, 31 | // .. 32 | ) {} 33 | } 34 | ``` 35 | */ 36 | export declare function headers( 37 | target: any, 38 | propertyKey: string, 39 | parameterIndex: number 40 | ): void; 41 | -------------------------------------------------------------------------------- /lib/src/syntax/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./config"; 3 | export * from "./body"; 4 | export * from "./default-response"; 5 | export * from "./endpoint"; 6 | export * from "./headers"; 7 | export * from "./path-params"; 8 | export * from "./query-params"; 9 | export * from "./request"; 10 | export * from "./response"; 11 | export * from "./security-header"; 12 | export * from "./types"; 13 | export * from "./draft"; 14 | -------------------------------------------------------------------------------- /lib/src/syntax/oa3server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class decorator factory for describing a server. 3 | * 4 | * ```ts 5 | * @oa3server({ url: "https://{username}.gigantic-server.com:{port}/{basePath}" }) 6 | * productionServer(){} 7 | * ``` 8 | * 9 | * @param config configuration 10 | */ 11 | export function oa3server(config: Oa3serverConfig): any { 12 | return (target: any) => {}; 13 | } 14 | 15 | export interface Oa3serverConfig { 16 | /** Server Url */ 17 | url: string; 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/syntax/oa3serverVariables.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing openapi 3 server variables. This is used to decorate a parameter object in `@oa3server` decorated methods 3 | * 4 | * @example 5 | ``` 6 | @api({ 7 | // ... 8 | }) 9 | class Contract { 10 | @oa3server({ url: "https://{username}.gigantic-server.com:{port}/{basePath}" }) 11 | productionServer( 12 | @oa3serverVariables 13 | variables: { 14 | /** 15 | * this value is assigned by the service provider, in this example `gigantic-server.com` 16 | * 17 | * @default "demo" 18 | */ 19 | /** 20 | username: String 21 | /** 22 | * @default "8443" 23 | */ 24 | /** 25 | port: "8443" | "443" 26 | /** 27 | * @default "v2" 28 | */ 29 | /** 30 | basePath: String = "v2" 31 | } 32 | ) {} 33 | // ... 34 | } 35 | ``` 36 | */ 37 | export declare function oa3serverVariables( 38 | target: any, 39 | propertyKey: string, 40 | parameterIndex: number 41 | ): void; 42 | -------------------------------------------------------------------------------- /lib/src/syntax/path-params.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing path params in requests. This is used to decorate a parameter object in `@request` decorated methods. 3 | * 4 | * @example 5 | ``` 6 | @endpoint({ 7 | path: "/users/:id", 8 | // ... 9 | }) 10 | class GetUserEndpoint { 11 | @request 12 | request( 13 | @pathParams 14 | pathParams: { 15 | id: string; 16 | // ... 17 | } 18 | // ... 19 | ) {} 20 | // ... 21 | } 22 | ``` 23 | */ 24 | export declare function pathParams( 25 | target: any, 26 | propertyKey: string, 27 | parameterIndex: number 28 | ): void; 29 | -------------------------------------------------------------------------------- /lib/src/syntax/query-params.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing query params in requests. This is used to decorate a parameter object in `@request` decorated methods. 3 | * 4 | * @example 5 | ``` 6 | @endpoint({ 7 | // ... 8 | }) 9 | class GetUsersEndpoint { 10 | @request 11 | request( 12 | @queryParams 13 | queryParams: { 14 | search?: string; 15 | // ... 16 | } 17 | // ... 18 | ) {} 19 | // ... 20 | } 21 | ``` 22 | */ 23 | export declare function queryParams( 24 | target: any, 25 | propertyKey: string, 26 | parameterIndex: number 27 | ): void; 28 | -------------------------------------------------------------------------------- /lib/src/syntax/request.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing a request. This should be used only once within an `@endpoint` decorated class. 3 | * 4 | * @example 5 | ``` 6 | @endpoint({ 7 | // ... 8 | }) 9 | class CreateUserEndpoint { 10 | @request 11 | request( 12 | // ... 13 | ) {} 14 | // ... 15 | } 16 | ``` 17 | */ 18 | export declare function request( 19 | target: any, 20 | propertyKey: string | symbol, 21 | descriptor: PropertyDescriptor 22 | ): void; 23 | -------------------------------------------------------------------------------- /lib/src/syntax/response.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing a response. This should be used within an `@endpoint` decorated class. 3 | * 4 | * @example 5 | ``` 6 | @endpoint({ 7 | // ... 8 | }) 9 | class CreateUserEndpoint { 10 | // ... 11 | @response({ status: 201 }) 12 | successResponse( 13 | // ... 14 | ) {} 15 | // ... 16 | } 17 | ``` 18 | */ 19 | export function response(config: ResponseConfig) { 20 | return ( 21 | target: any, 22 | propertyKey: string | symbol, 23 | descriptor: PropertyDescriptor 24 | ) => {}; 25 | } 26 | export interface ResponseConfig { 27 | /** HTTP status code */ 28 | status: number; 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/syntax/security-header.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for describing a security header used across the entire API. 3 | * 4 | * This should be used only once within an `@api` decorated class. 5 | * 6 | * @example 7 | ``` 8 | @api({ 9 | // ... 10 | }) 11 | class Api { 12 | @securityHeader 13 | 'x-auth-token': string; 14 | } 15 | ``` 16 | */ 17 | export declare function securityHeader(target: any, propertyKey: string): void; 18 | -------------------------------------------------------------------------------- /lib/src/syntax/types.ts: -------------------------------------------------------------------------------- 1 | /** A number */ 2 | export type Number = number; 3 | 4 | /** A floating point number */ 5 | export type Float = number; 6 | 7 | /** A double precision floating point number */ 8 | export type Double = number; 9 | 10 | /** An integer */ 11 | export type Integer = number; 12 | 13 | /** A 32-bit integer */ 14 | export type Int32 = number; 15 | 16 | /** A 64-bit integer */ 17 | export type Int64 = number; 18 | 19 | /** A string */ 20 | export type String = string; 21 | 22 | /** 23 | * A `full-date` as defined by https://tools.ietf.org/html/rfc3339#section-5.6 24 | * 25 | * @example 26 | * "2018-08-24" 27 | */ 28 | export type Date = string; 29 | 30 | /** 31 | * A `date-time` as defined by https://tools.ietf.org/html/rfc3339#section-5.6 32 | * 33 | * @example 34 | * "2018-08-24T21:18:36Z" 35 | */ 36 | export type DateTime = string; 37 | -------------------------------------------------------------------------------- /lib/src/utilities/expand-path-with-tilde.spec.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import { expandPathWithTilde } from "./expand-path-with-tilde"; 3 | 4 | describe("Expand path with tilde", () => { 5 | it("expands path with tilde to home directory", () => { 6 | expect(expandPathWithTilde("~/test/dir")).toBe(`${os.homedir()}/test/dir`); 7 | }); 8 | 9 | it("does not expand path with tilde if not prefixed properly", () => { 10 | expect(expandPathWithTilde("~test/dir")).toBe(`~test/dir`); 11 | }); 12 | 13 | it("does not expand path with no tilde", () => { 14 | expect(expandPathWithTilde("./test/dir")).toBe(`./test/dir`); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /lib/src/utilities/expand-path-with-tilde.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | 3 | export function expandPathWithTilde(path: string): string { 4 | const homeDir = os.homedir(); 5 | 6 | if (!homeDir) { 7 | return path; 8 | } 9 | 10 | return path.replace(/^~(?=\/|\\)/, homeDir); 11 | } 12 | -------------------------------------------------------------------------------- /lib/src/utilities/logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | log(message: string): void; 3 | error(message: string): void; 4 | } 5 | -------------------------------------------------------------------------------- /lib/src/validation-server/__spec-examples__/contract/contract-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | body, 3 | endpoint, 4 | headers, 5 | pathParams, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | import { ErrorBody, UserBody } from "./models"; 12 | 13 | /** Retrieves a user in a company */ 14 | @endpoint({ 15 | method: "POST", 16 | path: "/company/:companyId/users/:userId", 17 | tags: ["Company", "User"] 18 | }) 19 | class GetUser { 20 | @request 21 | request( 22 | @pathParams 23 | pathParams: { 24 | /** company identifier */ 25 | companyId: String; 26 | /** user identifier */ 27 | userId: String; 28 | }, 29 | @headers 30 | headers: { 31 | /** Auth Header */ 32 | "x-auth-token": String; 33 | }, 34 | @queryParams 35 | queryParams: { 36 | /** a demo query param */ 37 | "sample-query"?: String; 38 | } 39 | ) {} 40 | 41 | /** Successful creation of user */ 42 | @response({ status: 201 }) 43 | successResponse( 44 | @headers 45 | headers: { 46 | /** Location header */ 47 | Location: String; 48 | }, 49 | /** User response body */ 50 | @body body: UserBody 51 | ) {} 52 | 53 | /** Bad request response */ 54 | @response({ status: 404 }) 55 | badRequestResponse( 56 | /** Error response body */ 57 | @body body: ErrorBody 58 | ) {} 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/validation-server/__spec-examples__/contract/models.ts: -------------------------------------------------------------------------------- 1 | export interface Company { 2 | /** company id */ 3 | id: string; 4 | } 5 | 6 | export interface UserQuery { 7 | id: number; 8 | slug: string; 9 | } 10 | 11 | /** User response body */ 12 | export interface UserBody { 13 | /** data wrapper */ 14 | data: { 15 | /** user first name */ 16 | firstName: string; 17 | /** user last name */ 18 | lastName: string; 19 | /** profile data */ 20 | profile: Profile; 21 | }; 22 | } 23 | 24 | export interface CompanyBody { 25 | data: Company; 26 | } 27 | 28 | /** Error body */ 29 | export interface ErrorBody { 30 | /** error name */ 31 | name: string; 32 | /** error messages */ 33 | message: string[]; 34 | } 35 | 36 | interface Profile { 37 | private: boolean; 38 | messageOptions: MessageOptions; 39 | } 40 | 41 | interface MessageOptions { 42 | newsletter: boolean; 43 | } 44 | 45 | /** a residential address */ 46 | export type Address = string; 47 | -------------------------------------------------------------------------------- /lib/src/validation-server/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { Contract } from "../definitions"; 3 | import { InternalServerError } from "./spots/utils"; 4 | import { 5 | RecordedRequest, 6 | RecordedResponse, 7 | ValidateRequest, 8 | ValidateResponse 9 | } from "./spots/validate"; 10 | import { ContractMismatcher } from "./verifications/contract-mismatcher"; 11 | import { 12 | UserInputRequest, 13 | UserInputResponse 14 | } from "./verifications/user-input-models"; 15 | 16 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 17 | export function runValidationServer(port: number, contract: Contract) { 18 | const app = express(); 19 | 20 | app.use(express.json()); 21 | 22 | app.get("/health", (req, res) => { 23 | res.status(200).end(); 24 | }); 25 | 26 | app.post("/validate", (req, res) => { 27 | try { 28 | // TODO: Make sure body matches ValidateRequest, we should 29 | // send a 422 if it doesn't match 30 | const body = req.body as ValidateRequest; 31 | 32 | const userInputRequest = recordedRequestToUserInputRequest(body.request); 33 | const userInputResponse = recordedResponseToUserInputResponse( 34 | body.response 35 | ); 36 | 37 | const contractValidator = new ContractMismatcher(contract); 38 | 39 | const { violations, context } = contractValidator.findViolations( 40 | userInputRequest, 41 | userInputResponse 42 | ); 43 | 44 | const responseBody: ValidateResponse = { 45 | interaction: { 46 | request: body.request, 47 | response: body.response 48 | }, 49 | endpoint: context.endpoint, 50 | violations 51 | }; 52 | res.json(responseBody); 53 | } catch (error) { 54 | res.status(500).send(makeInternalServerError([(error as Error).message])); 55 | } 56 | }); 57 | 58 | return { 59 | app, 60 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 61 | defer: () => new Promise(resolve => app.listen(port, resolve)) 62 | }; 63 | } 64 | 65 | const makeInternalServerError = (messages: string[]): InternalServerError => { 66 | return { 67 | type: "internal_server", 68 | error_code: "500", 69 | error_messages: messages 70 | }; 71 | }; 72 | 73 | export const recordedRequestToUserInputRequest = ( 74 | recordedRequest: RecordedRequest 75 | ): UserInputRequest => { 76 | return { 77 | path: recordedRequest.path, 78 | method: recordedRequest.method, 79 | headers: recordedRequest.headers, 80 | body: recordedRequest.body && JSON.parse(recordedRequest.body) 81 | }; 82 | }; 83 | 84 | export const recordedResponseToUserInputResponse = ( 85 | recordedResponse: RecordedResponse 86 | ): UserInputResponse => { 87 | return { 88 | headers: recordedResponse.headers, 89 | statusCode: recordedResponse.status, 90 | body: recordedResponse.body && JSON.parse(recordedResponse.body) 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /lib/src/validation-server/spots/api.ts: -------------------------------------------------------------------------------- 1 | import { api } from "../../lib"; 2 | 3 | import "./health"; 4 | import "./validate"; 5 | 6 | @api({ 7 | name: "Validation API" 8 | }) 9 | class ValidationApi {} 10 | -------------------------------------------------------------------------------- /lib/src/validation-server/spots/health.ts: -------------------------------------------------------------------------------- 1 | import { endpoint, request, response } from "../../lib"; 2 | 3 | @endpoint({ 4 | method: "GET", 5 | path: "/health" 6 | }) 7 | export class HealthCheck { 8 | @request 9 | request() {} 10 | 11 | @response({ status: 200 }) 12 | response() {} 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/validation-server/spots/utils.ts: -------------------------------------------------------------------------------- 1 | import { String } from "../../lib"; 2 | 3 | export interface BaseError { 4 | error_code: String; 5 | error_messages: String[]; 6 | } 7 | 8 | export interface UnprocessableEntityError extends BaseError { 9 | type: "unprocessable_entity"; 10 | } 11 | 12 | export interface InternalServerError extends BaseError { 13 | type: "internal_server"; 14 | } 15 | 16 | export interface Header { 17 | name: String; 18 | value: String; 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/validation-server/verifications/__spec-examples__/contract/contract-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | body, 3 | endpoint, 4 | headers, 5 | pathParams, 6 | queryParams, 7 | request, 8 | response, 9 | String 10 | } from "@airtasker/spot"; 11 | import { ErrorBody, UserBody } from "./models"; 12 | 13 | /** Retrieves a user in a company */ 14 | @endpoint({ 15 | method: "POST", 16 | path: "/company/:companyId/users/:userId", 17 | tags: ["Company", "User"] 18 | }) 19 | class GetUser { 20 | @request 21 | request( 22 | @pathParams 23 | pathParams: { 24 | /** company identifier */ 25 | companyId: String; 26 | /** user identifier */ 27 | userId: String; 28 | }, 29 | @headers 30 | headers: { 31 | /** Auth Header */ 32 | "x-auth-token": String; 33 | }, 34 | @queryParams 35 | queryParams: { 36 | /** a demo query param */ 37 | "sample-query"?: String; 38 | } 39 | ) {} 40 | 41 | /** Successful creation of user */ 42 | @response({ status: 201 }) 43 | successResponse( 44 | @headers 45 | headers: { 46 | /** Location header */ 47 | Location: String; 48 | }, 49 | /** User response body */ 50 | @body body: UserBody 51 | ) {} 52 | 53 | /** Bad request response */ 54 | @response({ status: 404 }) 55 | badRequestResponse( 56 | /** Error response body */ 57 | @body body: ErrorBody 58 | ) {} 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/validation-server/verifications/__spec-examples__/contract/models.ts: -------------------------------------------------------------------------------- 1 | export interface Company { 2 | /** company id */ 3 | id: string; 4 | } 5 | 6 | export interface UserQuery { 7 | id: number; 8 | slug: string; 9 | } 10 | 11 | /** User response body */ 12 | export interface UserBody { 13 | /** data wrapper */ 14 | data: { 15 | /** user first name */ 16 | firstName: string; 17 | /** user last name */ 18 | lastName: string; 19 | /** profile data */ 20 | profile: Profile; 21 | }; 22 | } 23 | 24 | export interface CompanyBody { 25 | data: Company; 26 | } 27 | 28 | /** Error body */ 29 | export interface ErrorBody { 30 | /** error name */ 31 | name: string; 32 | /** error messages */ 33 | message: string[]; 34 | } 35 | 36 | interface Profile { 37 | private: boolean; 38 | messageOptions: MessageOptions; 39 | } 40 | 41 | interface MessageOptions { 42 | newsletter: boolean; 43 | } 44 | 45 | /** a residential address */ 46 | export type Address = string; 47 | -------------------------------------------------------------------------------- /lib/src/validation-server/verifications/user-input-models.ts: -------------------------------------------------------------------------------- 1 | export interface UserInputRequest { 2 | path: string; 3 | method: string; 4 | headers: UserInputHeader[]; 5 | body?: UserInputBody; 6 | } 7 | 8 | export interface UserInputResponse { 9 | headers: UserInputHeader[]; 10 | statusCode: number; 11 | body?: UserInputBody; 12 | } 13 | 14 | export interface UserInputHeader { 15 | name: string; 16 | value: string; 17 | } 18 | 19 | export type UserInputBody = unknown; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@airtasker/spot", 3 | "version": "1.13.0", 4 | "author": "Francois Wouts, Leslie Fung", 5 | "bin": { 6 | "spot": "./bin/run" 7 | }, 8 | "bugs": "https://github.com/airtasker/spot/issues", 9 | "dependencies": { 10 | "@oclif/command": "^1.8.0", 11 | "@oclif/config": "^1.17.0", 12 | "@oclif/plugin-help": "^3.2.3", 13 | "ajv": "^8.16.0", 14 | "ajv-formats": "^2.1.1", 15 | "assert-never": "^1.2.1", 16 | "cors": "^2.8.5", 17 | "express": "^4.19.2", 18 | "fs-extra": "^11.2.0", 19 | "inquirer": "^8.1.1", 20 | "js-yaml": "^4.1.0", 21 | "qs": "^6.12.1", 22 | "randomstring": "^1.2.1", 23 | "ts-morph": "18.0.0", 24 | "typescript": "^4.9.5", 25 | "validator": "^13.12.0" 26 | }, 27 | "devDependencies": { 28 | "@oclif/dev-cli": "^1.26.0", 29 | "@stoplight/spectral": "^5.9.2", 30 | "@types/cors": "^2.8.17", 31 | "@types/express": "^4.17.21", 32 | "@types/fs-extra": "^11.0.4", 33 | "@types/inquirer": "^8.1.2", 34 | "@types/jest": "^27.0.2", 35 | "@types/js-yaml": "^4.0.9", 36 | "@types/qs": "^6.9.15", 37 | "@types/randomstring": "^1.3.0", 38 | "@types/supertest": "^2.0.16", 39 | "@types/validator": "^13.11.10", 40 | "@typescript-eslint/eslint-plugin": "^4.31.2", 41 | "@typescript-eslint/parser": "^4.31.2", 42 | "eslint": "^7.32.0", 43 | "eslint-plugin-jest": "^27.6.3", 44 | "jest": "^26.6.3", 45 | "jest-junit": "^16.0.0", 46 | "nock": "^13.5.4", 47 | "prettier": "^3.3.2", 48 | "supertest": "^6.1.6", 49 | "ts-jest": "^26.5.6" 50 | }, 51 | "engines": { 52 | "node": ">=12.0.0" 53 | }, 54 | "files": [ 55 | "bin", 56 | "build", 57 | "index.d.ts", 58 | "index.js", 59 | "npm-shrinkwrap.json", 60 | "oclif.manifest.json" 61 | ], 62 | "main": "build/index.js", 63 | "types": "build/index.d.ts", 64 | "homepage": "https://github.com/airtasker/spot", 65 | "license": "MIT", 66 | "oclif": { 67 | "commands": "./build/cli/src/commands", 68 | "bin": "spot", 69 | "plugins": [ 70 | "@oclif/plugin-help" 71 | ] 72 | }, 73 | "repository": "airtasker/spot", 74 | "scripts": { 75 | "build-docs": "cd docs && yarn build", 76 | "build": "tsc", 77 | "build:watch": "tsc --watch", 78 | "postpack": "rm -f oclif.manifest.json", 79 | "prepack": "rm -rf build; tsc && oclif-dev manifest && yarn build-docs && oclif-dev readme", 80 | "test": "jest -w 4", 81 | "ci:test": "jest --config=jest.ci.config.js --ci -w 4", 82 | "lint:check": "yarn prettier:check && yarn eslint:check", 83 | "eslint:check": "eslint . --ext .js,.ts,.tsx", 84 | "prettier:check": "prettier --list-different \"**/*.js\" \"**/*.ts\" \"**/*.tsx\"", 85 | "lint:fix": "yarn prettier:fix && yarn eslint:fix", 86 | "eslint:fix": "eslint . --fix --ext .js,.ts,.tsx", 87 | "prettier:fix": "prettier --write \"**/*.js\" \"**/*.ts\" \"**/*.tsx\"" 88 | } 89 | } 90 | --------------------------------------------------------------------------------