├── .eslintrc ├── .github └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── coverage.yml │ └── publish.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── src ├── debug.ts ├── index.spec.ts ├── index.ts ├── router.spec.ts ├── router.ts └── validate.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@checkdigit"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | 8 | env: 9 | CI: true 10 | 11 | jobs: 12 | pullRequestBuild: 13 | name: Pull Request Build 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [16.x, 18.x] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Compile 28 | run: npm run ci:compile 29 | - name: Check Code Style 30 | run: npm run ci:style 31 | - name: Perform Linting 32 | run: npm run ci:lint 33 | - name: Run Tests 34 | run: npm run ci:test 35 | 36 | branchBuild: 37 | runs-on: ubuntu-latest 38 | name: Branch Build 39 | strategy: 40 | matrix: 41 | node-version: [16.x, 18.x] 42 | steps: 43 | - uses: actions/checkout@v3 44 | with: 45 | ref: ${{ github.event.pull_request.head.sha }} 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v3 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | cache: 'npm' 51 | - name: Install dependencies 52 | run: npm ci 53 | - name: Compile 54 | run: npm run ci:compile 55 | - name: Check Code Style 56 | run: npm run ci:style 57 | - name: Perform Linting 58 | run: npm run ci:lint 59 | - name: Run Tests 60 | run: npm run ci:test 61 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | schedule: 9 | - cron: '50 22 * * 1' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | language: ['javascript'] 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v3 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v2 28 | with: 29 | languages: ${{ matrix.language }} 30 | - name: Perform CodeQL Analysis 31 | uses: github/codeql-action/analyze@v2 32 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | 8 | env: 9 | CI: true 10 | 11 | jobs: 12 | branchBuild: 13 | runs-on: ubuntu-latest 14 | name: Branch Build 15 | strategy: 16 | matrix: 17 | node-version: [16.x] 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | ref: ${{ github.event.pull_request.head.sha }} 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | - name: Install dependencies 28 | run: npm ci 29 | - name: Calculate Code Coverage 30 | run: npm run ci:coverage 31 | - name: Create Coverage Report for base branch 32 | run: | 33 | mv coverage/lcov.info coverage/lcov_head.info 34 | git fetch 35 | git checkout origin/${{ github.event.pull_request.base.ref }} 36 | npm ci && npm run ci:coverage 37 | - name: Post Coverage Report 38 | uses: romeovs/lcov-reporter-action@v0.3.1 39 | with: 40 | lcov-file: 'coverage/lcov_head.info' 41 | lcov-base: 'coverage/lcov.info' 42 | delete-old-comments: true 43 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: '16.x' 15 | registry-url: 'https://registry.npmjs.org' 16 | - run: npm ci 17 | - run: npm publish 18 | env: 19 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | npm-debug.log* 3 | pids 4 | *.pid 5 | *.seed 6 | lib-cov 7 | .idea 8 | build 9 | node_modules 10 | typings 11 | coverage 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014-2022 Carl Ansley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swagger2-koa 2 | 3 | Koa 2 async-style middleware for loading, parsing and validating requests via swagger2. 4 | 5 | - `router(document) => koa2-style Router` 6 | - `validate(document) => koa2 middleware` 7 | 8 | ## Installation 9 | 10 | ```shell 11 | $ npm add swagger2-koa 12 | ``` 13 | 14 | ## Usage 15 | 16 | ### `router(document) => koa2-style Router` 17 | 18 | This is the easiest way to use swagger2-koa; it creates a standalone koa server, adds the `validate` middleware, and returns a 19 | Router object that allows you to add your route implementations. 20 | 21 | ``` 22 | import * as swagger from 'swagger2'; 23 | import {router as swaggerRouter, Router} from 'swagger2-koa'; 24 | 25 | ... 26 | const document = swagger.loadDocumentSync('./swagger.yml'); 27 | const router: Router = swaggerRouter(document); 28 | 29 | router.get('/ping', async (context) => { 30 | context.status = 200; 31 | context.body = { 32 | serverTime: new Date().toISOString() 33 | }; 34 | }); 35 | 36 | ... 37 | 38 | router.app() // get the koa 2 server 39 | .listen(3000); // start handling requests on port 3000 40 | 41 | ``` 42 | 43 | Note: in addition to `validate` (described below), `router` adds the following middleware to its koa server: 44 | 45 | - `@koa/cors` 46 | - `@koa/router` 47 | - `koa-bodyparser` 48 | 49 | ### `validate(document) => koa2 middleware` 50 | 51 | If you already have a Koa server, this middleware adds basic loading and validation of HTTP requests and responses against 52 | swagger 2.0 document: 53 | 54 | ```javascript 55 | import * as swagger from 'swagger2'; 56 | import { validate } from 'swagger2-koa'; 57 | 58 | const app = new Koa(); 59 | 60 | // load YAML swagger file 61 | const document = swagger.loadDocumentSync('./swagger.yml'); 62 | 63 | // validate document 64 | if (!swagger.validateDocument(document)) { 65 | throw Error(`./swagger.yml does not conform to the Swagger 2.0 schema`); 66 | } 67 | 68 | app.use(body()); 69 | app.use(validate(document)); 70 | ``` 71 | 72 | The `validate` middleware behaves as follows: 73 | 74 | - expects context.body to contain request body in object form (e.g. via use of koa-body) 75 | - if the request is for a path not defined in swagger document, an HTTP 404 is returned to the client (subsequent middleware is never processed). 76 | - if the request body does not validate, an HTTP 400 is returned to the client (subsequent middleware is never processed) 77 | - if the response body does not validate, an HTTP 500 is returned to the client 78 | 79 | For either request (HTTP 400) or response (HTTP 500) errors, details of the schema validation error are passed back in the body. e.g.: 80 | 81 | ```JSON 82 | { 83 | "code": "SWAGGER_RESPONSE_VALIDATION_FAILED", 84 | "errors": [{ 85 | "actual": {"badTime": "mock"}, 86 | "expected": { 87 | "schema": {"type": "object", "required": ["time"], "properties": {"time": {"type": "string", "format": "date-time"}}} 88 | }, 89 | "where": "response" 90 | }] 91 | } 92 | ``` 93 | 94 | ## Debugging 95 | 96 | This library uses [`debug`](https://github.com/visionmedia/debug), which can be activated using the 97 | `DEBUG` environment variable: 98 | 99 | ```shell 100 | export DEBUG=swagger2-koa:* 101 | ``` 102 | 103 | ## Limitations 104 | 105 | - only supports Koa 2-style async/await middleware interface 106 | - requires node version 16 and above 107 | 108 | ## License 109 | 110 | MIT 111 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | These versions of swagger2-koa are currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | > 4.x | :white_check_mark: | 10 | | < 4.0 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Please create an issue at https://github.com/carlansley/swagger2-koa/issues 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swagger2-koa", 3 | "version": "4.0.0", 4 | "description": "Koa 2 middleware for loading, parsing and validating requests via swagger2", 5 | "main": "dist/index.js", 6 | "engines": { 7 | "node": ">=16" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/carlansley/swagger2-koa.git" 12 | }, 13 | "keywords": [ 14 | "swagger", 15 | "swagger2", 16 | "typescript", 17 | "koa", 18 | "koa2" 19 | ], 20 | "author": "Carl Ansley", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/carlansley/swagger2-koa/issues" 24 | }, 25 | "homepage": "https://github.com/carlansley/swagger2-koa#readme", 26 | "typings": "./dist/index.d.ts", 27 | "prettier": "@checkdigit/prettier-config", 28 | "dependencies": { 29 | "@koa/cors": "^3.4.1", 30 | "@koa/router": "^12.0.0", 31 | "@types/koa-bodyparser": "^5.0.1", 32 | "debug": "^4.3.4", 33 | "koa": "^2.13.4", 34 | "koa-bodyparser": "^4.3.0", 35 | "swagger2": "^4.0.2" 36 | }, 37 | "devDependencies": { 38 | "@checkdigit/eslint-config": "^7.2.0", 39 | "@checkdigit/prettier-config": "^3.0.0", 40 | "@checkdigit/typescript-config": "^3.0.1", 41 | "@types/debug": "4.1.7", 42 | "@types/jest": "^28.1.7", 43 | "@types/koa": "^2.13.5", 44 | "@types/koa__cors": "^3.3.0", 45 | "@types/koa__router": "^8.0.11", 46 | "@types/koa-send": "^4.1.3", 47 | "@types/supertest": "^2.0.12", 48 | "jest": "^28.1.3", 49 | "rimraf": "^3.0.2", 50 | "supertest": "^6.2.4", 51 | "ts-jest": "^28.0.8", 52 | "ts-node": "^10.9.1" 53 | }, 54 | "maintainers": [ 55 | { 56 | "email": "carl.ansley@gmail.com", 57 | "name": "Carl Ansley" 58 | } 59 | ], 60 | "jest": { 61 | "preset": "ts-jest", 62 | "collectCoverageFrom": [ 63 | "/src/**", 64 | "!/src/**/*.json", 65 | "!/src/**/*.spec.ts", 66 | "!/src/**/*.test.ts" 67 | ], 68 | "globals": { 69 | "ts-jest": { 70 | "isolatedModules": true, 71 | "diagnostics": false 72 | } 73 | }, 74 | "testMatch": [ 75 | "/src/**/*.spec.ts" 76 | ] 77 | }, 78 | "scripts": { 79 | "dist": "rimraf dist && tsc --outDir dist && rimraf dist/*.spec.*", 80 | "prepublishOnly": "npm run dist && rimraf .github src .eslintrc .gitignore tsconfig.json", 81 | "lint": "eslint -f unix src/**/*.ts", 82 | "lint:fix": "eslint -f unix src/**/*.ts --fix", 83 | "prettier": "prettier --list-different .", 84 | "prettier:fix": "prettier --write .", 85 | "test": "npm run ci:compile && npm run ci:test && npm run ci:lint && npm run ci:style", 86 | "ci:compile": "tsc --noEmit", 87 | "ci:test": "jest --coverage=false", 88 | "ci:coverage": "jest --coverage=true", 89 | "ci:lint": "npm run lint", 90 | "ci:style": "npm run prettier" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | // debug.ts 2 | 3 | /* 4 | * Middleware for debugging HTTP requests and responses 5 | */ 6 | 7 | /* 8 | The MIT License 9 | 10 | Copyright (c) 2014-2022 Carl Ansley 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | */ 30 | 31 | import debug from 'debug'; 32 | 33 | /* eslint-disable @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/restrict-template-expressions */ 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | export default function (module: string): (context: any, next: () => Promise) => Promise { 37 | // set up logging 38 | const log = debug(module); 39 | 40 | if (!log.enabled) { 41 | // logging not enabled for this module, return do-nothing middleware 42 | return async (_, next) => next(); 43 | } 44 | 45 | /* istanbul ignore next */ 46 | return async (context, next) => { 47 | const startTime = Date.now(); 48 | const { 49 | method, 50 | url, 51 | request: { body }, 52 | } = context.request; 53 | 54 | await next(); 55 | 56 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 57 | const status = Number.parseInt(context.status, 10); 58 | const requestBody = JSON.stringify(body); 59 | const responseBody = JSON.stringify(context.body); 60 | const time = Date.now() - startTime; 61 | 62 | if (requestBody !== undefined && responseBody !== undefined) { 63 | log(`${method} ${url} ${requestBody} -> ${status} ${responseBody} ${time}ms`); 64 | } 65 | 66 | if (requestBody !== undefined && responseBody === undefined) { 67 | log(`${method} ${url} ${requestBody} -> ${status} ${time}ms`); 68 | } 69 | 70 | if (requestBody === undefined && responseBody !== undefined) { 71 | log(`${method} ${url} -> ${status} ${responseBody} ${time}ms`); 72 | } 73 | 74 | if (requestBody === undefined && responseBody === undefined) { 75 | log(`${method} ${url} -> ${status} ${time}ms`); 76 | } 77 | }; 78 | } 79 | 80 | /* eslint-enable @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/restrict-template-expressions */ 81 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | // index.spec.ts 2 | 3 | /* 4 | The MIT License 5 | 6 | Copyright (c) 2014-2022 Carl Ansley 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | */ 26 | 27 | import { strict as assert } from 'node:assert'; 28 | 29 | import { router, validate } from './index'; 30 | 31 | describe('swagger2-koa', () => { 32 | it('has validate middleware', () => assert.equal(typeof validate, 'function')); 33 | it('has router middleware', () => assert.equal(typeof router, 'function')); 34 | }); 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // index.ts 2 | 3 | /* 4 | * Koa2 middleware for validating against a Swagger document 5 | */ 6 | 7 | /* 8 | The MIT License 9 | 10 | Copyright (c) 2014-2022 Carl Ansley 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | */ 30 | 31 | export { default as validate } from './validate'; 32 | 33 | export { default as router } from './router'; 34 | export { Router } from './router'; 35 | -------------------------------------------------------------------------------- /src/router.spec.ts: -------------------------------------------------------------------------------- 1 | // router.spec.ts 2 | 3 | /* 4 | The MIT License 5 | 6 | Copyright (c) 2014-2022 Carl Ansley 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | */ 26 | 27 | import * as assert from 'node:assert'; 28 | 29 | import agent from 'supertest'; 30 | import type * as swagger from 'swagger2'; 31 | 32 | import swaggerRouter, { Context } from './router'; 33 | 34 | describe('router', () => { 35 | describe('numeric-path', () => { 36 | const numericPath = swaggerRouter({ 37 | swagger: '2.0', 38 | info: { 39 | title: 'thing', 40 | version: '1.0', 41 | }, 42 | paths: { 43 | '/v1/post/{id}': { 44 | get: { 45 | parameters: [ 46 | { 47 | name: 'id', 48 | in: 'path', 49 | type: 'integer', 50 | format: 'int64', 51 | description: 'The post to fetch', 52 | required: true, 53 | }, 54 | ], 55 | responses: { 56 | 200: { 57 | description: '', 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | }); 64 | 65 | numericPath.get('/v1/post/:id', async (context: Context) => { 66 | context.status = 200; 67 | }); 68 | 69 | const http = agent(numericPath.app().callback()); 70 | 71 | it('validates valid GET operation', async () => { 72 | await http.get('/v1/post/3').expect(200); 73 | await http.get('/v1/post/3.2').expect(400); 74 | await http.get('/v1/post/abc').expect(400); 75 | }); 76 | }); 77 | 78 | describe('no-base-path', () => { 79 | const routerNoBasePath = swaggerRouter({ 80 | swagger: '2.0', 81 | info: { 82 | title: 'mock', 83 | version: '0.0.1', 84 | }, 85 | paths: { 86 | '/ping': { 87 | get: { 88 | responses: { 89 | 200: { 90 | description: '', 91 | schema: { 92 | type: 'object', 93 | required: ['time'], 94 | properties: { 95 | time: { 96 | type: 'string', 97 | format: 'date-time', 98 | }, 99 | }, 100 | }, 101 | }, 102 | }, 103 | }, 104 | }, 105 | }, 106 | }); 107 | 108 | routerNoBasePath.get('/ping', async (context: Context) => { 109 | context.status = 200; 110 | context.body = { 111 | time: new Date().toISOString(), 112 | }; 113 | }); 114 | 115 | const http = agent(routerNoBasePath.app().callback()); 116 | 117 | it('validates valid GET operation', async () => { 118 | const { body } = await http.get('/ping').expect(200); 119 | assert.doesNotThrow(() => new Date(body.time)); 120 | }); 121 | }); 122 | 123 | describe('mock swagger', () => { 124 | // noinspection ReservedWordAsName 125 | const document: swagger.Document = { 126 | swagger: '2.0', 127 | info: { 128 | title: 'mock', 129 | version: '0.0.1', 130 | }, 131 | basePath: '/mock', 132 | paths: { 133 | '/ping': { 134 | get: { 135 | responses: { 136 | 200: { 137 | description: '', 138 | schema: { 139 | type: 'object', 140 | required: ['time'], 141 | properties: { 142 | time: { 143 | type: 'string', 144 | format: 'date-time', 145 | }, 146 | }, 147 | }, 148 | }, 149 | }, 150 | }, 151 | head: { 152 | responses: { 153 | 200: { 154 | description: '', 155 | }, 156 | }, 157 | }, 158 | patch: { 159 | responses: { 160 | 204: { 161 | description: '', 162 | }, 163 | }, 164 | }, 165 | put: { 166 | responses: { 167 | 204: { 168 | description: '', 169 | }, 170 | }, 171 | }, 172 | post: { 173 | responses: { 174 | 201: { 175 | description: '', 176 | }, 177 | }, 178 | }, 179 | options: { 180 | responses: { 181 | 200: { 182 | description: '', 183 | }, 184 | }, 185 | }, 186 | delete: { 187 | responses: { 188 | 204: { 189 | description: '', 190 | }, 191 | }, 192 | }, 193 | }, 194 | '/badPing': { 195 | get: { 196 | responses: { 197 | 200: { 198 | description: '', 199 | schema: { 200 | type: 'object', 201 | required: ['time'], 202 | properties: { 203 | time: { 204 | type: 'string', 205 | format: 'date-time', 206 | }, 207 | }, 208 | }, 209 | }, 210 | }, 211 | }, 212 | put: { 213 | responses: { 214 | 201: { description: '' }, 215 | }, 216 | }, 217 | post: { 218 | responses: { 219 | 201: { description: '' }, 220 | }, 221 | }, 222 | }, 223 | }, 224 | }; 225 | 226 | const router = swaggerRouter(document); 227 | 228 | router.head('/ping', async (context: Context) => { 229 | context.status = 200; 230 | }); 231 | 232 | router.get( 233 | '/ping', 234 | async (context: Context, next: () => void) => { 235 | context.status = 200; 236 | return next(); 237 | }, 238 | async (context: Context) => { 239 | context.body = { 240 | time: new Date().toISOString(), 241 | }; 242 | } 243 | ); 244 | 245 | router.put('/ping', async (context: Context) => { 246 | context.status = 204; 247 | }); 248 | 249 | router.patch('/ping', async (context: Context) => { 250 | context.status = 204; 251 | }); 252 | 253 | router.post('/ping', async (context: Context) => { 254 | context.status = 201; 255 | }); 256 | 257 | router.del('/ping', async (context: Context) => { 258 | context.status = 204; 259 | }); 260 | 261 | router.get('/badPing', async (context: Context) => { 262 | context.status = 200; 263 | context.body = { 264 | badTime: 'mock', 265 | }; 266 | }); 267 | 268 | router.put('/badPing', async (context: Context) => { 269 | context.status = 201; 270 | context.body = { 271 | something: 'mock', 272 | }; 273 | }); 274 | 275 | router.post('/badPing', async () => { 276 | const error = new Error(); 277 | (error as unknown as { status: number }).status = 400; 278 | throw error; 279 | }); 280 | 281 | const http = agent(router.app().callback()); 282 | 283 | it('fails with invalid filename', () => { 284 | assert.throws(() => swaggerRouter('invalid.yml'), /^Error: ENOENT/u); 285 | }); 286 | 287 | it('fails with invalid Swagger document', () => { 288 | // eslint-disable-next-line unicorn/prefer-module 289 | assert.throws(() => swaggerRouter(`${__dirname}/../.travis.yml`)); 290 | }); 291 | 292 | it('invalid path', async () => http.post('/mock/pingy').expect(404)); 293 | it('invalid path', async () => http.post('/pingy').expect(404)); 294 | it('invalid method', async () => http.patch('/mock/badPing').expect(405)); 295 | it('invalid request', async () => http.get('/mock/ping?x=y').expect(400)); 296 | 297 | it('validates valid GET operation', async () => { 298 | const { body } = await http.get('/mock/ping').expect(200); 299 | assert.doesNotThrow(() => new Date(body.time)); 300 | }); 301 | 302 | it('validates valid HEAD operation', async () => { 303 | const { body } = await http.head('/mock/ping').expect(200); 304 | assert.deepEqual(body, {}); 305 | }); 306 | 307 | it('validates valid POST operation', async () => { 308 | const { body } = await http.post('/mock/ping').expect(201); 309 | assert.deepEqual(body, {}); 310 | }); 311 | 312 | it('validates valid PATCH operation', async () => { 313 | const { body } = await http.patch('/mock/ping').expect(204); 314 | assert.deepEqual(body, {}); 315 | }); 316 | 317 | it('validates valid DELETE operation', async () => { 318 | const { body } = await http.del('/mock/ping').expect(204); 319 | assert.deepEqual(body, {}); 320 | }); 321 | 322 | it('pass through OPTIONS operation', async () => { 323 | const { body } = await http.options('/mock/ping').expect(200); 324 | assert.deepEqual(body, {}); 325 | }); 326 | 327 | it('validates valid PUT operation', async () => { 328 | const { body } = await http.put('/mock/ping').expect(204); 329 | assert.deepEqual(body, {}); 330 | }); 331 | 332 | it('handles POST operation throwing 400 error', async () => { 333 | await http.post('/mock/badPing').expect(400); 334 | }); 335 | 336 | it('does not validate invalid operation response', async () => { 337 | const { body } = await http.get('/mock/badPing').expect(500); 338 | assert.deepEqual(body, { 339 | code: 'SWAGGER_RESPONSE_VALIDATION_FAILED', 340 | errors: [ 341 | { 342 | actual: { badTime: 'mock' }, 343 | expected: { 344 | schema: { 345 | type: 'object', 346 | required: ['time'], 347 | properties: { time: { type: 'string', format: 'date-time' } }, 348 | }, 349 | }, 350 | where: 'response', 351 | error: 'data.time is required', 352 | }, 353 | ], 354 | }); 355 | }); 356 | 357 | it('does not validate response where nothing is expected', async () => { 358 | const { body } = await http.put('/mock/badPing').expect(500); 359 | assert.deepEqual(body, { 360 | code: 'SWAGGER_RESPONSE_VALIDATION_FAILED', 361 | errors: [ 362 | { 363 | actual: { something: 'mock' }, 364 | where: 'response', 365 | }, 366 | ], 367 | }); 368 | }); 369 | }); 370 | }); 371 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | // router.ts 2 | 3 | /* 4 | * Koa2 router implementation for validating against a Swagger document 5 | */ 6 | 7 | /* 8 | The MIT License 9 | 10 | Copyright (c) 2014-2022 Carl Ansley 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | */ 30 | 31 | import Koa from 'koa'; 32 | import body from 'koa-bodyparser'; 33 | import koaCors from '@koa/cors'; 34 | import KoaRouter from '@koa/router'; 35 | import * as swagger from 'swagger2'; 36 | 37 | import validate from './validate'; 38 | 39 | import debug from './debug'; 40 | 41 | export interface Request { 42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 | query: any; 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | body?: any; 46 | method: string; 47 | url: string; 48 | ip: string; 49 | ips: string[]; 50 | subdomains: string[]; 51 | origin: string; 52 | host: string; 53 | length: number; 54 | originalUrl: string; 55 | href: string; 56 | querystring: string; 57 | search: string; 58 | hostname: string; 59 | type: string; 60 | charset?: string; 61 | fresh: boolean; 62 | stale: boolean; 63 | idempotent: boolean; 64 | get: (field: string) => string; 65 | header: { [name: string]: string }; 66 | headers: { [name: string]: string }; 67 | } 68 | 69 | export interface Response { 70 | get: (field: string) => string; 71 | set: (field: string, value: string) => void; 72 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 73 | body?: any; 74 | status?: number; 75 | message?: string; 76 | redirect: (url: string, alt?: string) => void; 77 | header: { [name: string]: string }; 78 | } 79 | 80 | export interface Context extends Request, Response { 81 | params: { [name: string]: string }; 82 | request: Request; 83 | response: Response; 84 | } 85 | 86 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 87 | export type Middleware = (context: Context, next: () => void) => any; 88 | 89 | export interface Router { 90 | get: (path: string, ...middleware: Middleware[]) => Router; 91 | head: (path: string, ...middleware: Middleware[]) => Router; 92 | put: (path: string, ...middleware: Middleware[]) => Router; 93 | post: (path: string, ...middleware: Middleware[]) => Router; 94 | del: (path: string, ...middleware: Middleware[]) => Router; 95 | patch: (path: string, ...middleware: Middleware[]) => Router; 96 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 97 | app: () => any; 98 | } 99 | 100 | interface HttpRouter extends Router { 101 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 102 | routes: () => (context: Koa.Context, next: () => void) => any; 103 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 104 | allowedMethods: () => (context: Koa.Context, next: () => void) => any; 105 | } 106 | 107 | export default function (swaggerDocument: unknown): Router { 108 | const router = new KoaRouter() as unknown as HttpRouter; 109 | const app = new Koa(); 110 | 111 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 112 | let document: any; 113 | 114 | if (typeof swaggerDocument === 'string') { 115 | // eslint-disable-next-line no-sync 116 | document = swagger.loadDocumentSync(swaggerDocument); 117 | } else { 118 | document = swaggerDocument; 119 | } 120 | 121 | if (!swagger.validateDocument(document)) { 122 | throw new Error(`Document does not conform to the Swagger 2.0 schema`); 123 | } 124 | 125 | app.use(debug('swagger2-koa:router')); 126 | app.use(koaCors()); 127 | app.use(body()); 128 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 129 | app.use(validate(document)); 130 | app.use(router.routes()); 131 | app.use(router.allowedMethods()); 132 | 133 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/restrict-plus-operands 134 | const full = (path: string) => (document.basePath !== undefined ? document.basePath + path : path); 135 | 136 | return { 137 | get: (path, ...middleware) => router.get(full(path), ...middleware), 138 | head: (path, ...middleware) => router.head(full(path), ...middleware), 139 | put: (path, ...middleware) => router.put(full(path), ...middleware), 140 | post: (path, ...middleware) => router.post(full(path), ...middleware), 141 | del: (path, ...middleware) => router.del(full(path), ...middleware), 142 | patch: (path, ...middleware) => router.patch(full(path), ...middleware), 143 | app: () => app, 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | // validate.ts 2 | 3 | /* 4 | * Koa2 middleware for validating against a Swagger document 5 | */ 6 | 7 | /* 8 | The MIT License 9 | 10 | Copyright (c) 2014-2022 Carl Ansley 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | */ 30 | 31 | import * as swagger from 'swagger2'; 32 | 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | export default function (document: swagger.Document): (context: any, next: () => Promise) => Promise { 35 | // construct a validation object, pre-compiling all schema and regex required 36 | const compiled = swagger.compileDocument(document); 37 | 38 | // construct a canonical base path 39 | const basePath = (document.basePath || '') + ((document.basePath || '').endsWith('/') ? '' : '/'); 40 | 41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 | return async (context: any, next: () => Promise) => { 43 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access 44 | if (document.basePath !== undefined && !context.path.startsWith(basePath)) { 45 | // not a path that we care about 46 | return next(); 47 | } 48 | 49 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access 50 | const compiledPath = compiled(context.path); 51 | if (compiledPath === undefined) { 52 | // if there is no single matching path, return 404 (not found) 53 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 54 | context.status = 404; 55 | return; 56 | } 57 | 58 | // check the request matches the swagger schema 59 | const validationErrors = swagger.validateRequest( 60 | compiledPath, 61 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access 62 | context.method, 63 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 64 | context.request.query, 65 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 66 | context.request.body, 67 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 68 | context.request.headers 69 | ); 70 | 71 | if (validationErrors === undefined) { 72 | // operation not defined, return 405 (method not allowed) 73 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 74 | if (context.method !== 'OPTIONS') { 75 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 76 | context.status = 405; 77 | } 78 | return; 79 | } 80 | if (validationErrors.length > 0) { 81 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 82 | context.status = 400; 83 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 84 | context.body = { 85 | code: 'SWAGGER_REQUEST_VALIDATION_FAILED', 86 | errors: validationErrors, 87 | }; 88 | return; 89 | } 90 | 91 | // wait for the operation to execute 92 | await next(); 93 | 94 | // do not validate responses to OPTIONS 95 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access 96 | if (context.method.toLowerCase() === 'options') { 97 | return; 98 | } 99 | 100 | // check the response matches the swagger schema 101 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access 102 | const error = swagger.validateResponse(compiledPath, context.method, context.status, context.body); 103 | if (error) { 104 | error.where = 'response'; 105 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 106 | context.status = 500; 107 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 108 | context.body = { 109 | code: 'SWAGGER_RESPONSE_VALIDATION_FAILED', 110 | errors: [error], 111 | }; 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@checkdigit/typescript-config", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "build" 6 | }, 7 | "exclude": ["node_modules", "build", "dist"] 8 | } 9 | --------------------------------------------------------------------------------