├── .npmignore ├── .gitignore ├── renovate.json ├── .changeset ├── config.json └── README.md ├── .npmrc-public ├── src ├── compiler │ ├── ajv.ts │ ├── CompiledMediaType.ts │ ├── CompiledParameterCookie.ts │ ├── index.ts │ ├── CompiledParameterPath.ts │ ├── CompiledParameterHeader.ts │ ├── CompiledResponse.ts │ ├── CompiledResponseHeader.ts │ ├── CompiledParameterQuery.ts │ ├── CompiledSchema.ts │ ├── CompiledPath.ts │ ├── CompiledRequestBody.ts │ ├── CompiledPathItem.ts │ └── CompiledOperation.ts ├── error.ts └── index.ts ├── __test__ ├── __snapshots__ │ ├── response.spec.ts.snap │ └── pet-store.spec.ts.snap ├── deref-error.spec.ts ├── option.spec.ts ├── chow-error.spec.ts ├── fixtures │ ├── deref-error.json │ ├── option-unknown-fmt.json │ ├── parameter-in-path-level.json │ ├── parameter-in-both-operation-and-path-level.json │ ├── query.json │ ├── path.json │ ├── response.json │ └── pet-store.json ├── chow-keywords.spec.ts ├── query.spec.ts ├── path.spec.ts ├── chow-strict.spec.ts ├── response.spec.ts └── pet-store.spec.ts ├── jest.config.js ├── LICENSE ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── tsconfig.json ├── package.json ├── CHANGELOG.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | /__test__ 2 | /node_modules 3 | /coverage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /.vscode 4 | /lib 5 | .DS_store -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "public" 7 | } -------------------------------------------------------------------------------- /.npmrc-public: -------------------------------------------------------------------------------- 1 | //packages.atlassian.com/api/npm/npm-public/:_password=${ARTIFACTORY_PASSWORD_BASE64} 2 | //packages.atlassian.com/api/npm/npm-public/:username=${ARTIFACTORY_USERNAME} 3 | //packages.atlassian.com/api/npm/npm-public/:email=build-team@atlassian.com 4 | //packages.atlassian.com/api/npm/npm-public/:always-auth=true -------------------------------------------------------------------------------- /src/compiler/ajv.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { Options } from 'ajv'; 2 | import addFormats from 'ajv-formats'; 3 | 4 | const options: Options = { strict: 'log' }; 5 | 6 | export default function ajv(opts: Options = {}) { 7 | const ajv = new Ajv({ 8 | ...options, 9 | ...opts, 10 | }); 11 | addFormats(ajv); 12 | return ajv; 13 | } 14 | -------------------------------------------------------------------------------- /__test__/__snapshots__/response.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Response validateResponseByOperationId should fail if header value is invalid 1`] = `"ResponseValidationError: Schema validation error"`; 4 | 5 | exports[`Response validateResponseByPath should fail if header value is invalid 1`] = `"ResponseValidationError: Schema validation error"`; 6 | -------------------------------------------------------------------------------- /__test__/deref-error.spec.ts: -------------------------------------------------------------------------------- 1 | import ChowChow from '../src'; 2 | 3 | const fixture = require('./fixtures/deref-error.json'); 4 | 5 | describe('Deref Error', () => { 6 | it('should throw a proper error', async () => { 7 | await expect(ChowChow.create(fixture)).rejects.toMatchInlineSnapshot( 8 | `[MissingPointerError: Token "blahBlahBlah" does not exist.]` 9 | ); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | roots: ['/src', '__test__'], 4 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | testEnvironment: 'node', 7 | transform: { 8 | '^.+\\.tsx?$': [ 9 | 'ts-jest', 10 | { 11 | tsconfig: 'tsconfig.json', 12 | }, 13 | ], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Atlassian Pty Ltd 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /__test__/__snapshots__/pet-store.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Pet Store RequestBody It returns defined body content type 1`] = ` 4 | [ 5 | "application/json", 6 | ] 7 | `; 8 | 9 | exports[`Pet Store RequestBody It returns empty array for defined body content type if method is undefined 1`] = `[]`; 10 | 11 | exports[`Pet Store RequestBody It returns empty array for defined body content type if path is undefined 1`] = `[]`; 12 | 13 | exports[`Pet Store RequestBody It returns empty array for defined body content type if requestBody is not defined 1`] = `[]`; 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test-node: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [20.x, 22.x, 24.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: yarn install --frozen-lockfile 22 | - run: yarn lint 23 | - run: yarn build 24 | - run: yarn test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /__test__/option.spec.ts: -------------------------------------------------------------------------------- 1 | import ChowChow from '../src'; 2 | 3 | describe('Option Body', () => { 4 | it('throw with unknown format', async () => { 5 | const fixture = require('./fixtures/option-unknown-fmt.json'); 6 | 7 | await expect(ChowChow.create(fixture)).rejects.toMatchInlineSnapshot( 8 | `[Error: unknown format "pet-name" ignored in schema at path "#/properties/name"]` 9 | ); 10 | }); 11 | 12 | it('success with unknown format if unknown format is allowed', async () => { 13 | const fixture = require('./fixtures/option-unknown-fmt.json'); 14 | 15 | await expect( 16 | ChowChow.create(fixture, { 17 | responseBodyAjvOptions: { 18 | formats: { 19 | 'pet-name': true, 20 | }, 21 | }, 22 | }) 23 | ).resolves.toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /__test__/chow-error.spec.ts: -------------------------------------------------------------------------------- 1 | import ChowError from '../src/error'; 2 | 3 | describe('ChowError', () => { 4 | it('should be able to initialize a ChowError', () => { 5 | const chowError = new ChowError('An error', { in: 'test' }); 6 | expect(chowError.toJSON()).toMatchInlineSnapshot(` 7 | { 8 | "code": 400, 9 | "location": { 10 | "in": "test", 11 | }, 12 | "message": "An error", 13 | "suggestions": [], 14 | } 15 | `); 16 | }); 17 | 18 | it('should be able to output JSON format of the error', () => { 19 | const chowError = new ChowError('An error', { in: 'test' }); 20 | expect(chowError.toJSON()).toMatchInlineSnapshot(` 21 | { 22 | "code": 400, 23 | "location": { 24 | "in": "test", 25 | }, 26 | "message": "An error", 27 | "suggestions": [], 28 | } 29 | `); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "declaration": true, 5 | "module": "commonjs", 6 | "noFallthroughCasesInSwitch": true, 7 | "noImplicitAny": true, 8 | "outDir": "lib", 9 | "rootDir": "src", 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "target": "es2017" 14 | }, 15 | "include": [ 16 | "./src/**/*", 17 | "./typings.d.ts" 18 | ], 19 | "formatCodeOptions": { 20 | "convertTabsToSpaces": true, 21 | "indentSize": 2, 22 | "insertSpaceAfterCommaDelimiter": true, 23 | "insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, 24 | "insertSpaceAfterKeywordsInControlFlowStatements": true, 25 | "insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, 26 | "insertSpaceAfterSemicolonInForStatements": true, 27 | "insertSpaceBeforeAndAfterBinaryOperators": true, 28 | "newLineCharacter": "\n", 29 | "placeOpenBraceOnNewLineForControlBlocks": false, 30 | "placeOpenBraceOnNewLineForFunctions": false, 31 | "tabSize": 2 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /__test__/fixtures/deref-error.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.2", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Chow chow test", 6 | "description": "test", 7 | "contact": { 8 | "name": "test", 9 | "email": "test@test.com", 10 | "url": "http://test.com" 11 | }, 12 | "license": { 13 | "name": "Apache 2.0", 14 | "url": "https://www.apache.org/licenses/LICENSE-2.0.html" 15 | } 16 | }, 17 | "servers": [ 18 | { 19 | "url": "http://test.com", 20 | "description": "test" 21 | } 22 | ], 23 | "paths": { 24 | "/v1/test": { 25 | "get": { 26 | "description": "test", 27 | "operationId": "test", 28 | "responses": { 29 | "200": { 30 | "$ref": "#/components/schemas/TestSchema" 31 | } 32 | } 33 | } 34 | } 35 | }, 36 | "components": { 37 | "schemas": { 38 | "TestSchema": { 39 | "description": "test", 40 | "type": "object", 41 | "properties": { 42 | "test": { 43 | "type": { 44 | "$ref": "#/components/schemas/blahBlahBlah" 45 | }, 46 | "description": "test" 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/compiler/CompiledMediaType.ts: -------------------------------------------------------------------------------- 1 | import { MediaTypeObject } from 'openapi3-ts'; 2 | import * as Ajv from 'ajv'; 3 | import CompiledSchema from './CompiledSchema'; 4 | import ChowError from '../error'; 5 | 6 | export default class CompiledMediaType { 7 | private name: string; 8 | private compiledSchema: CompiledSchema; 9 | 10 | constructor(name: string, mediaType: MediaTypeObject, opts?: Ajv.Options) { 11 | this.name = name; 12 | this.compiledSchema = new CompiledSchema( 13 | mediaType.schema || {}, 14 | opts || {}, 15 | { schemaContext: 'response' } 16 | ); 17 | } 18 | 19 | public validate(value: any) { 20 | try { 21 | this.compiledSchema.validate(value); 22 | return value; 23 | } catch (e) { 24 | throw new ChowError('Schema validation error', { 25 | in: `media-type:${this.name}`, 26 | rawErrors: e as any, 27 | }); 28 | } 29 | } 30 | 31 | static extractMediaType(contentType: string | undefined): string | undefined { 32 | if (!contentType) { 33 | return; 34 | } 35 | 36 | return contentType.split(';')[0]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | pull-requests: write 12 | contents: write 13 | id-token: write 14 | name: Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repo 18 | uses: actions/checkout@v3 19 | 20 | - name: Setup Node.js 20.x 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 20.x 24 | 25 | - name: Install Dependencies 26 | run: yarn install --frozen-lockfile 27 | 28 | - name: Get Artifact Publish Token 29 | id: publish-token 30 | uses: atlassian-labs/artifact-publish-token@v1.0.1 31 | with: 32 | output-modes: npm 33 | 34 | - name: Create Release Pull Request or Publish to npm 35 | uses: changesets/action@v1 36 | with: 37 | # this expects you to have a script called release which does a build for your packages and calls changeset publish 38 | publish: yarn release 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_CONFIG_USERCONFIG: ./.npmrc-public -------------------------------------------------------------------------------- /src/compiler/CompiledParameterCookie.ts: -------------------------------------------------------------------------------- 1 | import { ParameterObject, SchemaObject } from 'openapi3-ts'; 2 | import CompiledSchema from './CompiledSchema'; 3 | import ChowError from '../error'; 4 | import { ChowOptions } from '..'; 5 | 6 | export default class CompiledParameterCookie { 7 | private compiledSchema: CompiledSchema; 8 | private cookieSchema: SchemaObject = { 9 | type: 'object', 10 | properties: {}, 11 | required: [], 12 | }; 13 | 14 | constructor(parameters: ParameterObject[], options: Partial) { 15 | for (const parameter of parameters) { 16 | this.cookieSchema.properties![parameter.name] = parameter.schema || {}; 17 | if (parameter.required) { 18 | this.cookieSchema.required!.push(parameter.name); 19 | } 20 | } 21 | 22 | this.compiledSchema = new CompiledSchema( 23 | this.cookieSchema, 24 | options.cookieAjvOptions 25 | ); 26 | } 27 | 28 | /** 29 | * If there is no query passed in, we make it an empty object 30 | */ 31 | public validate(value: any = {}) { 32 | try { 33 | this.compiledSchema.validate(value); 34 | return value; 35 | } catch (e) { 36 | throw new ChowError('Schema validation error', { 37 | in: 'cookie', 38 | rawErrors: e as any, 39 | }); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /__test__/chow-keywords.spec.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIObject } from 'openapi3-ts'; 2 | import ChowChow from '../src'; 3 | const doc: (additionalKeywords: Record) => OpenAPIObject = ( 4 | additionalKeywords = {} 5 | ) => ({ 6 | openapi: '3.0.1', 7 | info: { 8 | title: 'service open api spec', 9 | version: '1.0.1', 10 | }, 11 | components: { 12 | schemas: { 13 | ResolveUnsupportedError: { 14 | type: 'object', 15 | properties: { 16 | error: {}, 17 | }, 18 | ...additionalKeywords, 19 | }, 20 | }, 21 | }, 22 | paths: { 23 | '/resolve': { 24 | post: { 25 | operationId: 'resolve', 26 | responses: { 27 | '404': { 28 | content: { 29 | 'application/json': { 30 | schema: { 31 | $ref: '#/components/schemas/ResolveUnsupportedError', 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }); 41 | 42 | describe('additional open api keywords', () => { 43 | it.each([ 44 | { discriminator: '' }, 45 | { example: '' }, 46 | { externalDocs: '' }, 47 | { xml: '' }, 48 | ])('"%s" keyword should be allowed by default', async (additionalKeyword) => { 49 | expect(await ChowChow.create(doc(additionalKeyword))).toBeDefined(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import { IOutputError } from 'better-ajv-errors'; 2 | 3 | export interface ChowErrorMeta { 4 | in: string; 5 | rawErrors?: IOutputError[]; 6 | code?: number; 7 | } 8 | 9 | export default class ChowError extends Error { 10 | meta: ChowErrorMeta; 11 | 12 | constructor(message: string, meta: ChowErrorMeta) { 13 | // Pass remaining arguments (including vendor specific ones) to parent constructor 14 | super(message); 15 | this.name = this.constructor.name; 16 | 17 | // Custom debugging information 18 | this.meta = meta; 19 | } 20 | 21 | public toJSON() { 22 | return { 23 | code: this.meta.code || 400, 24 | location: { 25 | in: this.meta.in, 26 | }, 27 | message: this.message, 28 | suggestions: this.meta.rawErrors || [], 29 | }; 30 | } 31 | } 32 | 33 | export class RequestValidationError extends ChowError { 34 | constructor(message: string, meta: ChowErrorMeta) { 35 | // Pass remaining arguments (including vendor specific ones) to parent constructor 36 | super(`RequestValidationError: ${message}`, meta); 37 | } 38 | } 39 | export class ResponseValidationError extends ChowError { 40 | constructor(message: string, meta: ChowErrorMeta) { 41 | // Pass remaining arguments (including vendor specific ones) to parent constructor 42 | super(`ResponseValidationError: ${message}`, meta); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /__test__/query.spec.ts: -------------------------------------------------------------------------------- 1 | import ChowChow from '../src'; 2 | const fixture = require('./fixtures/query.json'); 3 | 4 | describe('Query', () => { 5 | let chowchow: ChowChow; 6 | 7 | beforeAll(async () => { 8 | chowchow = await ChowChow.create(fixture); 9 | }); 10 | 11 | it('should coerce query parameter to an array', () => { 12 | const queryMeta = { 13 | method: 'get', 14 | query: { 15 | petId: 123, 16 | }, 17 | }; 18 | expect(chowchow.validateRequestByPath('/pets', 'get', queryMeta)).toEqual( 19 | expect.objectContaining({ 20 | query: { 21 | petId: [123], 22 | }, 23 | }) 24 | ); 25 | expect(queryMeta.query.petId).toEqual(123); 26 | }); 27 | 28 | it('should validate query parameter with escaped url format', () => { 29 | const queryMeta = { 30 | method: 'get', 31 | query: { 32 | petId: 123, 33 | redirectUrl: 'http%3A%2F%2Fwww.atlassian.com%3A8901%2Fsuccess.html', 34 | }, 35 | }; 36 | expect(() => 37 | chowchow.validateRequestByPath('/pets', 'get', queryMeta) 38 | ).not.toThrowError(); 39 | }); 40 | 41 | it('should validate query parameter with url format', () => { 42 | const queryMeta = { 43 | method: 'get', 44 | query: { 45 | petId: 123, 46 | redirectUrl: 'http://www.atlassian.com:8901/success.html', 47 | }, 48 | }; 49 | expect(() => 50 | chowchow.validateRequestByPath('/pets', 'get', queryMeta) 51 | ).not.toThrowError(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/compiler/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIObject, PathItemObject } from 'openapi3-ts'; 2 | import CompiledPath from './CompiledPath'; 3 | import { ChowOptions } from '..'; 4 | import CompiledOperation from './CompiledOperation'; 5 | import { OperationRegisterFunc } from './CompiledPathItem'; 6 | 7 | export interface RequestMeta { 8 | query?: any; 9 | header?: any; 10 | path?: any; 11 | cookie?: any; 12 | body?: any; 13 | operationId?: string; 14 | } 15 | 16 | export interface ResponseMeta { 17 | status: number; 18 | header?: any; 19 | body?: any; 20 | } 21 | 22 | export default function compile( 23 | document: OpenAPIObject, 24 | options: Partial 25 | ): { 26 | compiledPaths: CompiledPath[]; 27 | compiledOperationById: Map; 28 | } { 29 | const compiledOperationById = new Map(); 30 | const registerOperationById: OperationRegisterFunc = ( 31 | operationId: string, 32 | compiledOperation: CompiledOperation 33 | ) => { 34 | compiledOperationById.set(operationId, compiledOperation); 35 | }; 36 | 37 | const compiledPaths = Object.keys(document.paths).map((path: string) => { 38 | const pathItemObject: PathItemObject = document.paths[path]; 39 | 40 | // TODO: support for base path 41 | return new CompiledPath(path, pathItemObject, { 42 | ...options, 43 | registerCompiledOperationWithId: registerOperationById, 44 | }); 45 | }); 46 | 47 | return { 48 | compiledPaths, 49 | compiledOperationById, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/compiler/CompiledParameterPath.ts: -------------------------------------------------------------------------------- 1 | import { ParameterObject, SchemaObject } from 'openapi3-ts'; 2 | import CompiledSchema from './CompiledSchema'; 3 | import ChowError from '../error'; 4 | import { ChowOptions } from '..'; 5 | 6 | export default class CompiledParameterPath { 7 | private compiledSchema: CompiledSchema; 8 | private pathSchema: SchemaObject = { 9 | type: 'object', 10 | properties: {}, 11 | required: [], 12 | }; 13 | 14 | constructor(parameters: ParameterObject[], options: Partial) { 15 | for (const parameter of parameters) { 16 | this.pathSchema.properties![parameter.name] = parameter.schema || {}; 17 | /** 18 | * All path parameters are required 19 | */ 20 | this.pathSchema.required!.push(parameter.name); 21 | } 22 | 23 | /** 24 | * We want query to coerce to array if needed 25 | * For example: 26 | * `/pets/123` will be valid against a schema with type=number even if `123` is string 27 | */ 28 | this.compiledSchema = new CompiledSchema(this.pathSchema, { 29 | coerceTypes: true, 30 | ...(options.pathAjvOptions ? options.pathAjvOptions : {}), 31 | }); 32 | } 33 | 34 | /** 35 | * If there is no path passed in, we make it an empty object 36 | */ 37 | public validate(value: any) { 38 | try { 39 | const coercedValue = { ...value }; 40 | this.compiledSchema.validate(coercedValue); 41 | return coercedValue; 42 | } catch (e) { 43 | throw new ChowError('Schema validation error', { 44 | in: 'path', 45 | rawErrors: e as any, 46 | }); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atlassian/oas3-chow-chow", 3 | "version": "5.0.0", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "author": "", 7 | "license": "Apache-2.0", 8 | "publishConfig": { 9 | "registry": "https://packages.atlassian.com/api/npm/npm-public/" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/atlassian/oas3-chow-chow.git" 14 | }, 15 | "scripts": { 16 | "build": "tsc", 17 | "test": "jest --coverage --no-watchman", 18 | "test:watch": "jest --watch", 19 | "test:watch:debug": "node --inspect node_modules/.bin/jest --runInBand --watch", 20 | "lint": "yarn lint:prettier", 21 | "lint:prettier": "prettier src \"__test__/**/*.ts\" --check", 22 | "fix:prettier": "yarn lint:prettier --write", 23 | "prepublishOnly": "rm -rf lib && yarn build", 24 | "release": "yarn prepublishOnly && changeset publish" 25 | }, 26 | "prettier": { 27 | "singleQuote": true 28 | }, 29 | "dependencies": { 30 | "@apidevtools/json-schema-ref-parser": "^11.1.0", 31 | "ajv": "^8.12.0", 32 | "ajv-formats": "^2.1.1", 33 | "better-ajv-errors": "1.2.0", 34 | "oas-validator": "^5.0.8", 35 | "openapi3-ts": "^1.3.0", 36 | "xregexp": "^4.2.4" 37 | }, 38 | "devDependencies": { 39 | "@changesets/cli": "2.26.2", 40 | "@types/jest": "29.5.4", 41 | "@types/node": "18.19.64", 42 | "@types/xregexp": "3.0.30", 43 | "babel-core": "6.26.3", 44 | "babel-jest": "^29.7.0", 45 | "jest": "^29.7.0", 46 | "prettier": "2.2.1", 47 | "ts-jest": "^29.1.1", 48 | "typescript": "5.6.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /__test__/fixtures/option-unknown-fmt.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Option Body Fixture", 6 | "license": { 7 | "name": "MIT" 8 | } 9 | }, 10 | "servers": [ 11 | { 12 | "url": "http://petstore.swagger.io/v1" 13 | } 14 | ], 15 | "paths": { 16 | "/pets/{petId}": { 17 | "get": { 18 | "summary": "Info for a specific pet", 19 | "operationId": "showPetById", 20 | "tags": ["pets"], 21 | "parameters": [ 22 | { 23 | "name": "petId", 24 | "in": "path", 25 | "required": true, 26 | "description": "The id of the pet to retrieve", 27 | "schema": { 28 | "type": "integer" 29 | } 30 | } 31 | ], 32 | "responses": { 33 | "200": { 34 | "description": "Expected response to a valid request", 35 | "content": { 36 | "application/json": { 37 | "schema": { 38 | "$ref": "#/components/schemas/Pet" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | }, 47 | "components": { 48 | "schemas": { 49 | "Pet": { 50 | "type": "object", 51 | "required": ["id", "name"], 52 | "properties": { 53 | "id": { 54 | "type": "integer", 55 | "format": "int64" 56 | }, 57 | "name": { 58 | "type": "string", 59 | "format": "pet-name" 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/compiler/CompiledParameterHeader.ts: -------------------------------------------------------------------------------- 1 | import { ParameterObject, SchemaObject } from 'openapi3-ts'; 2 | import CompiledSchema from './CompiledSchema'; 3 | import ChowError from '../error'; 4 | import { ChowOptions } from '..'; 5 | 6 | export default class CompiledParameterHeader { 7 | private compiledSchema: CompiledSchema; 8 | private headerSchema: SchemaObject = { 9 | type: 'object', 10 | properties: {}, 11 | required: [], 12 | // All header properties should be a string, no? 13 | additionalProperties: { type: 'string' }, 14 | }; 15 | /** 16 | * If in is "header" and the name field is "Accept", "Content-Type" or "Authorization", 17 | * the parameter definition SHALL be ignored. 18 | */ 19 | private ignoreHeaders = [ 20 | 'Accept', 21 | 'Content-Type', 22 | 'Authorization', 23 | ].map((header) => header.toLowerCase()); 24 | 25 | constructor(parameters: ParameterObject[], options: Partial) { 26 | for (const parameter of parameters) { 27 | const headerName = parameter.name.toLowerCase(); 28 | if (this.ignoreHeaders.includes(headerName)) { 29 | continue; 30 | } 31 | 32 | this.headerSchema.properties![headerName] = parameter.schema || {}; 33 | 34 | if (parameter.required) { 35 | this.headerSchema.required!.push(headerName); 36 | } 37 | } 38 | 39 | this.compiledSchema = new CompiledSchema( 40 | this.headerSchema, 41 | options.headerAjvOptions 42 | ); 43 | } 44 | 45 | /** 46 | * If there is no header passed in, we make it an empty object 47 | */ 48 | public validate(value: any = {}) { 49 | try { 50 | this.compiledSchema.validate(value); 51 | return value; 52 | } catch (e) { 53 | throw new ChowError('Schema validation error', { 54 | in: 'header', 55 | rawErrors: e as any, 56 | }); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/compiler/CompiledResponse.ts: -------------------------------------------------------------------------------- 1 | import { ResponseObject, HeaderObject, MediaTypeObject } from 'openapi3-ts'; 2 | import ChowError from '../error'; 3 | import CompiledResponseHeader from './CompiledResponseHeader'; 4 | import { ResponseMeta } from '.'; 5 | import CompiledMediaType from './CompiledMediaType'; 6 | import { ChowOptions } from '..'; 7 | 8 | export default class CompiledResponse { 9 | private compiledResponseHeader: CompiledResponseHeader; 10 | private content: { 11 | [key: string]: CompiledMediaType; 12 | } = {}; 13 | 14 | constructor(response: ResponseObject, options: Partial) { 15 | this.compiledResponseHeader = new CompiledResponseHeader( 16 | response.headers, 17 | options 18 | ); 19 | 20 | if (response.content) { 21 | this.content = Object.keys(response.content).reduce( 22 | (compiled: any, name: string) => { 23 | compiled[name] = new CompiledMediaType( 24 | name, 25 | response.content![name] as MediaTypeObject, 26 | options.responseBodyAjvOptions || {} 27 | ); 28 | return compiled; 29 | }, 30 | {} 31 | ); 32 | } 33 | } 34 | 35 | public validate(response: ResponseMeta) { 36 | this.compiledResponseHeader.validate(response.header); 37 | 38 | const contentType = CompiledMediaType.extractMediaType( 39 | response.header ? response.header['content-type'] : undefined 40 | ); 41 | /** 42 | * In the case where there is no Content-Type header. For example 204 status. 43 | */ 44 | if (!contentType) { 45 | return response.body; 46 | } 47 | 48 | if (this.content[contentType]) { 49 | return this.content[contentType].validate(response.body); 50 | } else { 51 | throw new ChowError('Unsupported Response Media Type', { 52 | in: 'response', 53 | }); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/compiler/CompiledResponseHeader.ts: -------------------------------------------------------------------------------- 1 | import { HeadersObject, SchemaObject, HeaderObject } from 'openapi3-ts'; 2 | import CompiledSchema from './CompiledSchema'; 3 | import ChowError from '../error'; 4 | import { ChowOptions } from '..'; 5 | 6 | export default class CompiledResponseHeader { 7 | private compiledSchema: CompiledSchema; 8 | private headerSchema: SchemaObject = { 9 | type: 'object', 10 | properties: {}, 11 | required: [], 12 | }; 13 | /** 14 | * If a response header is defined with the name "Content-Type", it SHALL be ignored. 15 | */ 16 | private ignoreHeaders = ['Content-Type']; 17 | 18 | constructor(headers: HeadersObject = {}, options: Partial) { 19 | for (const name in headers) { 20 | if (this.ignoreHeaders.includes(name)) { 21 | continue; 22 | } 23 | 24 | const headerNameLower = name.toLowerCase(); 25 | const header = headers[name] as HeaderObject; 26 | 27 | if (header.schema) { 28 | this.headerSchema.properties![headerNameLower] = header.schema; 29 | } 30 | 31 | if (header.required) { 32 | this.headerSchema.required!.push(headerNameLower); 33 | } 34 | } 35 | this.compiledSchema = new CompiledSchema( 36 | this.headerSchema, 37 | options.headerAjvOptions || {} 38 | ); 39 | } 40 | 41 | public validate(header: any = {}) { 42 | try { 43 | // Before validation, lowercase the header name, since header name is also lowercased in compiledSchema 44 | const newHeader = Object.entries(header).reduce( 45 | (newObject: any, [key, value]) => { 46 | newObject[key.toLowerCase()] = value; 47 | return newObject; 48 | }, 49 | {} 50 | ); 51 | 52 | this.compiledSchema.validate(newHeader); 53 | } catch (e) { 54 | throw new ChowError('Schema validation error', { 55 | in: 'response-header', 56 | rawErrors: e as any, 57 | }); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/compiler/CompiledParameterQuery.ts: -------------------------------------------------------------------------------- 1 | import { ParameterObject, SchemaObject } from 'openapi3-ts'; 2 | import CompiledSchema from './CompiledSchema'; 3 | import ChowError from '../error'; 4 | import * as querystring from 'querystring'; 5 | import { ChowOptions } from '..'; 6 | 7 | export default class CompiledParameterQuery { 8 | private compiledSchema: CompiledSchema; 9 | private querySchema: SchemaObject = { 10 | type: 'object', 11 | properties: {}, 12 | required: [], 13 | }; 14 | 15 | constructor(parameters: ParameterObject[], options: Partial) { 16 | for (const parameter of parameters) { 17 | this.querySchema.properties![parameter.name] = parameter.schema || {}; 18 | if (parameter.required) { 19 | this.querySchema.required!.push(parameter.name); 20 | } 21 | } 22 | 23 | /** 24 | * We want path to coerce type in general 25 | * For example: 26 | * `?query=x` will be valid against a schema with type=array 27 | */ 28 | this.compiledSchema = new CompiledSchema(this.querySchema, { 29 | coerceTypes: 'array', 30 | ...(options.queryAjvOptions ? options.queryAjvOptions : {}), 31 | }); 32 | } 33 | 34 | /** 35 | * If there is no query passed in, we make it an empty object 36 | */ 37 | public validate(value: any = {}) { 38 | try { 39 | /** 40 | * unescape the query if neccessary 41 | */ 42 | const coercedValue = Object.keys(value).reduce((result: any, key) => { 43 | if (typeof value[key] === 'string') { 44 | result[key] = querystring.unescape(value[key]); 45 | } else { 46 | result[key] = value[key]; 47 | } 48 | return result; 49 | }, {}); 50 | this.compiledSchema.validate(coercedValue); 51 | return coercedValue; 52 | } catch (e) { 53 | throw new ChowError('Schema validation error', { 54 | in: 'query', 55 | rawErrors: e as any, 56 | }); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/compiler/CompiledSchema.ts: -------------------------------------------------------------------------------- 1 | import { SchemaObject } from 'openapi3-ts'; 2 | import * as Ajv from 'ajv'; 3 | import betterAjvErrors from 'better-ajv-errors'; 4 | import ajv from './ajv'; 5 | 6 | export default class CompiledSchema { 7 | private schemaObject?: SchemaObject; 8 | private validator: Ajv.ValidateFunction; 9 | 10 | constructor(schema: SchemaObject, opts?: Ajv.Options, context?: any) { 11 | /** 12 | * Remove unsupported additional OpenAPI keywords. 13 | * https://swagger.io/docs/specification/data-models/keywords/ See "Additional Keywords" 14 | * Does not include all keywords listed in that page/section because some of them are supported by ajv https://ajv.js.org/json-schema.html#openapi-support 15 | * and some are explictly added within this class. 16 | */ 17 | const { 18 | discriminator, 19 | example, 20 | externalDocs, 21 | xml, 22 | ...schemaObject 23 | } = schema; 24 | this.schemaObject = schemaObject; 25 | 26 | const ajvInstance = ajv(opts); 27 | ajvInstance.removeKeyword('writeOnly'); 28 | ajvInstance.removeKeyword('readOnly'); 29 | ajvInstance.addKeyword({ 30 | keyword: 'writeOnly', 31 | validate: (schema: any) => 32 | schema ? context.schemaContext === 'request' : true, 33 | }); 34 | ajvInstance.addKeyword({ 35 | keyword: 'readOnly', 36 | validate: (schema: any) => 37 | schema ? context.schemaContext === 'response' : true, 38 | }); 39 | ajvInstance.addKeyword({ 40 | keyword: 'example', 41 | metaSchema: { 42 | type: ['object', 'array', 'string', 'number', 'boolean', 'null'], 43 | }, 44 | }); 45 | this.validator = ajvInstance.compile(schemaObject); 46 | } 47 | 48 | public validate(value: any) { 49 | const valid = this.validator(value); 50 | if (!valid) { 51 | const errors = betterAjvErrors( 52 | this.schemaObject, 53 | value || '', 54 | this.validator.errors!, 55 | { format: 'js', indent: 2 } 56 | ); 57 | /** 58 | * In the case where betterAjvErrors accidently return 0 errors 59 | * we return raw errors 60 | */ 61 | if (Array.isArray(errors) && errors.length > 0) { 62 | throw errors; 63 | } 64 | throw this.validator.errors; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/compiler/CompiledPath.ts: -------------------------------------------------------------------------------- 1 | import { PathItemObject } from 'openapi3-ts'; 2 | import CompiledPathItem, { OperationRegisterFunc } from './CompiledPathItem'; 3 | import { RequestMeta, ResponseMeta } from '.'; 4 | import * as XRegExp from 'xregexp'; 5 | import { ChowOptions } from '..'; 6 | 7 | interface PathParameters { 8 | [key: string]: string; 9 | } 10 | 11 | export default class CompiledPath { 12 | private path: string; 13 | private regex: RegExp; 14 | private compiledPathItem: CompiledPathItem; 15 | private ignoredMatches = ['index', 'input']; 16 | 17 | constructor( 18 | path: string, 19 | pathItemObject: PathItemObject, 20 | options: Partial< 21 | ChowOptions & { registerCompiledOperationWithId: OperationRegisterFunc } 22 | > 23 | ) { 24 | this.path = path; 25 | /** 26 | * The following statement should create Named Capturing Group for 27 | * each path parameter, for example 28 | * /pets/{petId} => ^/pets/(?[^/]+)/?$ 29 | */ 30 | (this.regex = XRegExp( 31 | '^' + path.replace(/\{([^}]*)}/g, '(?<$1>[^/]+)') + '/?$' 32 | )), 33 | (this.compiledPathItem = new CompiledPathItem( 34 | pathItemObject, 35 | path, 36 | options 37 | )); 38 | } 39 | 40 | public getDefinedRequestBodyContentType(method: string): string[] { 41 | return this.compiledPathItem.getDefinedRequestBodyContentType(method); 42 | } 43 | 44 | public test(path: string): boolean { 45 | return XRegExp.test(path, this.regex); 46 | } 47 | 48 | public validateRequest(path: string, method: string, request: RequestMeta) { 49 | return this.compiledPathItem.validateRequest(method, { 50 | ...request, 51 | path: this.extractPathParams(path), 52 | }); 53 | } 54 | 55 | public validateResponse(method: string, response: ResponseMeta) { 56 | return this.compiledPathItem.validateResponse(method, response); 57 | } 58 | 59 | private extractPathParams = (path: string): PathParameters => { 60 | const matches = XRegExp.exec(path, this.regex); 61 | 62 | // extract path parameters 63 | return Object.keys(matches) 64 | .filter(this.matchFilter.bind(this)) 65 | .reduce((obj, key) => { 66 | return { 67 | ...obj, 68 | [key]: matches[key as any], 69 | }; 70 | }, {}); 71 | }; 72 | 73 | private matchFilter = (key: string) => { 74 | return isNaN(parseInt(key)) && !this.ignoredMatches.includes(key); 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /__test__/fixtures/parameter-in-path-level.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Query Fixture", 6 | "license": { 7 | "name": "MIT" 8 | } 9 | }, 10 | "servers": [ 11 | { 12 | "url": "http://petstore.swagger.io/v1" 13 | } 14 | ], 15 | "paths": { 16 | "/pets/{petId}": { 17 | "parameters": [ 18 | { 19 | "name": "petId", 20 | "in": "path", 21 | "required": true, 22 | "description": "The id of the pet to retrieve", 23 | "schema": { 24 | "type": "integer" 25 | } 26 | } 27 | ], 28 | "get": { 29 | "summary": "Info for a specific pet", 30 | "operationId": "showPetById", 31 | "tags": [ 32 | "pets" 33 | ], 34 | "responses": { 35 | "200": { 36 | "description": "Expected response to a valid request", 37 | "content": { 38 | "application/json": { 39 | "schema": { 40 | "$ref": "#/components/schemas/Pets" 41 | } 42 | } 43 | } 44 | }, 45 | "default": { 46 | "description": "unexpected error", 47 | "content": { 48 | "application/json": { 49 | "schema": { 50 | "$ref": "#/components/schemas/Error" 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | } 58 | }, 59 | "components": { 60 | "schemas": { 61 | "Pet": { 62 | "type": "object", 63 | "required": [ 64 | "id", 65 | "name" 66 | ], 67 | "properties": { 68 | "id": { 69 | "type": "integer", 70 | "format": "int64" 71 | }, 72 | "name": { 73 | "type": "string" 74 | }, 75 | "tag": { 76 | "type": "string" 77 | } 78 | } 79 | }, 80 | "Pets": { 81 | "type": "array", 82 | "items": { 83 | "$ref": "#/components/schemas/Pet" 84 | } 85 | }, 86 | "Error": { 87 | "type": "object", 88 | "required": [ 89 | "code", 90 | "message" 91 | ], 92 | "properties": { 93 | "code": { 94 | "type": "integer", 95 | "format": "int32" 96 | }, 97 | "message": { 98 | "type": "string" 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /src/compiler/CompiledRequestBody.ts: -------------------------------------------------------------------------------- 1 | import { RequestBodyObject } from 'openapi3-ts'; 2 | import CompiledSchema from './CompiledSchema'; 3 | import ChowError from '../error'; 4 | import { ChowOptions } from '..'; 5 | 6 | export default class CompiledRequestBody { 7 | private compiledSchemas: { 8 | [key: string]: CompiledSchema; 9 | }; 10 | private required: boolean; 11 | 12 | constructor(requestBody: RequestBodyObject, options: Partial) { 13 | this.compiledSchemas = Object.keys(requestBody.content).reduce( 14 | (compiled: any, mediaType: string) => { 15 | const key = mediaType.toLowerCase(); // normalise 16 | compiled[key] = new CompiledSchema( 17 | requestBody.content[mediaType].schema || {}, 18 | options.requestBodyAjvOptions, 19 | { schemaContext: 'request' } 20 | ); 21 | return compiled; 22 | }, 23 | {} 24 | ); 25 | this.required = !!requestBody.required; 26 | } 27 | 28 | public validate(mediaType: string | undefined, value: any) { 29 | if (this.required && !value) { 30 | throw new ChowError('Missing required body', { in: 'request-body' }); 31 | } 32 | if (!this.required && !value) { 33 | return value; 34 | } 35 | const compiledSchema = this.findCompiledSchema(mediaType); 36 | if (!compiledSchema) { 37 | throw new ChowError(`Unsupported mediaType: "${mediaType}"`, { 38 | in: 'request-body', 39 | }); 40 | } 41 | 42 | try { 43 | compiledSchema.validate(value); 44 | return value; 45 | } catch (e) { 46 | throw new ChowError('Schema validation error', { 47 | in: 'request-body', 48 | rawErrors: e as any, 49 | }); 50 | } 51 | } 52 | 53 | public getDefinedContentTypes(): string[] { 54 | return Object.keys(this.compiledSchemas).filter((type) => 55 | this.compiledSchemas.hasOwnProperty(type) 56 | ); 57 | } 58 | 59 | private findCompiledSchema( 60 | mediaType: string | undefined 61 | ): CompiledSchema | undefined { 62 | if (!mediaType) { 63 | mediaType = '*/*'; 64 | } 65 | mediaType = mediaType.toLowerCase(); // normalise 66 | if (this.compiledSchemas[mediaType]) { 67 | return this.compiledSchemas[mediaType]; 68 | } 69 | // try wildcard 70 | const parts = mediaType.split('/'); 71 | // mediaTypeRange name taken from https://github.com/OAI/OpenAPI-Specification/pull/1295/files 72 | const mediaTypeRange = parts[0] + '/*'; 73 | if (this.compiledSchemas[mediaTypeRange]) { 74 | return this.compiledSchemas[mediaTypeRange]; 75 | } 76 | return this.compiledSchemas['*/*']; // last choice, may be undefined. 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/compiler/CompiledPathItem.ts: -------------------------------------------------------------------------------- 1 | import { PathItemObject, ParameterObject } from 'openapi3-ts'; 2 | import CompiledOperation from './CompiledOperation'; 3 | import { RequestMeta, ResponseMeta } from '.'; 4 | import ChowError from '../error'; 5 | import { ChowOptions } from '..'; 6 | 7 | export type OperationRegisterFunc = ( 8 | operationId: string, 9 | compiledOperation: CompiledOperation 10 | ) => void; 11 | 12 | export default class CompiledPathItem { 13 | static readonly SupportedMethod = [ 14 | 'get', 15 | 'post', 16 | 'put', 17 | 'patch', 18 | 'delete', 19 | 'head', 20 | 'options', 21 | 'trace', 22 | ]; 23 | private compiledOperationsByMethod: Map< 24 | string, 25 | CompiledOperation 26 | > = new Map(); 27 | private path: string; 28 | 29 | constructor( 30 | pathItemObject: PathItemObject, 31 | path: string, 32 | options: Partial< 33 | ChowOptions & { registerCompiledOperationWithId: OperationRegisterFunc } 34 | > 35 | ) { 36 | CompiledPathItem.SupportedMethod.forEach((method) => { 37 | const operationObject = pathItemObject[method]; 38 | 39 | if (!operationObject) { 40 | return; 41 | } 42 | 43 | const compiledOperation = new CompiledOperation( 44 | operationObject, 45 | (pathItemObject.parameters as ParameterObject[]) || [], 46 | options 47 | ); 48 | this.compiledOperationsByMethod.set(method, compiledOperation); 49 | 50 | if ( 51 | operationObject.operationId && 52 | options.registerCompiledOperationWithId 53 | ) { 54 | options.registerCompiledOperationWithId( 55 | operationObject.operationId, 56 | compiledOperation 57 | ); 58 | } 59 | }); 60 | 61 | this.path = path; 62 | } 63 | 64 | public getDefinedRequestBodyContentType(method: string): string[] { 65 | const m = method.toLowerCase(); 66 | 67 | const compiledOperation = this.compiledOperationsByMethod.get(m); 68 | return !!compiledOperation 69 | ? compiledOperation.getDefinedRequestBodyContentType() 70 | : []; 71 | } 72 | 73 | public validateRequest(method: string, request: RequestMeta) { 74 | const mt = method.toLowerCase(); 75 | const compiledOperation = this.compiledOperationsByMethod.get(mt); 76 | if (!compiledOperation) { 77 | throw new ChowError(`Invalid request method - ${mt}`, { in: 'path' }); 78 | } 79 | 80 | return compiledOperation.validateRequest(request); 81 | } 82 | 83 | public validateResponse(method: string, response: ResponseMeta) { 84 | const mt = method.toLowerCase(); 85 | const compiledOperation = this.compiledOperationsByMethod.get(mt); 86 | if (!compiledOperation) { 87 | throw new ChowError(`Invalid request method - ${mt}`, { in: 'path' }); 88 | } 89 | 90 | return compiledOperation.validateResponse(response); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /__test__/path.spec.ts: -------------------------------------------------------------------------------- 1 | import ChowChow from '../src'; 2 | import ChowError from '../src/error'; 3 | const pathFixture = require('./fixtures/path.json'); 4 | const parameterInPathLevelFixture = require('./fixtures/parameter-in-path-level.json'); 5 | const parameterInBothOperationAndPathFixture = require('./fixtures/parameter-in-both-operation-and-path-level.json'); 6 | 7 | describe('Path', () => { 8 | describe('Parameter in Operation level', () => { 9 | let chowchow: ChowChow; 10 | 11 | beforeAll(async () => { 12 | chowchow = await ChowChow.create(pathFixture); 13 | }); 14 | 15 | it('should validate the path parameters and coerce to the correct type', () => { 16 | const pathMeta = {}; 17 | expect( 18 | chowchow.validateRequestByPath('/pets/123', 'get', pathMeta) 19 | ).toEqual( 20 | expect.objectContaining({ 21 | path: { 22 | petId: 123, 23 | }, 24 | }) 25 | ); 26 | }); 27 | 28 | it('should throw error if path parameter fails schema check', () => { 29 | expect(() => { 30 | chowchow.validateRequestByPath('/pets/abc', 'get', {}); 31 | }).toThrowError(ChowError); 32 | }); 33 | }); 34 | 35 | describe('Parameter in Path level', () => { 36 | let chowchow: ChowChow; 37 | 38 | beforeAll(async () => { 39 | chowchow = await ChowChow.create(parameterInPathLevelFixture); 40 | }); 41 | 42 | it('should validate the path parameters and coerce to the correct type', () => { 43 | const pathMeta = {}; 44 | expect( 45 | chowchow.validateRequestByPath('/pets/123', 'get', pathMeta) 46 | ).toEqual( 47 | expect.objectContaining({ 48 | path: { 49 | petId: 123, 50 | }, 51 | }) 52 | ); 53 | }); 54 | 55 | it('should throw error if path parameter fails schema check', () => { 56 | expect(() => { 57 | chowchow.validateRequestByPath('/pets/abc', 'get', {}); 58 | }).toThrowError(ChowError); 59 | }); 60 | }); 61 | 62 | describe('Parameter in Operation level should override Path level', () => { 63 | let chowchow: ChowChow; 64 | 65 | beforeAll(async () => { 66 | chowchow = await ChowChow.create(parameterInBothOperationAndPathFixture); 67 | }); 68 | 69 | it('should validate the path parameters and coerce to the correct type', () => { 70 | const pathMeta = {}; 71 | expect( 72 | chowchow.validateRequestByPath('/pets/123', 'get', pathMeta) 73 | ).toEqual( 74 | expect.objectContaining({ 75 | path: { 76 | petId: 123, 77 | }, 78 | }) 79 | ); 80 | }); 81 | 82 | it('should throw error if path parameter fails schema check', () => { 83 | expect(() => { 84 | chowchow.validateRequestByPath('/pets/abc', 'get', {}); 85 | }).toThrowError(ChowError); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /__test__/fixtures/parameter-in-both-operation-and-path-level.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Query Fixture", 6 | "license": { 7 | "name": "MIT" 8 | } 9 | }, 10 | "servers": [ 11 | { 12 | "url": "http://petstore.swagger.io/v1" 13 | } 14 | ], 15 | "paths": { 16 | "/pets/{petId}": { 17 | "parameters": [ 18 | { 19 | "name": "petId", 20 | "in": "path", 21 | "required": true, 22 | "description": "The id of the pet to retrieve", 23 | "schema": { 24 | "type": "string" 25 | } 26 | } 27 | ], 28 | "get": { 29 | "summary": "Info for a specific pet", 30 | "operationId": "showPetById", 31 | "tags": [ 32 | "pets" 33 | ], 34 | "parameters": [ 35 | { 36 | "name": "petId", 37 | "in": "path", 38 | "required": true, 39 | "description": "The id of the pet to retrieve", 40 | "schema": { 41 | "type": "integer" 42 | } 43 | } 44 | ], 45 | "responses": { 46 | "200": { 47 | "description": "Expected response to a valid request", 48 | "content": { 49 | "application/json": { 50 | "schema": { 51 | "$ref": "#/components/schemas/Pets" 52 | } 53 | } 54 | } 55 | }, 56 | "default": { 57 | "description": "unexpected error", 58 | "content": { 59 | "application/json": { 60 | "schema": { 61 | "$ref": "#/components/schemas/Error" 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | }, 70 | "components": { 71 | "schemas": { 72 | "Pet": { 73 | "type": "object", 74 | "required": [ 75 | "id", 76 | "name" 77 | ], 78 | "properties": { 79 | "id": { 80 | "type": "integer", 81 | "format": "int64" 82 | }, 83 | "name": { 84 | "type": "string" 85 | }, 86 | "tag": { 87 | "type": "string" 88 | } 89 | } 90 | }, 91 | "Pets": { 92 | "type": "array", 93 | "items": { 94 | "$ref": "#/components/schemas/Pet" 95 | } 96 | }, 97 | "Error": { 98 | "type": "object", 99 | "required": [ 100 | "code", 101 | "message" 102 | ], 103 | "properties": { 104 | "code": { 105 | "type": "integer", 106 | "format": "int32" 107 | }, 108 | "message": { 109 | "type": "string" 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /__test__/fixtures/query.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Query Fixture", 6 | "license": { 7 | "name": "MIT" 8 | } 9 | }, 10 | "servers": [ 11 | { 12 | "url": "http://petstore.swagger.io/v1" 13 | } 14 | ], 15 | "paths": { 16 | "/pets": { 17 | "get": { 18 | "summary": "Info for pets", 19 | "operationId": "showPetById", 20 | "tags": [ 21 | "pets" 22 | ], 23 | "parameters": [ 24 | { 25 | "name": "petId", 26 | "in": "query", 27 | "required": true, 28 | "description": "The id of the pet to retrieve", 29 | "schema": { 30 | "type": "array", 31 | "items": { 32 | "type": "number" 33 | } 34 | } 35 | }, 36 | { 37 | "name": "redirectUrl", 38 | "in": "query", 39 | "description": "to test format validation", 40 | "required": false, 41 | "schema": { 42 | "type": "string", 43 | "format": "url" 44 | } 45 | } 46 | ], 47 | "responses": { 48 | "200": { 49 | "description": "Expected response to a valid request", 50 | "content": { 51 | "application/json": { 52 | "schema": { 53 | "$ref": "#/components/schemas/Pets" 54 | } 55 | } 56 | } 57 | }, 58 | "default": { 59 | "description": "unexpected error", 60 | "content": { 61 | "application/json": { 62 | "schema": { 63 | "$ref": "#/components/schemas/Error" 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | }, 72 | "components": { 73 | "schemas": { 74 | "Pet": { 75 | "type": "object", 76 | "required": [ 77 | "id", 78 | "name" 79 | ], 80 | "properties": { 81 | "id": { 82 | "type": "integer", 83 | "format": "int64" 84 | }, 85 | "name": { 86 | "type": "string" 87 | }, 88 | "tag": { 89 | "type": "string" 90 | } 91 | } 92 | }, 93 | "Pets": { 94 | "type": "array", 95 | "items": { 96 | "$ref": "#/components/schemas/Pet" 97 | } 98 | }, 99 | "Error": { 100 | "type": "object", 101 | "required": [ 102 | "code", 103 | "message" 104 | ], 105 | "properties": { 106 | "code": { 107 | "type": "integer", 108 | "format": "int32" 109 | }, 110 | "message": { 111 | "type": "string" 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /__test__/chow-strict.spec.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIObject, PathItemObject } from 'openapi3-ts'; 2 | import ChowChow from '../src'; 3 | 4 | /** 5 | * https://ajv.js.org/strict-mode.html 6 | */ 7 | describe('strict mode', () => { 8 | it('show log warn and not throw by default', async () => { 9 | const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); 10 | const doc: OpenAPIObject = { 11 | openapi: '3.0.1', 12 | info: { 13 | title: 'service open api spec', 14 | version: '1.0.1', 15 | }, 16 | components: { 17 | schemas: { 18 | ResolveUnsupportedError: { 19 | type: 'array', 20 | additionalItems: false, 21 | }, 22 | }, 23 | }, 24 | paths: { 25 | '/resolve': { 26 | post: { 27 | operationId: 'resolve', 28 | responses: { 29 | '404': { 30 | content: { 31 | 'application/json': { 32 | schema: { 33 | $ref: '#/components/schemas/ResolveUnsupportedError', 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }; 43 | expect(await ChowChow.create(doc)).toBeDefined(); 44 | expect(warnSpy).toHaveBeenCalledWith( 45 | 'strict mode: "additionalItems" is ignored when "items" is not an array of schemas' 46 | ); 47 | warnSpy.mockRestore(); 48 | }); 49 | 50 | it('should not log warning about example keyword', async () => { 51 | const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); 52 | const doc: OpenAPIObject = { 53 | openapi: '3.0.1', 54 | info: { 55 | title: 'service open api spec', 56 | version: '1.0.1', 57 | }, 58 | components: { 59 | schemas: { 60 | ResolveUnsupportedError: { 61 | type: 'object', 62 | description: 'some description', 63 | properties: { 64 | error: { 65 | type: 'object', 66 | properties: { 67 | message: { 68 | type: 'string', 69 | example: 'some example', 70 | }, 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | paths: { 78 | '/resolve': { 79 | post: { 80 | operationId: 'resolve', 81 | responses: { 82 | '404': { 83 | content: { 84 | 'application/json': { 85 | schema: { 86 | $ref: '#/components/schemas/ResolveUnsupportedError', 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | }; 96 | expect(await ChowChow.create(doc)).toBeDefined(); 97 | expect(warnSpy).not.toHaveBeenCalledWith( 98 | 'strict mode: unknown keyword: "example"' 99 | ); 100 | warnSpy.mockRestore(); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # oas3-chow-chow 2 | 3 | ## 5.0.0 4 | 5 | ### Major Changes 6 | 7 | - 01c070a: Migrate to atlassian scope 8 | 9 | ## 4.0.0 10 | 11 | ### Major Changes 12 | 13 | - bc9d442: Remove support for Node 16 and add support for 20 and 22. 14 | 15 | ### Patch Changes 16 | 17 | - 1262224: Prevent example keyword from causing warning logs when present in openapi spec 18 | 19 | ## 3.0.2 20 | 21 | ### Patch Changes 22 | 23 | - f4b0a41: update dependency @apidevtools/json-schema-ref-parser to v11.2.0 24 | 25 | ## 3.0.1 26 | 27 | ### Patch Changes 28 | 29 | - e81f606: handle additional open api keywords 30 | 31 | ## 3.0.0 32 | 33 | ### Major Changes 34 | 35 | - bf09b6c: The API has been updated to be an async function. 36 | Remove support for node v14. 37 | - b90c88c: Upgrade ajv to version 8. 38 | 39 | The main BREAKING CHANGE is that the support for JSON-Schema draft-04 is removed from version 8. 40 | Some properties of Ajv.Options has also changed its shape. 41 | More details: https://ajv.js.org/v6-to-v8-migration.html 42 | 43 | ## 2.0.1 44 | 45 | ### Patch Changes 46 | 47 | - 1e25a24: Fix issue with empty response header 48 | 49 | ## 2.0.0 50 | 51 | ### Major Changes 52 | 53 | - bd2cac7: bump typescript to v4 54 | 55 | ## 1.2.2 56 | 57 | ### Patch Changes 58 | 59 | - c8fc209: bump better-ajv-errors version 60 | 61 | ## 1.2.1 62 | 63 | ### Patch Changes 64 | 65 | - 5cc0aeb: Renovate bump dependencies 66 | 67 | ## 1.2.0 68 | 69 | ### Minor Changes 70 | 71 | - 48fa398: Make response header name validation case-insensitive 72 | 73 | ## 1.1.4 74 | 75 | ### Patch Changes 76 | 77 | - b99e1f1: Renovate bump dependencies 78 | 79 | ## 1.1.3 80 | 81 | ### Patch Changes 82 | 83 | - 9b804b4: fix: use responseBodyAjvOptions if passed 84 | 85 | ## 1.1.2 86 | 87 | ### Patch Changes 88 | 89 | - f0ed23d: fix #45 where validateRequest was mistakenly called in validateResponseByOperationId 90 | 91 | ## 1.1.1 92 | 93 | ### Patch Changes 94 | 95 | - af69512: Bump avj to 6.12.3 96 | 97 | ## 1.1.0 98 | 99 | ### Minor Changes 100 | 101 | - 4612f8a: Fixed type of ChowError.meta.rawErrors and updated documentation 102 | 103 | ### Patch Changes 104 | 105 | - 0df9521: Upgrade json-schema-deref-sync 106 | 107 | ## 1.0.0 108 | 109 | ### Major Changes 110 | 111 | - e7ce361: 💥 Breaking Changes: 112 | `validateRequest` will now be deprecated in favor of `validateRequestByPath`, but it will NOT break. Instead, it will be printing a deprecated warning message, but do expect it to be removed completely in the future. 113 | 114 | 🎁 New Features: 115 | Adds support for validate by operationId 116 | 117 | ### Patch Changes 118 | 119 | - a833a4c: Fix registry 120 | 121 | ## 0.18.0 122 | 123 | ### Minor Changes 124 | 125 | - 1edce3e: Add constructor argument "options" (ChowOptions) to CompiledRequestBody. This arg is passed to CompiledSchema and ultimately AJV for validation 126 | 127 | ### Patch Changes 128 | 129 | - 989f29c: Make HTTP header names case-insensitive 130 | 131 | ## 0.17.0 132 | 133 | ### Minor Changes 134 | 135 | - ab942d4: Support parameter override 136 | 137 | ## 0.16.3 138 | 139 | ### Patch Changes 140 | 141 | - ef5b0fe: Bump dev pkgs 142 | 143 | ## 0.16.2 144 | 145 | ### Patch Changes 146 | 147 | - 4ed0b01: bump better-ajv-errors 148 | -------------------------------------------------------------------------------- /__test__/fixtures/path.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Query Fixture", 6 | "license": { 7 | "name": "MIT" 8 | } 9 | }, 10 | "servers": [ 11 | { 12 | "url": "http://petstore.swagger.io/v1" 13 | } 14 | ], 15 | "paths": { 16 | "/pets/{petId}": { 17 | "get": { 18 | "summary": "Info for a specific pet", 19 | "operationId": "showPetById", 20 | "tags": [ 21 | "pets" 22 | ], 23 | "parameters": [ 24 | { 25 | "name": "petId", 26 | "in": "path", 27 | "required": true, 28 | "description": "The id of the pet to retrieve", 29 | "schema": { 30 | "type": "integer" 31 | } 32 | } 33 | ], 34 | "responses": { 35 | "200": { 36 | "description": "Expected response to a valid request", 37 | "content": { 38 | "application/json": { 39 | "schema": { 40 | "$ref": "#/components/schemas/Pets" 41 | } 42 | } 43 | } 44 | }, 45 | "default": { 46 | "description": "unexpected error", 47 | "content": { 48 | "application/json": { 49 | "schema": { 50 | "$ref": "#/components/schemas/Error" 51 | } 52 | } 53 | } 54 | } 55 | } 56 | }, 57 | "post": { 58 | "summary": "Update a pet", 59 | "operationId": "updatePets", 60 | "tags": [ 61 | "pets" 62 | ], 63 | "parameters": [ 64 | { 65 | "name": "petId", 66 | "in": "path", 67 | "required": true, 68 | "description": "The id of the pet to retrieve", 69 | "schema": { 70 | "type": "integer" 71 | } 72 | } 73 | ], 74 | "requestBody": { 75 | "description": "The pet information", 76 | "content": { 77 | "application/json":{ 78 | "schema": { 79 | "$ref": "#/components/schemas/Pet" 80 | } 81 | } 82 | } 83 | }, 84 | "responses": { 85 | "201": { 86 | "description": "Null response" 87 | }, 88 | "default": { 89 | "description": "unexpected error", 90 | "content": { 91 | "application/json": { 92 | "schema": { 93 | "$ref": "#/components/schemas/Error" 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | }, 102 | "components": { 103 | "schemas": { 104 | "Pet": { 105 | "type": "object", 106 | "required": [ 107 | "id", 108 | "name" 109 | ], 110 | "properties": { 111 | "id": { 112 | "type": "integer", 113 | "format": "int64" 114 | }, 115 | "name": { 116 | "type": "string" 117 | }, 118 | "tag": { 119 | "type": "string" 120 | } 121 | } 122 | }, 123 | "Pets": { 124 | "type": "array", 125 | "items": { 126 | "$ref": "#/components/schemas/Pet" 127 | } 128 | }, 129 | "Error": { 130 | "type": "object", 131 | "required": [ 132 | "code", 133 | "message" 134 | ], 135 | "properties": { 136 | "code": { 137 | "type": "integer", 138 | "format": "int32" 139 | }, 140 | "message": { 141 | "type": "string" 142 | } 143 | } 144 | } 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oas3-chow-chow 2 | 3 | > Request and response validator against OpenAPI Specification 4 | 5 | [![Build Status](https://travis-ci.org/atlassian/oas3-chow-chow.svg?branch=master)](https://travis-ci.org/atlassian/oas3-chow-chow) 6 | [![npm](https://img.shields.io/npm/v/oas3-chow-chow.svg?style=flat)](https://www.npmjs.com/package/oas3-chow-chow) 7 | 8 | ## Notes 9 | 10 | If you are looking for framework specific middleware, you might want to look at following libraries that use oas3-chow-chow under the hood. 11 | 12 | [koa-oas3](https://github.com/atlassian/koa-oas3) 13 | [openapi3-middleware](https://github.com/naugtur/openapi3-middleware) 14 | 15 | ## Installation 16 | Note: Starting from version 5, it's transferred to be under `@atlassian` scope. 17 | 18 | ```bash 19 | $ yarn add @atlassian/oas3-chow-chow 20 | $ # Or 21 | $ npm i @atlassian/oas3-chow-chow 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```typescript 27 | import ChowChow from "@atlassian/oas3-chow-chow"; 28 | import * as fs from "fs"; 29 | import * as yaml from "js-yaml"; 30 | 31 | var doc = yaml.safeLoad(fs.readFileSync("./openapi.yml", "utf8")); 32 | const chow = ChowChow.create(doc); 33 | 34 | // For URL: /:pathParam/info?arrParam=x&arrParam=y&other=z 35 | chow.validateRequestByPath( 36 | // url.pathname, 37 | "/books/info", 38 | "POST", { 39 | path: { pathParam: "books" }, 40 | // query: querystring.parse(url.search.substr(1)), 41 | query: { arrParam: ["x", "y"], other: "z" }, 42 | // header: req.headers, 43 | header: { "Content-Type": "application/json" }, 44 | body: { a: 1, b: 2 }, 45 | } 46 | ); 47 | chow.validateResponseByPath("/books/info", "POST", { 48 | header: { "Content-Type": "application/json" }, 49 | body: { 50 | name: "a nice book", 51 | author: "me me me" 52 | } 53 | }); 54 | ``` 55 | 56 | ## Config 57 | 58 | You could optionally provide configs to the constructor 59 | ```typescript 60 | const chow = ChowChow.create(doc, { 61 | headerAjvOptions: {}, 62 | cookieAjvOptions: {}, 63 | pathAjvOptions: { coerceTypes: true }, 64 | queryAjvOptions: { coerceTypes: 'array' }, 65 | requestBodyAjvOptions: {}, 66 | responseBodyAjvOptions: {}, 67 | }); 68 | ``` 69 | 70 | * **headerAjvOptions**: Ajv options that pass to header ajv instance 71 | * **cookieAjvOptions**: Ajv options that pass to cookie ajv instance 72 | * **pathAjvOptions**: Ajv options that pass to path ajv instance, default `{ coerceTypes: true }` 73 | * **queryAjvOptions**: Ajv options that pass to query ajv instance, default `{ coerceTypes: 'array' }` 74 | * **requestBodyAjvOptions**: Ajv options that pass to request body ajv instance 75 | * **responseBodyAjvOptions**: Ajv options that pass to response body ajv instance 76 | 77 | ## Contributors 78 | 79 | Pull requests, issues and comments welcome. For pull requests: 80 | 81 | * Add tests for new features and bug fixes 82 | * Follow the existing style 83 | * Separate unrelated changes into multiple pull requests 84 | * See the existing issues for things to start contributing. 85 | 86 | For bigger changes, make sure you start a discussion first by creating an issue and explaining the intended change. 87 | 88 | Atlassian requires contributors to sign a Contributor License Agreement, known as a CLA. This serves as a record stating that the contributor is entitled to contribute the code/documentation/translation to the project and is willing to have it used in distributions and derivative works (or is willing to transfer ownership). 89 | 90 | Prior to accepting your contributions we ask that you please follow the appropriate link below to digitally sign the CLA. The Corporate CLA is for those who are contributing as a member of an organization and the individual CLA is for those contributing as an individual. 91 | 92 | * [CLA for corporate contributors](https://na2.docusign.net/Member/PowerFormSigning.aspx?PowerFormId=e1c17c66-ca4d-4aab-a953-2c231af4a20b) 93 | * [CLA for individuals](https://na2.docusign.net/Member/PowerFormSigning.aspx?PowerFormId=3f94fbdc-2fbe-46ac-b14c-5d152700ae5d) 94 | -------------------------------------------------------------------------------- /src/compiler/CompiledOperation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OperationObject, 3 | ParameterObject, 4 | RequestBodyObject, 5 | } from 'openapi3-ts'; 6 | import CompiledRequestBody from './CompiledRequestBody'; 7 | import CompiledResponse from './CompiledResponse'; 8 | import { RequestMeta, ResponseMeta } from '.'; 9 | import ChowError from '../error'; 10 | import CompiledParameterHeader from './CompiledParameterHeader'; 11 | import CompiledParameterQuery from './CompiledParameterQuery'; 12 | import CompiledParameterPath from './CompiledParameterPath'; 13 | import CompiledParameterCookie from './CompiledParameterCookie'; 14 | import CompiledMediaType from './CompiledMediaType'; 15 | import { ChowOptions } from '..'; 16 | 17 | export default class CompiledOperation { 18 | private header: Map = new Map(); 19 | private compiledHeader: CompiledParameterHeader; 20 | private query: Map = new Map(); 21 | private compiledQuery: CompiledParameterQuery; 22 | private path: Map = new Map(); 23 | private compiledPath: CompiledParameterPath; 24 | private cookie: Map = new Map(); 25 | private compiledCookie: CompiledParameterCookie; 26 | private body?: CompiledRequestBody; 27 | private operationId?: string; 28 | private response: { 29 | [key: string]: CompiledResponse; 30 | } = {}; 31 | 32 | constructor( 33 | operation: OperationObject, 34 | inheritedParameter: ParameterObject[], 35 | options: Partial 36 | ) { 37 | const parameters = !!operation.parameters 38 | ? [...inheritedParameter, ...operation.parameters] 39 | : [...inheritedParameter]; 40 | for (const parameter of parameters as ParameterObject[]) { 41 | switch (parameter.in) { 42 | case 'header': 43 | this.header.set(parameter.name, parameter); 44 | break; 45 | case 'query': 46 | this.query.set(parameter.name, parameter); 47 | break; 48 | case 'path': 49 | this.path.set(parameter.name, parameter); 50 | break; 51 | case 'cookie': 52 | this.cookie.set(parameter.name, parameter); 53 | break; 54 | } 55 | } 56 | this.compiledHeader = new CompiledParameterHeader( 57 | Array.from(this.header.values()), 58 | options 59 | ); 60 | this.compiledQuery = new CompiledParameterQuery( 61 | Array.from(this.query.values()), 62 | options 63 | ); 64 | this.compiledPath = new CompiledParameterPath( 65 | Array.from(this.path.values()), 66 | options 67 | ); 68 | this.compiledCookie = new CompiledParameterCookie( 69 | Array.from(this.cookie.values()), 70 | options 71 | ); 72 | 73 | if (operation.requestBody) { 74 | this.body = new CompiledRequestBody( 75 | operation.requestBody as RequestBodyObject, 76 | options 77 | ); 78 | } 79 | 80 | this.operationId = operation.operationId; 81 | 82 | this.response = Object.keys(operation.responses).reduce( 83 | (compiled: any, status: string) => { 84 | compiled[status] = new CompiledResponse( 85 | operation.responses[status], 86 | options 87 | ); 88 | return compiled; 89 | }, 90 | {} 91 | ); 92 | } 93 | 94 | public getDefinedRequestBodyContentType(): string[] { 95 | return this.body ? this.body.getDefinedContentTypes() : []; 96 | } 97 | 98 | public validateRequest(request: RequestMeta): RequestMeta { 99 | const header = this.compiledHeader.validate(request.header); 100 | const query = this.compiledQuery.validate(request.query); 101 | const path = this.compiledPath.validate(request.path); 102 | const cookie = this.compiledCookie.validate(request.cookie); 103 | 104 | let body; 105 | if (this.body) { 106 | const contentType = CompiledMediaType.extractMediaType( 107 | request.header && request.header['content-type'] 108 | ); 109 | body = this.body.validate(contentType, request.body); 110 | } 111 | 112 | return { 113 | operationId: request.operationId || this.operationId, 114 | header, 115 | query, 116 | path, 117 | cookie, 118 | body, 119 | }; 120 | } 121 | 122 | public validateResponse(response: ResponseMeta): ResponseMeta { 123 | const compiledResponse = 124 | this.response[response.status] || this.response['default']; 125 | if (compiledResponse) { 126 | return { ...response, body: compiledResponse.validate(response) }; 127 | } else { 128 | throw new ChowError('Unsupported Response Status Code', { 129 | in: 'response', 130 | }); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Options as AjvOptions } from 'ajv'; 2 | import { OpenAPIObject } from 'openapi3-ts'; 3 | import $RefParser from '@apidevtools/json-schema-ref-parser'; 4 | import compile, { RequestMeta, ResponseMeta } from './compiler'; 5 | import CompiledPath from './compiler/CompiledPath'; 6 | import ChowError, { 7 | RequestValidationError, 8 | ResponseValidationError, 9 | } from './error'; 10 | import CompiledOperation from './compiler/CompiledOperation'; 11 | 12 | /** 13 | * Export Errors so that consumers can use it to ditinguish different error type. 14 | */ 15 | export { 16 | default as ChowError, 17 | RequestValidationError, 18 | ResponseValidationError, 19 | } from './error'; 20 | 21 | export interface ChowOptions { 22 | headerAjvOptions: AjvOptions; 23 | cookieAjvOptions: AjvOptions; 24 | pathAjvOptions: AjvOptions; 25 | queryAjvOptions: AjvOptions; 26 | requestBodyAjvOptions: AjvOptions; 27 | responseBodyAjvOptions: AjvOptions; 28 | } 29 | 30 | export default class ChowChow { 31 | private compiledPaths: CompiledPath[]; 32 | private compiledOperationById: Map; 33 | 34 | public static async create( 35 | document: object, 36 | options: Partial = {} 37 | ) { 38 | const res = await $RefParser.dereference(document, { 39 | continueOnError: false, 40 | resolve: { 41 | external: false, 42 | }, 43 | dereference: { 44 | circular: false, 45 | }, 46 | }); 47 | 48 | return new ChowChow(res as OpenAPIObject, options); 49 | } 50 | 51 | constructor(document: OpenAPIObject, options: Partial = {}) { 52 | const { compiledPaths, compiledOperationById } = compile(document, options); 53 | this.compiledPaths = compiledPaths; 54 | this.compiledOperationById = compiledOperationById; 55 | } 56 | 57 | validateRequestByPath(path: string, method: string, request: RequestMeta) { 58 | try { 59 | const compiledPath = this.identifyCompiledPath(path); 60 | return compiledPath.validateRequest(path, method, request); 61 | } catch (err) { 62 | if (err instanceof ChowError) { 63 | throw new RequestValidationError(err.message, err.meta); 64 | } else { 65 | throw err; 66 | } 67 | } 68 | } 69 | 70 | validateResponseByPath(path: string, method: string, response: ResponseMeta) { 71 | try { 72 | const compiledPath = this.identifyCompiledPath(path); 73 | return compiledPath.validateResponse(method, response); 74 | } catch (err) { 75 | if (err instanceof ChowError) { 76 | throw new ResponseValidationError(err.message, err.meta); 77 | } else { 78 | throw err; 79 | } 80 | } 81 | } 82 | 83 | validateRequestByOperationId(operationId: string, request: RequestMeta) { 84 | const compiledOperation = this.compiledOperationById.get(operationId); 85 | 86 | if (!compiledOperation) { 87 | throw new ChowError( 88 | `No matches found for the given operationId - ${operationId}`, 89 | { in: 'request', code: 404 } 90 | ); 91 | } 92 | 93 | try { 94 | return compiledOperation.validateRequest(request); 95 | } catch (err) { 96 | if (err instanceof ChowError) { 97 | throw new RequestValidationError(err.message, err.meta); 98 | } else { 99 | throw err; 100 | } 101 | } 102 | } 103 | 104 | validateResponseByOperationId(operationId: string, response: ResponseMeta) { 105 | const compiledOperation = this.compiledOperationById.get(operationId); 106 | 107 | if (!compiledOperation) { 108 | throw new ChowError( 109 | `No matches found for the given operationId - ${operationId}`, 110 | { in: 'response', code: 404 } 111 | ); 112 | } 113 | 114 | try { 115 | return compiledOperation.validateResponse(response); 116 | } catch (err) { 117 | if (err instanceof ChowError) { 118 | throw new ResponseValidationError(err.message, err.meta); 119 | } else { 120 | throw err; 121 | } 122 | } 123 | } 124 | 125 | getDefinedRequestBodyContentType(path: string, method: string) { 126 | try { 127 | const compiledPath = this.identifyCompiledPath(path); 128 | return compiledPath.getDefinedRequestBodyContentType(method); 129 | } catch (err) { 130 | if (err instanceof ChowError) { 131 | return []; 132 | } else { 133 | throw err; 134 | } 135 | } 136 | } 137 | 138 | private identifyCompiledPath(path: string) { 139 | const compiledPath = this.compiledPaths.find((cp: CompiledPath) => { 140 | return cp.test(path); 141 | }); 142 | 143 | if (!compiledPath) { 144 | throw new ChowError(`No matches found for the given path - ${path}`, { 145 | in: 'paths', 146 | code: 404, 147 | }); 148 | } 149 | 150 | return compiledPath; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /__test__/fixtures/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Response Fixture", 6 | "license": { 7 | "name": "MIT" 8 | } 9 | }, 10 | "servers": [ 11 | { 12 | "url": "http://petstore.swagger.io/v1" 13 | } 14 | ], 15 | "paths": { 16 | "/pets/{petId}": { 17 | "get": { 18 | "summary": "Info for a specific pet", 19 | "operationId": "showPetById", 20 | "tags": ["pets"], 21 | "parameters": [ 22 | { 23 | "name": "petId", 24 | "in": "path", 25 | "required": true, 26 | "description": "The id of the pet to retrieve", 27 | "schema": { 28 | "type": "integer" 29 | } 30 | } 31 | ], 32 | "responses": { 33 | "200": { 34 | "description": "Expected response to a valid request", 35 | "content": { 36 | "application/json": { 37 | "schema": { 38 | "$ref": "#/components/schemas/Pets" 39 | } 40 | } 41 | } 42 | }, 43 | "default": { 44 | "description": "unexpected error", 45 | "content": { 46 | "application/json": { 47 | "schema": { 48 | "$ref": "#/components/schemas/Error" 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | }, 56 | "/no-default": { 57 | "get": { 58 | "summary": "Info for a specific pet", 59 | "operationId": "getNoDefault", 60 | "tags": ["pets"], 61 | "parameters": [ 62 | { 63 | "name": "petId", 64 | "in": "path", 65 | "required": true, 66 | "description": "The id of the pet to retrieve", 67 | "schema": { 68 | "type": "integer" 69 | } 70 | } 71 | ], 72 | "responses": { 73 | "200": { 74 | "description": "Expected response to a valid request", 75 | "content": { 76 | "application/json": { 77 | "schema": { 78 | "$ref": "#/components/schemas/Pets" 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | }, 86 | "/header": { 87 | "get": { 88 | "summary": "Info for a specific pet", 89 | "operationId": "getHeader", 90 | "tags": ["pets"], 91 | "parameters": [], 92 | "responses": { 93 | "200": { 94 | "description": "Expected response to a valid request", 95 | "headers": { 96 | "Content-Type": { 97 | "description": "The returned content type", 98 | "schema": { 99 | "type": "string" 100 | } 101 | }, 102 | "Version": { 103 | "description": "The version of API", 104 | "schema": { 105 | "type": "string" 106 | }, 107 | "required": true 108 | }, 109 | "Any": { 110 | "description": "It matches anything", 111 | "required": false 112 | } 113 | }, 114 | "content": { 115 | "application/json": { 116 | "schema": { 117 | "$ref": "#/components/schemas/Pets" 118 | } 119 | }, 120 | "empty": {} 121 | } 122 | } 123 | } 124 | } 125 | } 126 | }, 127 | "components": { 128 | "schemas": { 129 | "Pet": { 130 | "type": "object", 131 | "required": ["id", "name"], 132 | "properties": { 133 | "id": { 134 | "type": "integer", 135 | "format": "int64" 136 | }, 137 | "name": { 138 | "type": "string" 139 | }, 140 | "readOnlyProp": { 141 | "type": "string", 142 | "readOnly": true 143 | }, 144 | "writeOnlyProp": { 145 | "type": "string", 146 | "writeOnly": true 147 | }, 148 | "notReadOnlyProp": { 149 | "type": "string", 150 | "readOnly": false 151 | }, 152 | "notWriteOnlyProp": { 153 | "type": "string", 154 | "writeOnly": false 155 | }, 156 | "tag": { 157 | "nullable": true, 158 | "type": "string" 159 | } 160 | } 161 | }, 162 | "Pets": { 163 | "type": "array", 164 | "items": { 165 | "$ref": "#/components/schemas/Pet" 166 | } 167 | }, 168 | "Error": { 169 | "type": "object", 170 | "required": ["code", "message"], 171 | "properties": { 172 | "code": { 173 | "type": "integer", 174 | "format": "int32" 175 | }, 176 | "message": { 177 | "type": "string" 178 | } 179 | } 180 | } 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /__test__/response.spec.ts: -------------------------------------------------------------------------------- 1 | import ChowChow from '../src'; 2 | import ChowError, { ResponseValidationError } from '../src/error'; 3 | import { ResponseMeta } from '../src/compiler'; 4 | 5 | const fixture = require('./fixtures/response.json'); 6 | 7 | describe('Response', () => { 8 | let chowchow: ChowChow; 9 | 10 | beforeAll(async () => { 11 | chowchow = await ChowChow.create(fixture); 12 | }); 13 | 14 | it('should validate the response with status code', () => { 15 | const responseMeta: ResponseMeta = { 16 | status: 200, 17 | header: { 18 | 'content-type': 'application/json', 19 | }, 20 | body: [ 21 | { 22 | id: 1, 23 | name: 'plum', 24 | readOnlyProp: '42', 25 | notWriteOnlyProp: '42', 26 | }, 27 | ], 28 | }; 29 | expect( 30 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta) 31 | ).toEqual(responseMeta); 32 | expect( 33 | chowchow.validateResponseByOperationId('showPetById', responseMeta) 34 | ).toEqual(responseMeta); 35 | }); 36 | 37 | it('should pass if a field that is nullable: true is null', () => { 38 | const responseMeta: ResponseMeta = { 39 | status: 200, 40 | header: { 41 | 'content-type': 'application/json', 42 | }, 43 | body: [ 44 | { 45 | id: 1, 46 | name: 'plum', 47 | tag: null, 48 | }, 49 | ], 50 | }; 51 | expect( 52 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta) 53 | ).toEqual(responseMeta); 54 | expect( 55 | chowchow.validateResponseByOperationId('showPetById', responseMeta) 56 | ).toEqual(responseMeta); 57 | }); 58 | 59 | it('should fail validation the response with writeOnly property', () => { 60 | const responseMeta: ResponseMeta = { 61 | status: 200, 62 | header: { 63 | 'content-type': 'application/json', 64 | }, 65 | body: [ 66 | { 67 | id: 1, 68 | name: 'plum', 69 | writeOnlyProp: '42', 70 | }, 71 | ], 72 | }; 73 | expect(() => 74 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta) 75 | ).toThrow(ResponseValidationError); 76 | expect(() => 77 | chowchow.validateResponseByOperationId('showPetById', responseMeta) 78 | ).toThrow(ResponseValidationError); 79 | }); 80 | 81 | it('should fall back to default if no status code is matched', () => { 82 | const responseMeta: ResponseMeta = { 83 | status: 500, 84 | header: { 85 | 'content-type': 'application/json', 86 | }, 87 | body: { 88 | code: 500, 89 | message: 'something is wrong', 90 | }, 91 | }; 92 | expect( 93 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta) 94 | ).toEqual(responseMeta); 95 | expect( 96 | chowchow.validateResponseByOperationId('showPetById', responseMeta) 97 | ).toEqual(responseMeta); 98 | }); 99 | 100 | it('should throw error if path parameter fails schema check', () => { 101 | expect(() => { 102 | chowchow.validateRequestByPath('/pets/abc', 'get', {}); 103 | }).toThrowError(ChowError); 104 | }); 105 | 106 | it('should fail on unsupported response media type', () => { 107 | const responseMeta: ResponseMeta = { 108 | status: 200, 109 | header: { 110 | 'content-type': 'application/nonono', 111 | }, 112 | }; 113 | expect(() => { 114 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta); 115 | }).toThrow(); 116 | expect(() => { 117 | chowchow.validateResponseByOperationId('showPetById', responseMeta); 118 | }).toThrow(); 119 | }); 120 | 121 | it('should ignore on empty response media type', () => { 122 | const responseMeta: ResponseMeta = { 123 | status: 204, 124 | header: { 125 | 'content-type': '', 126 | }, 127 | }; 128 | expect(() => { 129 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta); 130 | }).not.toThrow(); 131 | expect(() => { 132 | chowchow.validateResponseByOperationId('showPetById', responseMeta); 133 | }).not.toThrow(); 134 | }); 135 | 136 | it('should ignore on empty response header', () => { 137 | const responseMeta: ResponseMeta = { 138 | status: 200, 139 | }; 140 | expect(() => { 141 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta); 142 | }).not.toThrow(); 143 | expect(() => { 144 | chowchow.validateResponseByOperationId('showPetById', responseMeta); 145 | }).not.toThrow(); 146 | }); 147 | 148 | it('should extract media type correctly in Content-Type header', () => { 149 | const responseMeta: ResponseMeta = { 150 | status: 200, 151 | header: { 152 | 'content-type': 'application/json; charset=utf-8', 153 | }, 154 | }; 155 | expect(() => { 156 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta); 157 | }).toThrow(); 158 | expect(() => { 159 | chowchow.validateResponseByOperationId('showPetById', responseMeta); 160 | }).toThrow(); 161 | }); 162 | 163 | it('should fail if response body is invalid', () => { 164 | const responseMeta: ResponseMeta = { 165 | status: 200, 166 | header: { 167 | 'content-type': 'application/json', 168 | }, 169 | body: [ 170 | { 171 | id: 1, 172 | }, 173 | ], 174 | }; 175 | expect(() => { 176 | chowchow.validateResponseByPath('/pets/123', 'get', responseMeta); 177 | }).toThrow(); 178 | expect(() => { 179 | chowchow.validateResponseByOperationId('showPetById', responseMeta); 180 | }).toThrow(); 181 | }); 182 | 183 | it('should fail if response code does not match any', () => { 184 | const responseMeta: ResponseMeta = { 185 | status: 400, 186 | header: { 187 | 'content-type': 'application/json', 188 | }, 189 | }; 190 | expect(() => { 191 | chowchow.validateResponseByPath('/no-default', 'get', responseMeta); 192 | }).toThrow(); 193 | expect(() => { 194 | chowchow.validateResponseByOperationId('/no-default', responseMeta); 195 | }).toThrow(); 196 | }); 197 | 198 | it('should fail if response method is invalid', () => { 199 | expect(() => { 200 | chowchow.validateResponseByPath('/no-default', 'path', { 201 | status: 400, 202 | header: { 203 | 'content-type': 'application/json', 204 | }, 205 | }); 206 | }).toThrow(); 207 | }); 208 | 209 | it('validateResponseByPath should fail if header value is invalid', () => { 210 | const responseMeta: ResponseMeta = { 211 | status: 200, 212 | header: { 213 | 'content-type': 'application/json', 214 | version: [1, 2], 215 | }, 216 | }; 217 | 218 | expect(() => 219 | chowchow.validateResponseByPath('/header', 'get', responseMeta) 220 | ).toThrowErrorMatchingSnapshot(); 221 | }); 222 | 223 | it('validateResponseByOperationId should fail if header value is invalid', () => { 224 | const responseMeta: ResponseMeta = { 225 | status: 200, 226 | header: { 227 | 'content-type': 'application/json', 228 | version: [1, 2], 229 | }, 230 | }; 231 | 232 | expect(() => 233 | chowchow.validateResponseByOperationId('getHeader', responseMeta) 234 | ).toThrowErrorMatchingSnapshot(); 235 | }); 236 | 237 | it('should succeed if header case is different than spec', () => { 238 | const lowercaseHeaderName = 'version'; 239 | const responseMeta: ResponseMeta = { 240 | status: 200, 241 | header: { 242 | [lowercaseHeaderName]: '1', 243 | }, 244 | }; 245 | 246 | // test validateResponseByPath 247 | expect(() => 248 | chowchow.validateResponseByPath('/header', 'get', responseMeta) 249 | ).not.toThrow(); 250 | }); 251 | 252 | it('should succeed if header case is same as spec', () => { 253 | const uppercaseHeaderName = 'Version'; 254 | const responseMeta: ResponseMeta = { 255 | status: 200, 256 | header: { 257 | [uppercaseHeaderName]: '1', 258 | }, 259 | }; 260 | 261 | // test validateResponseByPath 262 | expect(() => 263 | chowchow.validateResponseByPath('/header', 'get', responseMeta) 264 | ).not.toThrow(); 265 | }); 266 | 267 | it('should fail if required header is empty', () => { 268 | const responseMeta: ResponseMeta = { 269 | status: 200, 270 | }; 271 | expect(() => { 272 | chowchow.validateResponseByPath('/header', 'get', responseMeta); 273 | }).toThrow(); 274 | expect(() => { 275 | chowchow.validateResponseByOperationId('getHeader', responseMeta); 276 | }).toThrow(); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /__test__/fixtures/pet-store.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Swagger Petstore", 6 | "license": { 7 | "name": "MIT" 8 | } 9 | }, 10 | "servers": [ 11 | { 12 | "url": "http://petstore.swagger.io/v1" 13 | } 14 | ], 15 | "paths": { 16 | "/pets": { 17 | "get": { 18 | "summary": "List all pets", 19 | "operationId": "listPets", 20 | "tags": [ 21 | "pets" 22 | ], 23 | "parameters": [ 24 | { 25 | "name": "limit", 26 | "in": "query", 27 | "description": "How many items to return at one time (max 100)", 28 | "required": false, 29 | "schema": { 30 | "type": "integer", 31 | "format": "int32" 32 | } 33 | }, 34 | { 35 | "name": "sex", 36 | "in": "query", 37 | "description": "Male or female", 38 | "required": false 39 | }, 40 | { 41 | "name": "breed", 42 | "in": "query", 43 | "description": "Filter result with specified breed", 44 | "required": false, 45 | "schema": { 46 | "type": "array", 47 | "maxItems": 3, 48 | "items": { 49 | "type": "string", 50 | "enum": ["bichon", "chowchow", "jack russel", "labrador"] 51 | } 52 | } 53 | } 54 | ], 55 | "responses": { 56 | "200": { 57 | "description": "An paged array of pets", 58 | "headers": { 59 | "x-next": { 60 | "description": "A link to the next page of responses", 61 | "schema": { 62 | "type": "string" 63 | } 64 | } 65 | }, 66 | "content": { 67 | "application/json": { 68 | "schema": { 69 | "$ref": "#/components/schemas/Pets" 70 | } 71 | } 72 | } 73 | }, 74 | "default": { 75 | "description": "unexpected error", 76 | "content": { 77 | "application/json": { 78 | "schema": { 79 | "$ref": "#/components/schemas/Error" 80 | } 81 | } 82 | } 83 | } 84 | } 85 | }, 86 | "post": { 87 | "summary": "Create a pet", 88 | "operationId": "createPets", 89 | "tags": [ 90 | "pets" 91 | ], 92 | "requestBody": { 93 | "description": "The pet information", 94 | "content": { 95 | "application/json":{ 96 | "schema": { 97 | "$ref": "#/components/schemas/Pet" 98 | } 99 | } 100 | }, 101 | "required": true 102 | }, 103 | "responses": { 104 | "201": { 105 | "description": "Null response" 106 | }, 107 | "default": { 108 | "description": "unexpected error", 109 | "content": { 110 | "application/json": { 111 | "schema": { 112 | "$ref": "#/components/schemas/Error" 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | }, 120 | "/test/body": { 121 | "post": { 122 | "summary": "Create a pet", 123 | "operationId": "createPets", 124 | "tags": [ 125 | "pets" 126 | ], 127 | "requestBody": { 128 | "description": "A body without schema", 129 | "content": { 130 | "application/json":{} 131 | }, 132 | "required": true 133 | }, 134 | "responses": { 135 | "201": { 136 | "description": "Null response" 137 | }, 138 | "default": { 139 | "description": "unexpected error", 140 | "content": { 141 | "application/json": { 142 | "schema": { 143 | "$ref": "#/components/schemas/Error" 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | }, 151 | "/test/wildcard": { 152 | "post": { 153 | "summary": "Create a pet", 154 | "operationId": "createPets", 155 | "tags": [ 156 | "pets" 157 | ], 158 | "requestBody": { 159 | "description": "a schema that accepts many applications", 160 | "content": { 161 | "application/*":{ 162 | "schema": { 163 | "$ref": "#/components/schemas/Pet" 164 | } 165 | }, 166 | "*/*":{ 167 | "schema": { 168 | "$ref": "#/components/schemas/Pets" 169 | } 170 | } 171 | }, 172 | "required": true 173 | }, 174 | "responses": { 175 | "201": { 176 | "description": "Null response" 177 | }, 178 | "default": { 179 | "description": "unexpected error", 180 | "content": { 181 | "application/json": { 182 | "schema": { 183 | "$ref": "#/components/schemas/Error" 184 | } 185 | } 186 | } 187 | } 188 | } 189 | } 190 | }, 191 | "/test/header": { 192 | "get": { 193 | "summary": "For testing header validation", 194 | "operationId": "testHeader", 195 | "tags": ["test"], 196 | "parameters":[ 197 | { 198 | "name": "version", 199 | "in": "header", 200 | "required": true, 201 | "description": "This is a required header", 202 | "schema": { 203 | "type": "integer" 204 | } 205 | }, 206 | { 207 | "name": "Content-Type", 208 | "in": "header", 209 | "required": true, 210 | "description": "This should be ignored", 211 | "schema": { 212 | "type": "string" 213 | } 214 | }, 215 | { 216 | "name": "no-required", 217 | "in": "header", 218 | "required": false, 219 | "description": "This is not required", 220 | "schema": { 221 | "type": "string" 222 | } 223 | }, 224 | { 225 | "name": "no-schema", 226 | "in": "header", 227 | "required": false, 228 | "description": "This is not required" 229 | } 230 | ], 231 | "responses":{ 232 | "default": { 233 | "description": "unexpected error", 234 | "content": { 235 | "application/json": { 236 | "schema": { 237 | "$ref": "#/components/schemas/Error" 238 | } 239 | } 240 | } 241 | } 242 | } 243 | } 244 | }, 245 | "/test/cookie": { 246 | "get": { 247 | "summary": "For testing cookie validation", 248 | "operationId": "testCookie", 249 | "tags": ["test"], 250 | "parameters":[ 251 | { 252 | "name": "count", 253 | "in": "cookie", 254 | "required": true, 255 | "description": "This is a required cookie", 256 | "schema": { 257 | "type": "integer" 258 | } 259 | }, 260 | { 261 | "name": "not-required", 262 | "in": "cookie", 263 | "required": false, 264 | "description": "This is not a required cookie", 265 | "schema": { 266 | "type": "integer" 267 | } 268 | } 269 | ], 270 | "responses":{ 271 | "default": { 272 | "description": "unexpected error", 273 | "content": { 274 | "application/json": { 275 | "schema": { 276 | "$ref": "#/components/schemas/Error" 277 | } 278 | } 279 | } 280 | } 281 | } 282 | } 283 | }, 284 | "/test/path/{id}": { 285 | "get": { 286 | "summary": "test for path", 287 | "operationId": "showPetById", 288 | "tags": [ 289 | "pets" 290 | ], 291 | "parameters": [ 292 | { 293 | "name": "id", 294 | "in": "path", 295 | "required": true, 296 | "description": "The id of the pet to retrieve" 297 | } 298 | ], 299 | "responses": { 300 | "200": { 301 | "description": "Expected response to a valid request", 302 | "content": { 303 | "application/json": { 304 | "schema": { 305 | "$ref": "#/components/schemas/Pets" 306 | } 307 | } 308 | } 309 | }, 310 | "default": { 311 | "description": "unexpected error", 312 | "content": { 313 | "application/json": { 314 | "schema": { 315 | "$ref": "#/components/schemas/Error" 316 | } 317 | } 318 | } 319 | } 320 | } 321 | } 322 | }, 323 | "/test/schema": { 324 | "get": { 325 | "summary": "For testing schema", 326 | "operationId": "testSchema", 327 | "tags": ["test"], 328 | "parameters":[ 329 | { 330 | "name": "count", 331 | "in": "cookie", 332 | "required": true, 333 | "description": "This is a required cookie" 334 | } 335 | ], 336 | "responses":{ 337 | "default": { 338 | "description": "unexpected error", 339 | "content": { 340 | "application/json": { 341 | "schema": { 342 | "$ref": "#/components/schemas/Error" 343 | } 344 | } 345 | } 346 | } 347 | } 348 | } 349 | }, 350 | "/pets/{petId}": { 351 | "get": { 352 | "summary": "Info for a specific pet", 353 | "operationId": "showPetById", 354 | "tags": [ 355 | "pets" 356 | ], 357 | "parameters": [ 358 | { 359 | "name": "petId", 360 | "in": "path", 361 | "required": true, 362 | "description": "The id of the pet to retrieve", 363 | "schema": { 364 | "type": "integer" 365 | } 366 | } 367 | ], 368 | "responses": { 369 | "200": { 370 | "description": "Expected response to a valid request", 371 | "content": { 372 | "application/json": { 373 | "schema": { 374 | "$ref": "#/components/schemas/Pets" 375 | } 376 | } 377 | } 378 | }, 379 | "default": { 380 | "description": "unexpected error", 381 | "content": { 382 | "application/json": { 383 | "schema": { 384 | "$ref": "#/components/schemas/Error" 385 | } 386 | } 387 | } 388 | } 389 | } 390 | }, 391 | "post": { 392 | "summary": "Update a pet", 393 | "operationId": "updatePets", 394 | "tags": [ 395 | "pets" 396 | ], 397 | "parameters": [ 398 | { 399 | "name": "petId", 400 | "in": "path", 401 | "required": true, 402 | "description": "The id of the pet to retrieve", 403 | "schema": { 404 | "type": "integer" 405 | } 406 | } 407 | ], 408 | "requestBody": { 409 | "description": "The pet information", 410 | "content": { 411 | "application/json":{ 412 | "schema": { 413 | "$ref": "#/components/schemas/Pet" 414 | } 415 | } 416 | } 417 | }, 418 | "responses": { 419 | "201": { 420 | "description": "Null response" 421 | }, 422 | "default": { 423 | "description": "unexpected error", 424 | "content": { 425 | "application/json": { 426 | "schema": { 427 | "$ref": "#/components/schemas/Error" 428 | } 429 | } 430 | } 431 | } 432 | } 433 | } 434 | } 435 | }, 436 | "components": { 437 | "schemas": { 438 | "Pet": { 439 | "type": "object", 440 | "required": [ 441 | "id", 442 | "name" 443 | ], 444 | "properties": { 445 | "id": { 446 | "type": "integer", 447 | "format": "int64" 448 | }, 449 | "name": { 450 | "type": "string" 451 | }, 452 | "readOnlyProp": { 453 | "type": "string", 454 | "readOnly": true 455 | }, 456 | "writeOnlyProp": { 457 | "type": "string", 458 | "writeOnly": true 459 | }, 460 | "notReadOnlyProp": { 461 | "type": "string", 462 | "readOnly": false 463 | }, 464 | "notWriteOnlyProp": { 465 | "type": "string", 466 | "writeOnly": false 467 | }, 468 | "tag": { 469 | "type": "string" 470 | } 471 | } 472 | }, 473 | "Pets": { 474 | "type": "array", 475 | "items": { 476 | "$ref": "#/components/schemas/Pet" 477 | } 478 | }, 479 | "Error": { 480 | "type": "object", 481 | "required": [ 482 | "code", 483 | "message" 484 | ], 485 | "properties": { 486 | "code": { 487 | "type": "integer", 488 | "format": "int32" 489 | }, 490 | "message": { 491 | "type": "string" 492 | } 493 | } 494 | } 495 | } 496 | } 497 | } 498 | -------------------------------------------------------------------------------- /__test__/pet-store.spec.ts: -------------------------------------------------------------------------------- 1 | import ChowChow, { ChowOptions } from '../src'; 2 | import ChowError, { RequestValidationError } from '../src/error'; 3 | const fixture = require('./fixtures/pet-store.json'); 4 | 5 | describe('Pet Store', () => { 6 | let chowchow: ChowChow; 7 | 8 | beforeAll(async () => { 9 | chowchow = await ChowChow.create(fixture as any); 10 | }); 11 | 12 | test('It should throw an error if a path is undefined', () => { 13 | expect(() => { 14 | chowchow.validateRequestByPath('/undefined', 'get', {}); 15 | }).toThrowError(ChowError); 16 | }); 17 | 18 | test('It should successfully throw an error if a method is undefined', () => { 19 | expect(() => { 20 | chowchow.validateRequestByPath('/pets', 'put', {}); 21 | }).toThrowError(ChowError); 22 | }); 23 | 24 | describe('Path', () => { 25 | test('It should fail validation if provided path parameter is wrong', () => { 26 | expect(() => { 27 | chowchow.validateRequestByPath('/pets/chow', 'get', { 28 | path: { 29 | petId: 'chow', 30 | }, 31 | }); 32 | }).toThrowError(ChowError); 33 | }); 34 | 35 | test('It should pass validation if provided path parameter is correct', () => { 36 | expect(() => { 37 | chowchow.validateRequestByPath('/pets/123', 'get', { 38 | path: { 39 | petId: 123, 40 | }, 41 | }); 42 | }).not.toThrowError(); 43 | }); 44 | }); 45 | 46 | describe('Query', () => { 47 | test('It should fail validation if provided query parameter is wrong', () => { 48 | expect(() => { 49 | chowchow.validateRequestByPath('/pets', 'get', { 50 | query: { 51 | limit: 'xyz', 52 | }, 53 | }); 54 | }).toThrowError(ChowError); 55 | }); 56 | 57 | test('It should pass validation if provided path parameter is correct', () => { 58 | expect(() => { 59 | chowchow.validateRequestByPath('/pets', 'get', { 60 | query: { 61 | limit: 50, 62 | }, 63 | }); 64 | }).not.toThrowError(); 65 | }); 66 | 67 | test('It should pass validation if an array is passed to parameter which should be an array', () => { 68 | expect(() => { 69 | chowchow.validateRequestByPath('/pets', 'get', { 70 | query: { 71 | breed: ['chowchow'], 72 | }, 73 | }); 74 | }).not.toThrowError(); 75 | }); 76 | 77 | test('It should fail validation if invalid item is passed in enum', () => { 78 | expect(() => { 79 | chowchow.validateRequestByPath('/pets', 'get', { 80 | query: { 81 | breed: ['nice dog'], 82 | }, 83 | }); 84 | }).toThrowError(ChowError); 85 | }); 86 | 87 | test('It should fail validation if number of items exceeds the limit', () => { 88 | expect(() => { 89 | chowchow.validateRequestByPath('/pets', 'get', { 90 | query: { 91 | breed: ['chowchow', 'bichon', 'jack russell', 'labrador'], 92 | }, 93 | }); 94 | }).toThrowError(ChowError); 95 | }); 96 | 97 | test('It should pass validation for valid array parameter', () => { 98 | expect(() => { 99 | chowchow.validateRequestByPath('/pets', 'get', { 100 | query: { 101 | breed: ['chowchow', 'bichon', 'labrador'], 102 | }, 103 | }); 104 | }).not.toThrowError(); 105 | }); 106 | }); 107 | describe('Configure ChowOptions for allErrors', () => { 108 | test('It should fail validation and receive multiple errors if payload is invalid and ChowOptions configured with allErrors:true', async () => { 109 | let chowOptions: Partial = { 110 | requestBodyAjvOptions: { allErrors: true }, 111 | }; 112 | chowchow = await ChowChow.create(fixture as any, chowOptions); 113 | 114 | try { 115 | chowchow.validateRequestByPath('/pets', 'post', { 116 | body: { 117 | name: 123, 118 | }, 119 | header: { 120 | 'content-type': 'application/json', 121 | }, 122 | }); 123 | } catch (e) { 124 | expect(e).toBeDefined(); 125 | expect(e).toBeInstanceOf(ChowError); 126 | const chowError: ChowError = e as any; 127 | expect(chowError.toJSON().suggestions.length).toBe(2); 128 | expect( 129 | chowError.meta.rawErrors && chowError.meta.rawErrors.length 130 | ).toBe(2); 131 | } 132 | }); 133 | 134 | test('It should fail validation and receive a single error if payload is invalid and ChowOptions configured for allErrors:false', async () => { 135 | let chowOptions: Partial = { 136 | requestBodyAjvOptions: { allErrors: false }, 137 | }; 138 | chowchow = await ChowChow.create(fixture as any, chowOptions); 139 | 140 | try { 141 | chowchow.validateRequestByPath('/pets', 'post', { 142 | body: { 143 | name: 123, 144 | }, 145 | header: { 146 | 'content-type': 'application/json', 147 | }, 148 | }); 149 | } catch (e) { 150 | expect(e).toBeDefined(); 151 | expect(e).toBeInstanceOf(ChowError); 152 | const chowError: ChowError = e as any; 153 | expect(chowError.toJSON().suggestions.length).toBe(1); 154 | expect( 155 | chowError.meta.rawErrors && chowError.meta.rawErrors.length 156 | ).toBe(1); 157 | } 158 | }); 159 | 160 | test('It should fail validation and receive a single error if payload is invalid and ChowOptions not configured', async () => { 161 | chowchow = await ChowChow.create(fixture as any); 162 | 163 | try { 164 | chowchow.validateRequestByPath('/pets', 'post', { 165 | body: { 166 | name: 123, 167 | }, 168 | header: { 169 | 'content-type': 'application/json', 170 | }, 171 | }); 172 | } catch (e) { 173 | expect(e).toBeDefined(); 174 | expect(e).toBeInstanceOf(ChowError); 175 | const chowError: ChowError = e as any; 176 | expect(chowError.toJSON().suggestions.length).toBe(1); 177 | expect( 178 | chowError.meta.rawErrors && chowError.meta.rawErrors.length 179 | ).toBe(1); 180 | } 181 | }); 182 | }); 183 | 184 | describe('RequestBody', () => { 185 | test('It should fail validation if payload is invalid', () => { 186 | expect(() => { 187 | chowchow.validateRequestByPath('/pets', 'post', { 188 | body: { 189 | name: 'plum', 190 | }, 191 | header: { 192 | 'content-type': 'application/json', 193 | }, 194 | }); 195 | }).toThrowError(ChowError); 196 | }); 197 | 198 | test('It should fail validation if invalid mediaType is asked', () => { 199 | expect(() => { 200 | chowchow.validateRequestByPath('/pets', 'post', { 201 | body: { 202 | id: 123, 203 | name: 'plum', 204 | }, 205 | header: { 206 | 'content-type': 'application/awsome', 207 | }, 208 | }); 209 | }).toThrowError(ChowError); 210 | }); 211 | 212 | test('It should fail validation if requestBody is required but missing', () => { 213 | expect(() => { 214 | chowchow.validateRequestByPath('/pets', 'post', { 215 | header: { 216 | 'content-type': 'application/json', 217 | }, 218 | }); 219 | }).toThrowError(ChowError); 220 | }); 221 | 222 | test('It should fail validation if requestBody is required but Content type is missing', () => { 223 | expect(() => { 224 | chowchow.validateRequestByPath('/pets', 'post', {}); 225 | }).toThrowError(ChowError); 226 | }); 227 | 228 | test('It is ok to ignore body if it is not required', () => { 229 | expect(() => { 230 | chowchow.validateRequestByPath('/pets/123', 'post', { 231 | path: { 232 | petId: 123, 233 | }, 234 | header: { 235 | 'content-type': 'application/json', 236 | }, 237 | }); 238 | }).not.toThrowError(); 239 | }); 240 | 241 | test('It should pass validation if valid requestBody is passed', () => { 242 | expect(() => { 243 | chowchow.validateRequestByPath('/pets', 'post', { 244 | body: { 245 | id: 123, 246 | name: 'plum', 247 | writeOnlyProp: '42', 248 | notReadOnlyProp: '42', 249 | }, 250 | header: { 251 | 'content-type': 'application/json', 252 | }, 253 | }); 254 | }).not.toThrowError(); 255 | }); 256 | 257 | test('It should fail validation if requestBody with readOnly property passed', () => { 258 | expect(() => { 259 | chowchow.validateRequestByPath('/pets', 'post', { 260 | body: { 261 | id: 123, 262 | name: 'plum', 263 | readOnlyProp: '42', 264 | }, 265 | header: { 266 | 'content-type': 'application/json', 267 | }, 268 | }); 269 | }).toThrow(RequestValidationError); 270 | }); 271 | 272 | test('It returns defined body content type', () => { 273 | expect( 274 | chowchow.getDefinedRequestBodyContentType('/pets', 'post') 275 | ).toMatchSnapshot(); 276 | }); 277 | 278 | test('It returns empty array for defined body content type if path is undefined', () => { 279 | expect( 280 | chowchow.getDefinedRequestBodyContentType('/nonono', 'post') 281 | ).toMatchSnapshot(); 282 | }); 283 | 284 | test('It returns empty array for defined body content type if method is undefined', () => { 285 | expect( 286 | chowchow.getDefinedRequestBodyContentType('/pets', 'head') 287 | ).toMatchSnapshot(); 288 | }); 289 | 290 | test('It returns empty array for defined body content type if requestBody is not defined', () => { 291 | expect( 292 | chowchow.getDefinedRequestBodyContentType('/pets', 'get') 293 | ).toMatchSnapshot(); 294 | }); 295 | }); 296 | 297 | describe('Header', () => { 298 | test('It should fail validation if a required header is missing', () => { 299 | expect(() => { 300 | chowchow.validateRequestByPath('/test/header', 'get', { 301 | header: { 302 | 'content-type': 'application/json', 303 | }, 304 | }); 305 | }).toThrowError(ChowError); 306 | }); 307 | 308 | test('It should fail validation if a header fails schema validation', () => { 309 | expect(() => { 310 | chowchow.validateRequestByPath('/test/header', 'get', { 311 | header: { 312 | version: 'awsome version', 313 | }, 314 | }); 315 | }).toThrowError(ChowError); 316 | }); 317 | 318 | test('It should pass validation if headers are satisfied', () => { 319 | expect(() => { 320 | chowchow.validateRequestByPath('/test/header', 'get', { 321 | header: { 322 | version: 123, 323 | }, 324 | }); 325 | }).not.toThrowError(); 326 | }); 327 | 328 | test('It should pass for header without schema', () => { 329 | expect(() => { 330 | chowchow.validateRequestByPath('/test/header', 'get', { 331 | header: { 332 | version: 123, 333 | 'no-schema': 123, 334 | }, 335 | }); 336 | }).not.toThrowError(); 337 | }); 338 | }); 339 | 340 | describe('Cookie', () => { 341 | test('It should fail validation if a required cookie is missing', () => { 342 | expect(() => { 343 | chowchow.validateRequestByPath('/test/cookie', 'get', { 344 | cookie: {}, 345 | }); 346 | }).toThrowError(ChowError); 347 | }); 348 | 349 | test('It should fail validation if a cookie fails schema validation', () => { 350 | expect(() => { 351 | chowchow.validateRequestByPath('/test/cookie', 'get', { 352 | cookie: { 353 | count: 'many', 354 | }, 355 | }); 356 | }).toThrowError(ChowError); 357 | }); 358 | 359 | test('It should pass validation if cookies are satisfied', () => { 360 | expect(() => { 361 | chowchow.validateRequestByPath('/test/cookie', 'get', { 362 | cookie: { 363 | count: 123, 364 | }, 365 | }); 366 | }).not.toThrowError(); 367 | }); 368 | }); 369 | 370 | describe('Schema', () => { 371 | test('It is ok to not give a schema', () => { 372 | expect(() => { 373 | chowchow.validateRequestByPath('/test/schema', 'get', { 374 | cookie: { 375 | count: 123, 376 | }, 377 | }); 378 | }).not.toThrowError(); 379 | }); 380 | 381 | test('It is ok to use wildcards', () => { 382 | expect(() => { 383 | chowchow.validateRequestByPath('/test/wildcard', 'post', { 384 | body: { 385 | id: 123, 386 | name: 'plum', 387 | }, 388 | header: { 389 | 'content-type': 'application/awesome', 390 | }, 391 | }); 392 | }).not.toThrowError(); 393 | }); 394 | 395 | test('It is ok to fall back to */* when no content type is provided', () => { 396 | expect(() => { 397 | chowchow.validateRequestByPath('/test/wildcard', 'post', { 398 | body: [ 399 | { 400 | id: 123, 401 | name: 'plum', 402 | }, 403 | { 404 | id: 456, 405 | name: 'chow', 406 | }, 407 | ], 408 | }); 409 | }).not.toThrowError(); 410 | }); 411 | }); 412 | 413 | describe('OperationId', () => { 414 | test('It should return unique operationId', () => { 415 | const validatedRequest = chowchow.validateRequestByPath( 416 | '/pets/123', 417 | 'get', 418 | { 419 | path: { 420 | petId: 123, 421 | }, 422 | } 423 | ); 424 | expect(validatedRequest.operationId).toEqual('showPetById'); 425 | }); 426 | 427 | test('It should respect custom operationId', () => { 428 | const validatedRequest = chowchow.validateRequestByPath( 429 | '/pets/123', 430 | 'get', 431 | { 432 | operationId: 'customId', 433 | path: { 434 | petId: 123, 435 | }, 436 | } 437 | ); 438 | expect(validatedRequest.operationId).toEqual('customId'); 439 | }); 440 | }); 441 | }); 442 | --------------------------------------------------------------------------------