├── .prettierrc.yml ├── packages ├── openapi-validator │ ├── index.ts │ ├── tsconfig.eslint.json │ ├── README.md │ ├── lib │ │ ├── classes │ │ │ ├── errors │ │ │ │ └── ValidationError.ts │ │ │ ├── AxiosResponse.ts │ │ │ ├── AbstractResponse.ts │ │ │ ├── RequestPromiseResponse.ts │ │ │ ├── SuperAgentResponse.ts │ │ │ ├── OpenApi2Spec.ts │ │ │ ├── OpenApi3Spec.ts │ │ │ └── AbstractOpenApiSpec.ts │ │ ├── responseFactory.ts │ │ ├── index.ts │ │ ├── openApiSpecFactory.ts │ │ └── utils │ │ │ ├── common.utils.ts │ │ │ └── OpenApi3Spec.utils.ts │ ├── package.json │ └── tsconfig.json ├── jest-openapi │ ├── index.ts │ ├── tsconfig.eslint.json │ ├── src │ │ ├── utils.ts │ │ ├── index.ts │ │ └── matchers │ │ │ ├── toSatisfySchemaInApiSpec.ts │ │ │ └── toSatisfyApiSpec.ts │ ├── __test__ │ │ ├── .eslintrc.yml │ │ ├── bugRecreationTemplate.test.ts │ │ ├── matchers │ │ │ ├── toSatisfySchemaInApiSpec │ │ │ │ └── noSchemaComponents.test.ts │ │ │ └── toSatisfyApiSpec │ │ │ │ ├── noResponseComponents.test.ts │ │ │ │ ├── preferNonTemplatedPathOverTemplatedPath.test.ts │ │ │ │ └── basePathDefinedDifferently.test.ts │ │ └── setup.test.ts │ ├── jest.config.ts │ ├── package.json │ ├── tsconfig.json │ └── README.md └── chai-openapi-response-validator │ ├── index.ts │ ├── tsconfig.eslint.json │ ├── .nycrc.yml │ ├── lib │ ├── utils.ts │ ├── index.ts │ └── assertions │ │ ├── satisfySchemaInApiSpec.ts │ │ └── satisfyApiSpec.ts │ ├── test │ ├── .eslintrc.yml │ ├── bugRecreationTemplate.test.ts │ ├── assertions │ │ ├── satisfySchemaInApiSpec │ │ │ └── noSchemaComponents.test.ts │ │ └── satisfyApiSpec │ │ │ ├── noResponseComponents.test.ts │ │ │ ├── preferNonTemplatedPathOverTemplatedPath.test.ts │ │ │ └── basePathDefinedDifferently.test.ts │ └── setup.test.ts │ ├── package.json │ ├── tsconfig.json │ └── README.md ├── commonTestResources ├── exampleOpenApiFiles │ ├── invalid │ │ ├── fileFormat │ │ │ ├── emptyYaml.yml │ │ │ ├── neitherYamlNorJson.js │ │ │ ├── invalidYamlFormat.yml │ │ │ └── invalidJsonFormat.json │ │ └── openApi │ │ │ ├── openApi3.yml │ │ │ └── openApi2.json │ └── valid │ │ ├── satisfySchemaInApiSpec │ │ ├── noSchemaComponents │ │ │ ├── openapi3WithNoComponents.yml │ │ │ └── openapi2WithNoDefinitions.json │ │ ├── openapi3.yml │ │ └── openapi2.json │ │ ├── noResponseComponents │ │ ├── openapi3WithNoComponents.yml │ │ └── openapi2WithNoResponses.json │ │ ├── basePathDefinedDifferently │ │ ├── noBasePathProperty.yml │ │ └── basePathProperty.yml │ │ ├── serversDefinedDifferently │ │ ├── noServersProperty.yml │ │ ├── serversIsEmptyArray.yml │ │ ├── noServersWithBasePaths.yml │ │ ├── onlyAbsoluteServersWithBasePaths.yml │ │ ├── variousServers.yml │ │ └── withServerVariables.yml │ │ ├── bugRecreationTemplate │ │ └── openapi.yml │ │ ├── preferNonTemplatedPathOverTemplatedPath │ │ ├── nonTemplatedPathAfterTemplatedPath │ │ │ ├── openapi2.yml │ │ │ └── openapi3.yml │ │ └── nonTemplatedPathBeforeTemplatedPath │ │ │ ├── openapi2.yml │ │ │ └── openapi3.yml │ │ ├── openapi3.json │ │ ├── openapi3.yml │ │ └── openapi2.json ├── tsconfig.eslint.json ├── utils.ts └── exampleApp.ts ├── .gitignore ├── types └── combos.d.ts ├── .vscode ├── settings.json └── launch.json ├── .prettierignore ├── lerna.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci.yml ├── .eslintrc.yml ├── .editorconfig ├── LICENSE ├── package.json ├── patches └── openapi-response-validator+9.2.0.patch ├── CODE_OF_CONDUCT.md ├── .all-contributorsrc ├── CONTRIBUTING.md ├── README.md └── tsconfig.json /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: all 3 | -------------------------------------------------------------------------------- /packages/openapi-validator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib'; 2 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/invalid/fileFormat/emptyYaml.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /commonTestResources/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/jest-openapi/index.ts: -------------------------------------------------------------------------------- 1 | import src from './src'; 2 | 3 | export default src; 4 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/index.ts: -------------------------------------------------------------------------------- 1 | import lib from './lib'; 2 | 3 | export default lib; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output/ 3 | coverage/ 4 | packages/**/commonTestResources 5 | .DS_STORE 6 | dist 7 | -------------------------------------------------------------------------------- /packages/jest-openapi/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/openapi-validator/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/invalid/fileFormat/neitherYamlNorJson.js: -------------------------------------------------------------------------------- 1 | 'file invalid because it is neither YAML nor JSON'; 2 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /types/combos.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'combos' { 2 | const combos: ( 3 | keysToPossibleValues: Record, 4 | ) => Record[]; 5 | 6 | export default combos; 7 | } 8 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/.nycrc.yml: -------------------------------------------------------------------------------- 1 | cwd: '..' 2 | include: 3 | - chai-openapi-response-validator 4 | - openapi-validator 5 | statements: 100 6 | branches: 100 7 | functions: 100 8 | lines: 100 9 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/invalid/fileFormat/invalidYamlFormat.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: invalid YAML 4 | description: invalid due to duplicate key 5 | description: invalid due to duplicate key -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | "commonTestResources", 4 | "packages/chai-openapi-response-validator", 5 | "packages/jest-openapi", 6 | "packages/openapi-validator" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # copied from .gitignore: 2 | node_modules 3 | .nyc_output/ 4 | coverage/ 5 | packages/**/commonTestResources 6 | .DS_STORE 7 | dist 8 | 9 | # extra: 10 | yarn.lock 11 | launch.json 12 | **/invalidYamlFormat.yml 13 | lerna.json 14 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "useWorkspaces": true, 7 | "command": { 8 | "version": { 9 | "allowBranch": "master" 10 | } 11 | }, 12 | "version": "0.14.2" 13 | } 14 | -------------------------------------------------------------------------------- /commonTestResources/utils.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util'; 2 | 3 | export const str = (obj: unknown): string => 4 | inspect(obj, { showHidden: false, depth: null }); 5 | 6 | export const joinWithNewLines = (...lines: string[]): string => 7 | lines.join('\n\n'); 8 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/invalid/fileFormat/invalidJsonFormat.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "invalid JSON", 5 | "description": "invalid due to duplicate key", 6 | "description": "invalid due to duplicate key" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/jest-openapi/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util'; 2 | 3 | export const stringify = (obj: unknown): string => 4 | inspect(obj, { showHidden: false, depth: null }); 5 | 6 | export const joinWithNewLines = (...lines: string[]): string => 7 | lines.join('\n\n'); 8 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util'; 2 | 3 | export const stringify = (obj: unknown): string => 4 | inspect(obj, { showHidden: false, depth: null }); 5 | 6 | export const joinWithNewLines = (...lines: string[]): string => 7 | lines.join('\n\n'); 8 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/invalid/openApi/openApi3.yml: -------------------------------------------------------------------------------- 1 | invalidField: {} 2 | openapi: 3.0.0 3 | info: 4 | title: Invalid OpenAPI 3 spec 5 | description: Invalid due to an invalidField (at the top) 6 | version: 0.1.0 7 | paths: 8 | /test: 9 | get: 10 | responses: 11 | 200: 12 | description: App healthy 13 | -------------------------------------------------------------------------------- /packages/jest-openapi/__test__/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | jest/globals: true 3 | 4 | plugins: 5 | - jest 6 | 7 | extends: 8 | - plugin:jest/all 9 | 10 | rules: 11 | jest/prefer-expect-assertions: off 12 | jest/no-disabled-tests: warn 13 | jest/lowercase-name: 14 | - error 15 | - ignore: 16 | - describe 17 | jest/no-hooks: off 18 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | mocha: true 3 | 4 | plugins: 5 | - mocha 6 | - chai-friendly 7 | 8 | extends: 9 | - plugin:mocha/recommended 10 | 11 | rules: 12 | '@typescript-eslint/no-unused-expressions': off 13 | chai-friendly/no-unused-expressions: error 14 | mocha/no-setup-in-describe: off 15 | mocha/no-mocha-arrows: off 16 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/satisfySchemaInApiSpec/noSchemaComponents/openapi3WithNoComponents.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Has various paths with responses to use in testing 5 | version: 0.1.0 6 | paths: 7 | /unused: 8 | get: 9 | responses: 10 | 204: 11 | description: No response body 12 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/noResponseComponents/openapi3WithNoComponents.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Has various paths with responses to use in testing 5 | version: 0.1.0 6 | paths: 7 | /endpointPath: 8 | get: 9 | responses: 10 | 204: 11 | $ref: '#/components/responses/NonExistentResponse' 12 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/basePathDefinedDifferently/noBasePathProperty.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | description: Has one path 4 | title: Example OpenApi 2 spec with no basePath 5 | version: 0.1.0 6 | paths: 7 | /endpointPath: 8 | get: 9 | responses: 10 | 200: 11 | description: Response body should be a string 12 | schema: 13 | type: string 14 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/basePathDefinedDifferently/basePathProperty.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | description: Has one path 4 | title: Example OpenApi 2 spec with basePath 5 | version: 0.1.0 6 | basePath: /basePath 7 | paths: 8 | /endpointPath: 9 | get: 10 | responses: 11 | 200: 12 | description: Response body should be a string 13 | schema: 14 | type: string 15 | -------------------------------------------------------------------------------- /packages/openapi-validator/README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI Validator 2 | 3 | [![downloads](https://img.shields.io/npm/dm/openapi-validator)](https://www.npmjs.com/package/openapi-validator) 4 | [![npm](https://img.shields.io/npm/v/openapi-validator.svg)](https://www.npmjs.com/package/openapi-validator) 5 | 6 | Common code for [jest-openapi](https://www.npmjs.com/package/jest-openapi) and [Chai OpenAPI Response Validator](https://www.npmjs.com/package/chai-openapi-response-validator) 7 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/serversDefinedDifferently/noServersProperty.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Has no 'servers' property 5 | version: 0.1.0 6 | paths: 7 | /endpointPath: 8 | get: 9 | responses: 10 | 200: 11 | description: Response body should be a string 12 | content: 13 | application/json: 14 | schema: 15 | type: string 16 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/errors/ValidationError.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | ServerNotFound, 3 | BasePathNotFound, 4 | PathNotFound, 5 | MethodNotFound, 6 | StatusNotFound, 7 | InvalidBody, 8 | InvalidObject, 9 | } 10 | 11 | export default class ValidationError extends Error { 12 | constructor(public code: ErrorCode, message?: string) { 13 | super(message); 14 | } 15 | 16 | override toString(): string { 17 | return this.message; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/serversDefinedDifferently/serversIsEmptyArray.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: \'servers' is an empty array 5 | version: 0.1.0 6 | servers: [] 7 | paths: 8 | /endpointPath: 9 | get: 10 | responses: 11 | 200: 12 | description: Response body should be a string 13 | content: 14 | application/json: 15 | schema: 16 | type: string 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Are you using OpenAPI 2, 3.0.X, or 3.1.0?** 11 | 12 | 13 | **Would this solve a problem or make something easier?** 14 | 15 | 16 | **What would you like to happen?** 17 | 18 | 19 | **Describe alternatives you've considered** 20 | 21 | 22 | **Additional context or screenshots** 23 | 24 | 25 | **Are you going to resolve the issue?** 26 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/invalid/openApi/openApi2.json: -------------------------------------------------------------------------------- 1 | { 2 | "invalidField": {}, 3 | "swagger": "2.0", 4 | "info": { 5 | "description": "Invalid due to an invalidField (at the top)", 6 | "title": "Invalid OpenAPI 2 spec", 7 | "version": "0.1.0" 8 | }, 9 | "paths": { 10 | "/test": { 11 | "get": { 12 | "parameters": [], 13 | "responses": { 14 | "200": { 15 | "description": "App healthy" 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/bugRecreationTemplate/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Use to recreate a bug 5 | version: 0.1.0 6 | paths: 7 | /recreate/bug: 8 | get: 9 | responses: 10 | '200': 11 | description: OK 12 | content: 13 | application/json: 14 | schema: 15 | type: object 16 | properties: 17 | expectedProperty1: 18 | type: string 19 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/noResponseComponents/openapi2WithNoResponses.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Has various paths with responses to use in testing", 5 | "title": "Example OpenApi 3 spec", 6 | "version": "0.1.0" 7 | }, 8 | "paths": { 9 | "/endpointPath": { 10 | "get": { 11 | "parameters": [], 12 | "responses": { 13 | "204": { 14 | "$ref": "#/responses/NonExistentResponse" 15 | } 16 | } 17 | } 18 | } 19 | }, 20 | "x-components": {} 21 | } 22 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/satisfySchemaInApiSpec/noSchemaComponents/openapi2WithNoDefinitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Has various paths with responses to use in testing", 5 | "title": "Example OpenApi 3 spec", 6 | "version": "0.1.0" 7 | }, 8 | "paths": { 9 | "/unused": { 10 | "get": { 11 | "parameters": [], 12 | "responses": { 13 | "204": { 14 | "description": "No response body" 15 | } 16 | } 17 | } 18 | } 19 | }, 20 | "x-components": {} 21 | } 22 | -------------------------------------------------------------------------------- /packages/jest-openapi/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | rootDir: '..', 7 | roots: ['/jest-openapi', '/openapi-validator'], 8 | collectCoverageFrom: [ 9 | '/jest-openapi/src/**/*', 10 | '/openapi-validator/lib/**/*', 11 | ], 12 | coverageThreshold: { 13 | global: { 14 | branches: 100, 15 | functions: 100, 16 | lines: 100, 17 | statements: 100, 18 | }, 19 | }, 20 | }; 21 | 22 | export default config; 23 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/serversDefinedDifferently/noServersWithBasePaths.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: This spec does not define a server with a base path 5 | version: 0.1.0 6 | servers: 7 | - url: http://api.example.com 8 | description: absolute server without a base path 9 | paths: 10 | /endpointPath: 11 | get: 12 | responses: 13 | 200: 14 | description: Response body should be a string 15 | content: 16 | application/json: 17 | schema: 18 | type: string 19 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/serversDefinedDifferently/onlyAbsoluteServersWithBasePaths.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Has an absolute server with a base path 5 | version: 0.1.0 6 | servers: 7 | - url: http://api.example.com/basePath1 8 | description: absolute server with a base path 9 | paths: 10 | /endpointPath: 11 | get: 12 | responses: 13 | 200: 14 | description: Response body should be a string 15 | content: 16 | application/json: 17 | schema: 18 | type: string 19 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/responseFactory.ts: -------------------------------------------------------------------------------- 1 | import { RawResponse } from './classes/AbstractResponse'; 2 | import AxiosResponse from './classes/AxiosResponse'; 3 | import RequestPromiseResponse from './classes/RequestPromiseResponse'; 4 | import SuperAgentResponse from './classes/SuperAgentResponse'; 5 | 6 | export default function makeResponse( 7 | res: RawResponse, 8 | ): AxiosResponse | SuperAgentResponse | RequestPromiseResponse { 9 | if ('data' in res) { 10 | return new AxiosResponse(res); 11 | } 12 | if ('status' in res) { 13 | return new SuperAgentResponse(res); 14 | } 15 | return new RequestPromiseResponse(res); 16 | } 17 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/AxiosResponse.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse as AxiosResponseType } from 'axios'; 2 | import AbstractResponse from './AbstractResponse'; 3 | 4 | export type RawAxiosResponse = AxiosResponseType; 5 | 6 | export default class AxiosResponse extends AbstractResponse { 7 | constructor(protected override res: RawAxiosResponse) { 8 | super(res); 9 | this.status = res.status; 10 | this.body = res.data; 11 | this.req = res.request; 12 | this.bodyHasNoContent = this.body === ''; 13 | } 14 | 15 | getBodyForValidation(): AxiosResponse['body'] { 16 | if (this.bodyHasNoContent) { 17 | return null; 18 | } 19 | return this.body; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parser: '@typescript-eslint/parser' 2 | parserOptions: 3 | ecmaVersion: 2018 4 | sourceType: module 5 | project: tsconfig.eslint.json 6 | 7 | env: 8 | es6: true 9 | node: true 10 | 11 | extends: 12 | - eslint:recommended 13 | - airbnb-base 14 | - airbnb-typescript/base 15 | - plugin:@typescript-eslint/recommended 16 | # - plugin:@typescript-eslint/recommended-requiring-type-checking 17 | - prettier/@typescript-eslint 18 | - prettier # must go last, to turn off some previous rules 19 | 20 | rules: 21 | prefer-arrow-callback: error 22 | func-names: off 23 | no-use-before-define: off 24 | require-await: error 25 | '@typescript-eslint/no-use-before-define': off 26 | 27 | ignorePatterns: 28 | - dist 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '*.md' 7 | - '.all-contributorsrc' 8 | pull_request: 9 | paths-ignore: 10 | - '*.md' 11 | - '.all-contributorsrc' 12 | workflow_dispatch: 13 | 14 | jobs: 15 | Test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - uses: actions/setup-node@v2.1.5 21 | with: 22 | node-version: 12 23 | 24 | - name: Use yarn cache 25 | uses: c-hive/gha-yarn-cache@v1 26 | 27 | - run: yarn install --frozen-lockfile 28 | 29 | - run: yarn test:ci 30 | 31 | - name: Post code coverage to CodeCov 32 | run: bash <(curl -s https://codecov.io/bash) 33 | if: success() 34 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/preferNonTemplatedPathOverTemplatedPath/nonTemplatedPathAfterTemplatedPath/openapi2.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Test OpenApi 2 spec 4 | description: Test that our plugins prefer to match responses to non-templated paths over templated paths 5 | version: 0.1.0 6 | paths: 7 | /preferNonTemplatedPathOverTemplatedPath/{templatedPath}: 8 | get: 9 | responses: 10 | 200: 11 | description: Response body should be a number 12 | schema: 13 | type: number 14 | /preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath: 15 | get: 16 | responses: 17 | 200: 18 | description: Response body should be a string 19 | schema: 20 | type: string 21 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/preferNonTemplatedPathOverTemplatedPath/nonTemplatedPathBeforeTemplatedPath/openapi2.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Test OpenApi 2 spec 4 | description: Test that our plugins prefer to match responses to non-templated paths over templated paths 5 | version: 0.1.0 6 | paths: 7 | /preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath: 8 | get: 9 | responses: 10 | 200: 11 | description: Response body should be a string 12 | schema: 13 | type: string 14 | /preferNonTemplatedPathOverTemplatedPath/{templatedPath}: 15 | get: 16 | responses: 17 | 200: 18 | description: Response body should be a number 19 | schema: 20 | type: number 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # Use 4 spaces for the Python files 13 | [*.py] 14 | indent_size = 4 15 | max_line_length = 80 16 | 17 | # The JSON files contain newlines inconsistently 18 | [*.json] 19 | insert_final_newline = ignore 20 | 21 | # Minified JavaScript files shouldn't be changed 22 | [**.min.js] 23 | indent_style = ignore 24 | insert_final_newline = ignore 25 | 26 | # Makefiles always use tabs for indentation 27 | [Makefile] 28 | indent_style = tab 29 | 30 | # Batch files use tabs for indentation 31 | [*.bat] 32 | indent_style = tab 33 | 34 | [*.md] 35 | trim_trailing_whitespace = false 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a reproducible bug 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Are you using jest or chai**? 11 | 12 | 13 | **Are you using OpenAPI 2, 3.0.X, or 3.1.0?** 14 | 15 | 16 | **Describe the bug clearly** 17 | 18 | 19 | **Steps to reproduce the bug:** 20 | 1. ... 21 | 2. ... 22 | 3. See error (please paste error output or a screenshot) 23 | 24 | 25 | 26 | **What did you expect to happen instead?** 27 | 28 | 29 | **Are you going to resolve the issue?** 30 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/preferNonTemplatedPathOverTemplatedPath/nonTemplatedPathAfterTemplatedPath/openapi3.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Test OpenApi 3 spec 4 | description: Test that our plugins prefer to match responses to non-templated paths over templated paths 5 | version: 0.1.0 6 | paths: 7 | /preferNonTemplatedPathOverTemplatedPath/{templatedPath}: 8 | get: 9 | responses: 10 | 200: 11 | description: Response body should be a number 12 | content: 13 | application/json: 14 | schema: 15 | type: number 16 | /preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath: 17 | get: 18 | responses: 19 | 200: 20 | description: Response body should be a string 21 | content: 22 | application/json: 23 | schema: 24 | type: string 25 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/preferNonTemplatedPathOverTemplatedPath/nonTemplatedPathBeforeTemplatedPath/openapi3.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Test OpenApi 3 spec 4 | description: Test that our plugins prefer to match responses to non-templated paths over templated paths 5 | version: 0.1.0 6 | paths: 7 | /preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath: 8 | get: 9 | responses: 10 | 200: 11 | description: Response body should be a string 12 | content: 13 | application/json: 14 | schema: 15 | type: string 16 | /preferNonTemplatedPathOverTemplatedPath/{templatedPath}: 17 | get: 18 | responses: 19 | 200: 20 | description: Response body should be a number 21 | content: 22 | application/json: 23 | schema: 24 | type: number 25 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/index.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPI } from 'openapi-types'; 2 | import type OpenApi2Spec from './classes/OpenApi2Spec'; 3 | import type OpenApi3Spec from './classes/OpenApi3Spec'; 4 | 5 | export type { Schema } from './classes/AbstractOpenApiSpec'; 6 | export type { 7 | ActualRequest, 8 | ActualResponse, 9 | RawResponse, 10 | } from './classes/AbstractResponse'; 11 | export type { default as ValidationError } from './classes/errors/ValidationError'; 12 | export { ErrorCode } from './classes/errors/ValidationError'; 13 | export type { default as OpenApi2Spec } from './classes/OpenApi2Spec'; 14 | export type { default as OpenApi3Spec } from './classes/OpenApi3Spec'; 15 | export { default as makeApiSpec } from './openApiSpecFactory'; 16 | export { default as makeResponse } from './responseFactory'; 17 | 18 | export type OpenApiSpec = OpenApi2Spec | OpenApi3Spec; 19 | export type OpenAPISpecObject = OpenAPI.Document; 20 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/openapi3.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Example API", 5 | "description": "Example OpenApi 3 spec", 6 | "version": "0.1.0" 7 | }, 8 | "servers": [ 9 | { 10 | "url": "/local", 11 | "description": "local" 12 | }, 13 | { 14 | "url": "/remote", 15 | "description": "remote" 16 | } 17 | ], 18 | "paths": { 19 | "/test": { 20 | "get": { 21 | "responses": { 22 | "200": { 23 | "description": "Response body should be a string", 24 | "content": { 25 | "application/json": { 26 | "schema": { 27 | "type": "string" 28 | } 29 | } 30 | } 31 | }, 32 | "204": { 33 | "description": "No response body" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /commonTestResources/exampleApp.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; // eslint-disable-line import/no-extraneous-dependencies 2 | 3 | const app = express() as Express & { 4 | server: ReturnType; 5 | }; 6 | 7 | app.get('/header/application/json/and/responseBody/string', (_req, res) => 8 | res.json('res.body is a string'), 9 | ); 10 | 11 | app.get('/header/application/json/and/responseBody/emptyObject', (_req, res) => 12 | res.send({}), 13 | ); 14 | 15 | app.get('/header/application/json/and/responseBody/boolean', (_req, res) => 16 | res.json(false), 17 | ); 18 | 19 | app.get('/header/application/json/and/responseBody/nullable', (_req, res) => 20 | res.json(null), 21 | ); 22 | 23 | app.get('/header/text/html', (_req, res) => res.send('res.body is a string')); 24 | 25 | app.get('/no/content-type/header/and/no/response/body', (_req, res) => 26 | res.sendStatus(204), 27 | ); 28 | 29 | export default app; 30 | 31 | export const port = 5000; 32 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/test/bugRecreationTemplate.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import path from 'path'; 3 | 4 | import chaiResponseValidator from '..'; 5 | 6 | const dirContainingApiSpec = path.resolve( 7 | '../../commonTestResources/exampleOpenApiFiles/valid/bugRecreationTemplate', 8 | ); 9 | const { expect } = chai; 10 | 11 | describe('Recreate bug (issue #XX)', () => { 12 | before(() => { 13 | const pathToApiSpec = path.join(dirContainingApiSpec, 'openapi.yml'); 14 | chai.use(chaiResponseValidator(pathToApiSpec)); 15 | }); 16 | 17 | const res = { 18 | status: 200, 19 | req: { 20 | method: 'GET', 21 | path: '/recreate/bug', 22 | }, 23 | body: { 24 | expectedProperty1: 'foo', 25 | }, 26 | }; 27 | 28 | it('passes', () => { 29 | expect(res).to.satisfyApiSpec; 30 | }); 31 | 32 | it('fails when using .not', () => { 33 | const assertion = () => expect(res).to.not.satisfyApiSpec; 34 | expect(assertion).to.throw(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/jest-openapi/__test__/bugRecreationTemplate.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { inspect } from 'util'; 3 | 4 | import jestOpenAPI from '..'; 5 | 6 | const dirContainingApiSpec = path.resolve( 7 | '../../commonTestResources/exampleOpenApiFiles/valid/bugRecreationTemplate', 8 | ); 9 | 10 | describe('Recreate bug (issue #XX)', () => { 11 | beforeAll(() => { 12 | const pathToApiSpec = path.join(dirContainingApiSpec, 'openapi.yml'); 13 | jestOpenAPI(pathToApiSpec); 14 | }); 15 | 16 | const res = { 17 | status: 200, 18 | req: { 19 | method: 'GET', 20 | path: '/recreate/bug', 21 | }, 22 | body: { 23 | expectedProperty1: 'foo', 24 | }, 25 | }; 26 | 27 | it('passes', () => { 28 | expect(res).toSatisfyApiSpec(); 29 | }); 30 | 31 | it('fails when using .not', () => { 32 | const assertion = () => expect(res).not.toSatisfyApiSpec(); 33 | expect(assertion).toThrow( 34 | inspect({ 35 | body: { 36 | expectedProperty1: 'foo', 37 | }, 38 | }), 39 | ); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/AbstractResponse.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from '../utils/common.utils'; 2 | import type { RawAxiosResponse } from './AxiosResponse'; 3 | import type { RawRequestPromiseResponse } from './RequestPromiseResponse'; 4 | import type { RawSuperAgentResponse } from './SuperAgentResponse'; 5 | 6 | export type RawResponse = 7 | | RawAxiosResponse 8 | | RawSuperAgentResponse 9 | | RawRequestPromiseResponse; 10 | 11 | export default abstract class AbstractResponse { 12 | public declare status: number; 13 | 14 | public declare req: { method: string; path: string }; 15 | 16 | public abstract getBodyForValidation(): unknown; 17 | 18 | protected body: unknown; 19 | 20 | protected declare bodyHasNoContent: boolean; 21 | 22 | constructor(protected res: RawResponse) {} 23 | 24 | summary(): { body: unknown } { 25 | return { 26 | body: this.body, 27 | }; 28 | } 29 | 30 | toString(): string { 31 | return stringify(this.summary()); 32 | } 33 | } 34 | 35 | export type ActualResponse = AbstractResponse; 36 | 37 | export type ActualRequest = AbstractResponse['req']; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) openapi-library 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/RequestPromiseResponse.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'request'; 2 | import AbstractResponse from './AbstractResponse'; 3 | 4 | export type RawRequestPromiseResponse = Response & { 5 | req: Request; 6 | request: Response['request'] & { 7 | _json?: unknown; 8 | }; 9 | }; 10 | 11 | export default class RequestPromiseResponse extends AbstractResponse { 12 | constructor(protected override res: RawRequestPromiseResponse) { 13 | super(res); 14 | this.status = res.statusCode; 15 | this.body = res.request._json // eslint-disable-line no-underscore-dangle 16 | ? res.body 17 | : res.body.replace(/"/g, ''); 18 | this.req = res.req; 19 | this.bodyHasNoContent = this.body === ''; 20 | } 21 | 22 | getBodyForValidation(): RequestPromiseResponse['body'] { 23 | if (this.bodyHasNoContent) { 24 | return null; 25 | } 26 | try { 27 | return JSON.parse(this.body as string); 28 | } catch (error) { 29 | // if JSON.parse errors, then body is not stringfied JSON that 30 | // needs parsing into a JSON object, so just move to the next 31 | // block and return the body 32 | } 33 | return this.body; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/serversDefinedDifferently/variousServers.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Has various server urls 5 | version: 0.1.0 6 | servers: 7 | - url: /relativeServer 8 | description: relative server url 9 | - url: /differentRelativeServer 10 | description: different relative server url 11 | - url: /relativeServer2 12 | description: pathnames that match this will also match 'relativeServer' 13 | - url: http://api.example.com/basePath1 14 | description: different scheme (http) 15 | - url: https://api.example.com/basePath2 16 | description: different scheme (https) 17 | - url: ws://api.example.com/basePath3 18 | description: different scheme (ws) 19 | - url: wss://api.example.com/basePath4 20 | description: different scheme (wss) 21 | - url: http://api.example.com:8443/basePath5 22 | description: with port 23 | - url: http://localhost:3025/basePath6 24 | description: different host (localhost) 25 | - url: http://10.0.81.36/basePath7 26 | description: different host (IPv4) 27 | paths: 28 | /endpointPath: 29 | get: 30 | responses: 31 | 200: 32 | description: Response body should be a string 33 | content: 34 | application/json: 35 | schema: 36 | type: string 37 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { makeApiSpec, OpenAPISpecObject } from 'openapi-validator'; 2 | import satisfyApiSpec from './assertions/satisfyApiSpec'; 3 | import satisfySchemaInApiSpec from './assertions/satisfySchemaInApiSpec'; 4 | 5 | declare global { 6 | // eslint-disable-next-line @typescript-eslint/no-namespace 7 | namespace Chai { 8 | interface Assertion { 9 | /** 10 | * Check the HTTP response object satisfies a response defined in your OpenAPI spec. 11 | * [See usage example](https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/chai-openapi-response-validator#in-api-tests-validate-the-status-and-body-of-http-responses-against-your-openapi-spec) 12 | */ 13 | satisfyApiSpec: Assertion; 14 | /** 15 | * Check the object satisfies a schema defined in your OpenAPI spec. 16 | * [See usage example](https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/chai-openapi-response-validator#in-unit-tests-validate-objects-against-schemas-defined-in-your-openapi-spec) 17 | */ 18 | satisfySchemaInApiSpec(schemaName: string): Assertion; 19 | } 20 | } 21 | } 22 | 23 | export default function ( 24 | filepathOrObject: string | OpenAPISpecObject, 25 | ): Chai.ChaiPlugin { 26 | const openApiSpec = makeApiSpec(filepathOrObject); 27 | return function (chai) { 28 | satisfyApiSpec(chai, openApiSpec); 29 | satisfySchemaInApiSpec(chai, openApiSpec); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "type": "node", 10 | "request": "launch", 11 | "name": "Test chai-openapi-response-validator (Mocha)", 12 | "program": "${workspaceFolder}/node_modules/ts-mocha/bin/ts-mocha", 13 | "args": [ 14 | "--timeout", 15 | "999999", // timeout length. Required since while debugging we may pause for longer than Mocha's test timeout 16 | "--colors", 17 | "${workspaceFolder}/packages/chai-openapi-response-validator/test", 18 | "--recursive", 19 | "--exit", 20 | ], 21 | "internalConsoleOptions": "openOnSessionStart", 22 | "cwd": "${workspaceFolder}/packages/chai-openapi-response-validator" 23 | }, 24 | { 25 | "type": "node", 26 | "request": "launch", 27 | "name": "Test jest-openapi (Jest)", 28 | "program": "${workspaceFolder}/node_modules/.bin/jest", 29 | "args": ["--runInBand"], 30 | "console": "integratedTerminal", 31 | "internalConsoleOptions": "neverOpen", 32 | "disableOptimisticBPs": true, 33 | "windows": { 34 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 35 | }, 36 | "cwd": "${workspaceFolder}/packages/jest-openapi" 37 | }, 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/SuperAgentResponse.ts: -------------------------------------------------------------------------------- 1 | import type { Response, SuperAgentRequest } from 'superagent'; 2 | import AbstractResponse from './AbstractResponse'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | const isEmptyObj = (obj: any): obj is Record => 6 | !!obj && Object.entries(obj).length === 0 && obj.constructor === Object; 7 | 8 | export type RawSuperAgentResponse = Response & { 9 | req: SuperAgentRequest & { path: string }; 10 | }; 11 | 12 | export default class SuperAgentResponse extends AbstractResponse { 13 | private isResTextPopulatedInsteadOfResBody: boolean; 14 | 15 | constructor(protected override res: RawSuperAgentResponse) { 16 | super(res); 17 | this.status = res.status; 18 | this.body = res.body; 19 | this.req = res.req; 20 | this.isResTextPopulatedInsteadOfResBody = 21 | res.text !== '{}' && isEmptyObj(this.body); 22 | this.bodyHasNoContent = res.text === ''; 23 | } 24 | 25 | getBodyForValidation(): SuperAgentResponse['body'] { 26 | if (this.bodyHasNoContent) { 27 | return null; 28 | } 29 | if (this.isResTextPopulatedInsteadOfResBody) { 30 | return this.res.text; 31 | } 32 | return this.body; 33 | } 34 | 35 | override summary(): ReturnType & { 36 | text?: string; 37 | } { 38 | return { 39 | ...super.summary(), 40 | ...(this.isResTextPopulatedInsteadOfResBody && { text: this.res.text }), 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/satisfySchemaInApiSpec/openapi3.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Has various paths with responses to use in testing 5 | version: 0.1.0 6 | paths: 7 | /unused: 8 | get: 9 | responses: 10 | 204: 11 | description: No response body 12 | components: 13 | schemas: 14 | StringSchema: 15 | type: string 16 | IntegerSchema: 17 | type: integer 18 | SimpleObjectSchema: 19 | type: object 20 | required: 21 | - property1 22 | properties: 23 | property1: 24 | type: string 25 | SimpleObjectSchemaWithDifferentPropertyName: 26 | type: object 27 | required: 28 | - property2 29 | properties: 30 | property2: 31 | type: string 32 | SchemaReferencingAnotherSchema: 33 | required: 34 | - property1 35 | properties: 36 | property1: 37 | $ref: '#/components/schemas/StringSchema' 38 | SchemaUsingAllOf: 39 | allOf: 40 | - $ref: '#/components/schemas/SimpleObjectSchema' 41 | - $ref: '#/components/schemas/SimpleObjectSchemaWithDifferentPropertyName' 42 | SchemaUsingAnyOf: 43 | anyOf: 44 | - $ref: '#/components/schemas/SimpleObjectSchema' 45 | - $ref: '#/components/schemas/SimpleObjectSchemaWithDifferentPropertyName' 46 | SchemaUsingOneOf: 47 | oneOf: 48 | - $ref: '#/components/schemas/SimpleObjectSchema' 49 | - $ref: '#/components/schemas/SimpleObjectSchemaWithDifferentPropertyName' 50 | -------------------------------------------------------------------------------- /packages/jest-openapi/__test__/matchers/toSatisfySchemaInApiSpec/noSchemaComponents.test.ts: -------------------------------------------------------------------------------- 1 | import { EXPECTED_COLOR as green } from 'jest-matcher-utils'; 2 | import path from 'path'; 3 | 4 | import jestOpenAPI from '../../..'; 5 | 6 | const openApiSpecsDir = path.resolve( 7 | '../../commonTestResources/exampleOpenApiFiles/valid/satisfySchemaInApiSpec/noSchemaComponents', 8 | ); 9 | const openApiSpecs = [ 10 | { 11 | openApiVersion: 2, 12 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi2WithNoDefinitions.json'), 13 | }, 14 | { 15 | openApiVersion: 3, 16 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi3WithNoComponents.yml'), 17 | }, 18 | ]; 19 | 20 | openApiSpecs.forEach((spec) => { 21 | const { openApiVersion, pathToApiSpec } = spec; 22 | 23 | describe(`expect(obj).to.satisfySchemaInApiSpec(schemaName) (using an OpenAPI ${openApiVersion} spec with no schema definitions)`, () => { 24 | const obj = 'foo'; 25 | 26 | beforeAll(() => { 27 | jestOpenAPI(pathToApiSpec); 28 | }); 29 | 30 | it('fails', () => { 31 | const assertion = () => 32 | expect(obj).toSatisfySchemaInApiSpec('NonExistentSchema'); 33 | expect(assertion).toThrow( 34 | `${green('schemaName')} must match a schema in your API spec`, 35 | ); 36 | }); 37 | 38 | it('fails when using .not', () => { 39 | const assertion = () => 40 | expect(obj).not.toSatisfySchemaInApiSpec('NonExistentSchema'); 41 | expect(assertion).toThrow( 42 | `${green('schemaName')} must match a schema in your API spec`, 43 | ); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/satisfySchemaInApiSpec/openapi2.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Has various paths with responses to use in testing", 5 | "title": "Example OpenApi 3 spec", 6 | "version": "0.1.0" 7 | }, 8 | "paths": { 9 | "/unused": { 10 | "get": { 11 | "parameters": [], 12 | "responses": { 13 | "204": { 14 | "description": "No response body" 15 | } 16 | } 17 | } 18 | } 19 | }, 20 | "definitions": { 21 | "IntegerSchema": { 22 | "type": "integer" 23 | }, 24 | "SchemaReferencingAnotherSchema": { 25 | "required": ["property1"], 26 | "properties": { 27 | "property1": { 28 | "$ref": "#/definitions/StringSchema" 29 | } 30 | } 31 | }, 32 | "SchemaUsingAllOf": { 33 | "allOf": [ 34 | { 35 | "$ref": "#/definitions/SimpleObjectSchema" 36 | }, 37 | { 38 | "$ref": "#/definitions/SimpleObjectSchemaWithDifferentPropertyName" 39 | } 40 | ] 41 | }, 42 | "SimpleObjectSchema": { 43 | "type": "object", 44 | "required": ["property1"], 45 | "properties": { 46 | "property1": { 47 | "type": "string" 48 | } 49 | } 50 | }, 51 | "SimpleObjectSchemaWithDifferentPropertyName": { 52 | "properties": { 53 | "property2": { 54 | "type": "string" 55 | } 56 | }, 57 | "required": ["property2"], 58 | "type": "object" 59 | }, 60 | "StringSchema": { 61 | "type": "string" 62 | } 63 | }, 64 | "x-components": {} 65 | } 66 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/serversDefinedDifferently/withServerVariables.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Defines servers using server variables 5 | version: 0.1.0 6 | servers: 7 | - url: /{variableInPath} 8 | description: server url with 1 server variable in the path 9 | variables: 10 | variableInPath: 11 | default: defaultValueOfVariableInPath 12 | enum: 13 | - enumValueOfVariableInPath 14 | - url: /{firstVariableInPath}/{secondVariableInPath} 15 | description: server url with multiple server variables in the path 16 | variables: 17 | firstVariableInPath: 18 | default: defaultValueOfFirstVariableInPath 19 | secondVariableInPath: 20 | default: defaultValueOfSecondVariableInPath 21 | - url: https://{hostVariable}.com:{portVariable}/ 22 | description: server url with server variables only before the path 23 | variables: 24 | hostVariable: 25 | default: defaultValueOfHostVariable 26 | portVariable: 27 | default: '1234' 28 | - url: https://{hostVariable}.com:{portVariable}/{variableInDifferentPath} 29 | description: server url with server variables before and after the path 30 | variables: 31 | hostVariable: 32 | default: defaultValueOfHostVariable 33 | portVariable: 34 | default: '1234' 35 | variableInDifferentPath: 36 | default: defaultValueOfVariableInDifferentPath 37 | paths: 38 | /endpointPath: 39 | get: 40 | responses: 41 | 200: 42 | description: Response body should be a string 43 | content: 44 | application/json: 45 | schema: 46 | type: string 47 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/test/assertions/satisfySchemaInApiSpec/noSchemaComponents.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import path from 'path'; 3 | 4 | import chaiResponseValidator from '../../..'; 5 | 6 | const openApiSpecsDir = path.resolve( 7 | '../../commonTestResources/exampleOpenApiFiles/valid/satisfySchemaInApiSpec/noSchemaComponents', 8 | ); 9 | const openApiSpecs = [ 10 | { 11 | openApiVersion: 2, 12 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi2WithNoDefinitions.json'), 13 | }, 14 | { 15 | openApiVersion: 3, 16 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi3WithNoComponents.yml'), 17 | }, 18 | ]; 19 | 20 | const { expect, AssertionError } = chai; 21 | 22 | openApiSpecs.forEach((spec) => { 23 | const { openApiVersion, pathToApiSpec } = spec; 24 | 25 | describe(`expect(obj).to.satisfySchemaInApiSpec(schemaName) (using an OpenAPI ${openApiVersion} spec with no schema definitions)`, () => { 26 | const obj = 'foo'; 27 | 28 | before(() => { 29 | chai.use(chaiResponseValidator(pathToApiSpec)); 30 | }); 31 | 32 | it('fails', () => { 33 | const assertion = () => 34 | expect(obj).to.satisfySchemaInApiSpec('NonExistentSchema'); 35 | expect(assertion).to.throw( 36 | AssertionError, 37 | 'The argument to satisfySchemaInApiSpec must match a schema in your API spec', 38 | ); 39 | }); 40 | 41 | it('fails when using .not', () => { 42 | const assertion = () => 43 | expect(obj).to.not.satisfySchemaInApiSpec('NonExistentSchema'); 44 | expect(assertion).to.throw( 45 | AssertionError, 46 | 'The argument to satisfySchemaInApiSpec must match a schema in your API spec', 47 | ); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "openapi-validators", 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "engines": { 8 | "node": ">=10.0.0" 9 | }, 10 | "scripts": { 11 | "postinstall": "patch-package", 12 | "patch-packages": "yarn patch-package openapi-response-validator", 13 | "clean": "yarn workspaces run clean && rimraf packages/.nyc_output && rimraf packages/coverage", 14 | "format": "prettier --write . --ignore-path .prettierignore", 15 | "lint": "yarn workspaces run lint", 16 | "build": "yarn workspaces run build", 17 | "test": "yarn workspaces run test", 18 | "test:ci": "yarn workspaces run test:ci", 19 | "lerna:version:preview": "yarn lerna:version --no-git-tag-version", 20 | "lerna:version": "lerna version -m \"chore(release): %s\" --conventional-commits --no-changelog", 21 | "lerna:publish": "lerna publish -m \"chore(release): %s\" --conventional-commits --no-changelog" 22 | }, 23 | "devDependencies": { 24 | "@types/express": "^4.17.13", 25 | "@typescript-eslint/eslint-plugin": "^4.33.0", 26 | "@typescript-eslint/parser": "^4.33.0", 27 | "eslint": "^7.11.0", 28 | "eslint-config-airbnb-base": "^14.2.1", 29 | "eslint-config-airbnb-typescript": "^14.0.1", 30 | "eslint-config-prettier": "^6.12.0", 31 | "eslint-plugin-import": "^2.22.1", 32 | "eslint-plugin-jest": "^24.1.0", 33 | "eslint-plugin-mocha": "^8.0.0", 34 | "express": "^4.17.1", 35 | "husky": "^4.3.0", 36 | "lerna": "^4.0.0", 37 | "patch-package": "^6.4.7", 38 | "postinstall-postinstall": "^2.1.0", 39 | "prettier": "^2.4.1", 40 | "rimraf": "^3.0.2", 41 | "typescript": "^4.4.3" 42 | }, 43 | "husky": { 44 | "hooks": { 45 | "pre-commit": "yarn test:ci" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/test/assertions/satisfyApiSpec/noResponseComponents.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import path from 'path'; 3 | 4 | import { joinWithNewLines } from '../../../../../commonTestResources/utils'; 5 | import chaiResponseValidator from '../../..'; 6 | 7 | const openApiSpecsDir = path.resolve( 8 | '../../commonTestResources/exampleOpenApiFiles/valid/noResponseComponents', 9 | ); 10 | const openApiSpecs = [ 11 | { 12 | openApiVersion: 2, 13 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi2WithNoResponses.json'), 14 | }, 15 | { 16 | openApiVersion: 3, 17 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi3WithNoComponents.yml'), 18 | }, 19 | ]; 20 | 21 | const { expect } = chai; 22 | 23 | openApiSpecs.forEach((spec) => { 24 | const { openApiVersion, pathToApiSpec } = spec; 25 | 26 | describe(`expect(res).to.satisfyApiSpec (using an OpenAPI ${openApiVersion} spec with no response component definitions)`, () => { 27 | const res = { 28 | status: 204, 29 | req: { 30 | method: 'GET', 31 | path: '/endpointPath', 32 | }, 33 | }; 34 | 35 | before(() => { 36 | chai.use(chaiResponseValidator(pathToApiSpec)); 37 | }); 38 | 39 | it('fails', () => { 40 | const assertion = () => expect(res).to.satisfyApiSpec; 41 | expect(assertion).to.throw( 42 | joinWithNewLines( 43 | "expected res to satisfy a '204' response defined for endpoint 'GET /endpointPath' in your API spec", 44 | "res had status '204', but your API spec has no '204' response defined for endpoint 'GET /endpointPath'", 45 | ), 46 | ); 47 | }); 48 | 49 | it('passes when using .not', () => { 50 | expect(res).not.to.satisfyApiSpec; 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/jest-openapi/__test__/matchers/toSatisfyApiSpec/noResponseComponents.test.ts: -------------------------------------------------------------------------------- 1 | import { RECEIVED_COLOR as red } from 'jest-matcher-utils'; 2 | import path from 'path'; 3 | 4 | import { joinWithNewLines } from '../../../../../commonTestResources/utils'; 5 | import jestOpenAPI from '../../..'; 6 | 7 | const openApiSpecsDir = path.resolve( 8 | '../../commonTestResources/exampleOpenApiFiles/valid/noResponseComponents', 9 | ); 10 | const openApiSpecs = [ 11 | { 12 | openApiVersion: 2, 13 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi2WithNoResponses.json'), 14 | }, 15 | { 16 | openApiVersion: 3, 17 | pathToApiSpec: path.join(openApiSpecsDir, 'openapi3WithNoComponents.yml'), 18 | }, 19 | ]; 20 | 21 | openApiSpecs.forEach((spec) => { 22 | const { openApiVersion, pathToApiSpec } = spec; 23 | 24 | describe(`expect(res).toSatisfyApiSpec() (using an OpenAPI ${openApiVersion} spec with no response component definitions)`, () => { 25 | const res = { 26 | status: 204, 27 | req: { 28 | method: 'GET', 29 | path: '/endpointPath', 30 | }, 31 | }; 32 | 33 | beforeAll(() => { 34 | jestOpenAPI(pathToApiSpec); 35 | }); 36 | 37 | it('fails', () => { 38 | const assertion = () => expect(res).toSatisfyApiSpec(); 39 | expect(assertion).toThrow( 40 | // prettier-ignore 41 | joinWithNewLines( 42 | `expected ${red('received')} to satisfy a '204' response defined for endpoint 'GET /endpointPath' in your API spec`, 43 | `${red('received')} had status ${red('204')}, but your API spec has no ${red('204')} response defined for endpoint 'GET /endpointPath'`, 44 | ), 45 | ); 46 | }); 47 | 48 | it('passes when using .not', () => { 49 | expect(res).not.toSatisfyApiSpec(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/openapi-validator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openapi-validator", 3 | "version": "0.14.2", 4 | "description": "Common code for jest-openapi and Chai OpenAPI Response Validator", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "clean": "rimraf dist", 9 | "format": "prettier --write . --ignore-path ../../.prettierignore", 10 | "lint": "tsc --noEmit && eslint .", 11 | "lint:fix": "yarn lint --fix", 12 | "build": "tsc", 13 | "test": "echo", 14 | "test:ci": "echo", 15 | "prepack": "yarn build" 16 | }, 17 | "repository": "https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/openapi-validator", 18 | "author": "OpenApiChai ", 19 | "contributors": [ 20 | "rwalle61 ", 21 | "Jonny Spruce " 22 | ], 23 | "license": "MIT", 24 | "keywords": [ 25 | "jest", 26 | "chai", 27 | "openapi", 28 | "testing", 29 | "response", 30 | "validate" 31 | ], 32 | "bugs": { 33 | "url": "https://github.com/openapi-library/OpenAPIValidators/issues" 34 | }, 35 | "homepage": "https://github.com/openapi-library/OpenAPIValidators#openapi-validators", 36 | "files": [ 37 | "dist" 38 | ], 39 | "dependencies": { 40 | "@types/request": "^2.48.7", 41 | "@types/superagent": "^4.1.12", 42 | "axios": "^0.21.1", 43 | "combos": "^0.2.0", 44 | "fs-extra": "^9.0.0", 45 | "js-yaml": "^4.0.0", 46 | "openapi-response-validator": "^9.2.0", 47 | "openapi-schema-validator": "^9.2.0", 48 | "path-parser": "^6.1.0", 49 | "typeof": "^1.0.0" 50 | }, 51 | "devDependencies": { 52 | "@types/fs-extra": "^9.0.12", 53 | "@types/js-yaml": "^4.0.3", 54 | "@types/typeof": "^1.0.0", 55 | "openapi-types": "^9.2.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/jest-openapi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-openapi", 3 | "version": "0.14.2", 4 | "description": "Jest matchers for asserting that HTTP responses satisfy an OpenAPI spec", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "clean": "rimraf dist", 9 | "clean:openapi-validator": "cd ../openapi-validator && yarn clean", 10 | "format": "prettier --write ../../ --ignore-path ../../.prettierignore", 11 | "lint": "tsc --noEmit --project tsconfig.eslint.json && eslint .", 12 | "lint:fix": "yarn lint --fix", 13 | "build": "tsc", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:coverage": "yarn clean && yarn clean:openapi-validator && jest --coverage", 17 | "test:coverage:browse": "yarn test:coverage; open ../coverage/lcov-report/index.html", 18 | "test:ci": "yarn format && yarn lint && yarn test:coverage", 19 | "prepack": "yarn build" 20 | }, 21 | "repository": "https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/jest-openapi", 22 | "author": "rwalle61 ", 23 | "contributors": [ 24 | "Jonny Spruce " 25 | ], 26 | "license": "MIT", 27 | "keywords": [ 28 | "jest", 29 | "openapi", 30 | "testing", 31 | "response", 32 | "validate", 33 | "assertions" 34 | ], 35 | "bugs": { 36 | "url": "https://github.com/openapi-library/OpenAPIValidators/issues" 37 | }, 38 | "homepage": "https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/jest-openapi#readme", 39 | "files": [ 40 | "dist" 41 | ], 42 | "devDependencies": { 43 | "@types/jest": "^27.0.1", 44 | "@types/request-promise": "^4.1.48", 45 | "@types/supertest": "^2.0.11", 46 | "axios": "^0.21.1", 47 | "eslint": "^7.11.0", 48 | "eslint-plugin-jest": "^24.1.0", 49 | "express": "^4.17.1", 50 | "fs-extra": "^9.0.1", 51 | "jest": "^26.6.3", 52 | "request-promise": "^4.2.6", 53 | "supertest": "^6.0.0", 54 | "ts-jest": "^26.5.3", 55 | "ts-node": "^9.1.1" 56 | }, 57 | "dependencies": { 58 | "jest-matcher-utils": "^26.6.2", 59 | "openapi-validator": "^0.14.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/jest-openapi/src/index.ts: -------------------------------------------------------------------------------- 1 | import { makeApiSpec, OpenAPISpecObject } from 'openapi-validator'; 2 | import toSatisfyApiSpec from './matchers/toSatisfyApiSpec'; 3 | import toSatisfySchemaInApiSpec from './matchers/toSatisfySchemaInApiSpec'; 4 | 5 | declare global { 6 | // eslint-disable-next-line @typescript-eslint/no-namespace 7 | namespace jest { 8 | interface Matchers { 9 | /** 10 | * Check the HTTP response object satisfies a response defined in your OpenAPI spec. 11 | * [See usage example](https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/jest-openapi#in-api-tests-validate-the-status-and-body-of-http-responses-against-your-openapi-spec) 12 | */ 13 | toSatisfyApiSpec(): R; 14 | /** 15 | * Check the object satisfies a schema defined in your OpenAPI spec. 16 | * [See usage example](https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/jest-openapi#in-unit-tests-validate-objects-against-schemas-defined-in-your-openapi-spec) 17 | */ 18 | toSatisfySchemaInApiSpec(schemaName: string): R; 19 | } 20 | } 21 | } 22 | 23 | export default function (filepathOrObject: string | OpenAPISpecObject): void { 24 | const openApiSpec = makeApiSpec(filepathOrObject); 25 | 26 | const jestMatchers: jest.ExpectExtendMap = { 27 | toSatisfyApiSpec(received: unknown) { 28 | return toSatisfyApiSpec.call(this, received, openApiSpec); 29 | }, 30 | toSatisfySchemaInApiSpec(received: unknown, schemaName: string) { 31 | return toSatisfySchemaInApiSpec.call( 32 | this, 33 | received, 34 | schemaName, 35 | openApiSpec, 36 | ); 37 | }, 38 | }; 39 | 40 | const jestExpect = (global as { expect?: jest.Expect }).expect; 41 | 42 | /* istanbul ignore next */ 43 | if (jestExpect !== undefined) { 44 | jestExpect.extend(jestMatchers); 45 | } else { 46 | // eslint-disable-next-line no-console 47 | console.error( 48 | [ 49 | "Unable to find Jest's global expect.", 50 | 'Please check you have configured jest-openapi correctly.', 51 | 'See https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/jest-openapi#usage for help.', 52 | ].join('\n'), 53 | ); 54 | } 55 | /* istanbul ignore next */ 56 | } 57 | -------------------------------------------------------------------------------- /packages/jest-openapi/__test__/matchers/toSatisfyApiSpec/preferNonTemplatedPathOverTemplatedPath.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import jestOpenAPI from '../../..'; 4 | 5 | const openApiSpecsDir = path.resolve( 6 | '../../commonTestResources/exampleOpenApiFiles/valid/preferNonTemplatedPathOverTemplatedPath', 7 | ); 8 | 9 | describe('expect(res).toSatisfyApiSpec() (using an OpenAPI spec with similar templated and non-templated OpenAPI paths)', () => { 10 | [2, 3].forEach((openApiVersion) => { 11 | describe(`OpenAPI ${openApiVersion}`, () => { 12 | const openApiSpecs = [ 13 | { 14 | isNonTemplatedPathFirst: true, 15 | pathToApiSpec: path.join( 16 | openApiSpecsDir, 17 | 'nonTemplatedPathBeforeTemplatedPath', 18 | `openapi${openApiVersion}.yml`, 19 | ), 20 | }, 21 | { 22 | isNonTemplatedPathFirst: false, 23 | pathToApiSpec: path.join( 24 | openApiSpecsDir, 25 | 'nonTemplatedPathAfterTemplatedPath', 26 | `openapi${openApiVersion}.yml`, 27 | ), 28 | }, 29 | ]; 30 | 31 | openApiSpecs.forEach((spec) => { 32 | const { pathToApiSpec, isNonTemplatedPathFirst } = spec; 33 | 34 | describe(`res.req.path matches a non-templated OpenAPI path ${ 35 | isNonTemplatedPathFirst ? 'before' : 'after' 36 | } a templated OpenAPI path`, () => { 37 | const res = { 38 | status: 200, 39 | req: { 40 | method: 'GET', 41 | path: '/preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath', 42 | }, 43 | body: 'valid body (string)', 44 | }; 45 | 46 | beforeAll(() => { 47 | jestOpenAPI(pathToApiSpec); 48 | }); 49 | 50 | it('passes', () => { 51 | expect(res).toSatisfyApiSpec(); 52 | }); 53 | 54 | it('fails when using .not', () => { 55 | const assertion = () => expect(res).not.toSatisfyApiSpec(); 56 | expect(assertion).toThrow( 57 | "not to satisfy the '200' response defined for endpoint 'GET /preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath'", 58 | ); 59 | }); 60 | }); 61 | }); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/lib/assertions/satisfySchemaInApiSpec.ts: -------------------------------------------------------------------------------- 1 | import type { OpenApiSpec, Schema, ValidationError } from 'openapi-validator'; 2 | import { stringify, joinWithNewLines } from '../utils'; 3 | 4 | export default function ( 5 | chai: Chai.ChaiStatic, 6 | openApiSpec: OpenApiSpec, 7 | ): void { 8 | const { Assertion, AssertionError } = chai; 9 | 10 | Assertion.addMethod('satisfySchemaInApiSpec', function (schemaName) { 11 | const actualObject = this._obj; // eslint-disable-line no-underscore-dangle 12 | 13 | const schema = openApiSpec.getSchemaObject(schemaName); 14 | if (!schema) { 15 | // alert users they are misusing this assertion 16 | // eslint-disable-next-line @typescript-eslint/no-throw-literal 17 | throw new AssertionError( 18 | 'The argument to satisfySchemaInApiSpec must match a schema in your API spec', 19 | ); 20 | } 21 | 22 | const validationError = openApiSpec.validateObject(actualObject, schema); 23 | const pass = !validationError; 24 | this.assert( 25 | pass, 26 | pass 27 | ? '' 28 | : getExpectReceivedToSatisfySchemaInApiSpecMsg( 29 | actualObject, 30 | schemaName, 31 | schema, 32 | validationError, 33 | ), 34 | getExpectReceivedNotToSatisfySchemaInApiSpecMsg( 35 | actualObject, 36 | schemaName, 37 | schema, 38 | ), 39 | null, 40 | ); 41 | }); 42 | } 43 | 44 | function getExpectReceivedToSatisfySchemaInApiSpecMsg( 45 | received: unknown, 46 | schemaName: string, 47 | schema: Schema, 48 | validationError: ValidationError, 49 | ) { 50 | return joinWithNewLines( 51 | `expected object to satisfy the '${schemaName}' schema defined in your API spec`, 52 | `object did not satisfy it because: ${validationError}`, 53 | `object was: ${stringify(received)}`, 54 | `The '${schemaName}' schema in API spec: ${stringify(schema)}`, 55 | ); 56 | } 57 | 58 | function getExpectReceivedNotToSatisfySchemaInApiSpecMsg( 59 | received: unknown, 60 | schemaName: string, 61 | schema: Schema, 62 | ) { 63 | return joinWithNewLines( 64 | `expected object not to satisfy the '${schemaName}' schema defined in your API spec`, 65 | `object was: ${stringify(received)}`, 66 | `The '${schemaName}' schema in API spec: ${stringify(schema)}`, 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/OpenApi2Spec.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV2 } from 'openapi-types'; 2 | import type { ResponseObjectWithSchema } from './AbstractOpenApiSpec'; 3 | import { 4 | getPathnameWithoutBasePath, 5 | findOpenApiPathMatchingPossiblePathnames, 6 | } from '../utils/common.utils'; 7 | import AbstractOpenApiSpec from './AbstractOpenApiSpec'; 8 | import ValidationError, { ErrorCode } from './errors/ValidationError'; 9 | 10 | const basePathPropertyNotProvided = (spec: OpenAPIV2.Document): boolean => 11 | !spec.basePath; 12 | 13 | export default class OpenApi2Spec extends AbstractOpenApiSpec { 14 | public didUserDefineBasePath: boolean; 15 | 16 | constructor(public override spec: OpenAPIV2.Document) { 17 | super(spec); 18 | this.didUserDefineBasePath = !basePathPropertyNotProvided(spec); 19 | } 20 | 21 | /** 22 | * "If the basePath property is not provided, the API is served directly under the host 23 | * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#fixed-fields 24 | */ 25 | findOpenApiPathMatchingPathname(pathname: string): string { 26 | const { basePath } = this.spec; 27 | if (basePath && !pathname.startsWith(basePath)) { 28 | throw new ValidationError(ErrorCode.BasePathNotFound); 29 | } 30 | const pathnameWithoutBasePath = basePath 31 | ? getPathnameWithoutBasePath(basePath, pathname) 32 | : pathname; 33 | const openApiPath = findOpenApiPathMatchingPossiblePathnames( 34 | [pathnameWithoutBasePath], 35 | this.paths(), 36 | ); 37 | if (!openApiPath) { 38 | throw new ValidationError(ErrorCode.PathNotFound); 39 | } 40 | return openApiPath; 41 | } 42 | 43 | findResponseDefinition( 44 | referenceString: string, 45 | ): ResponseObjectWithSchema | undefined { 46 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 47 | const nameOfResponseDefinition = referenceString.split('#/responses/')[1]!; 48 | return this.spec.responses?.[nameOfResponseDefinition] as 49 | | ResponseObjectWithSchema 50 | | undefined; 51 | } 52 | 53 | getComponentDefinitionsProperty(): { 54 | definitions: OpenAPIV2.Document['definitions']; 55 | } { 56 | return { definitions: this.spec.definitions }; 57 | } 58 | 59 | getSchemaObjects(): OpenAPIV2.Document['definitions'] { 60 | return this.spec.definitions; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/test/assertions/satisfyApiSpec/preferNonTemplatedPathOverTemplatedPath.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import path from 'path'; 3 | 4 | import chaiResponseValidator from '../../..'; 5 | 6 | const openApiSpecsDir = path.resolve( 7 | '../../commonTestResources/exampleOpenApiFiles/valid/preferNonTemplatedPathOverTemplatedPath', 8 | ); 9 | const { expect } = chai; 10 | 11 | describe('expect(res).to.satisfyApiSpec (using an OpenAPI spec with similar templated and non-templated OpenAPI paths)', () => { 12 | [2, 3].forEach((openApiVersion) => { 13 | describe(`OpenAPI ${openApiVersion}`, () => { 14 | const openApiSpecs = [ 15 | { 16 | isNonTemplatedPathFirst: true, 17 | pathToApiSpec: path.join( 18 | openApiSpecsDir, 19 | 'nonTemplatedPathBeforeTemplatedPath', 20 | `openapi${openApiVersion}.yml`, 21 | ), 22 | }, 23 | { 24 | isNonTemplatedPathFirst: false, 25 | pathToApiSpec: path.join( 26 | openApiSpecsDir, 27 | 'nonTemplatedPathAfterTemplatedPath', 28 | `openapi${openApiVersion}.yml`, 29 | ), 30 | }, 31 | ]; 32 | 33 | openApiSpecs.forEach((spec) => { 34 | const { pathToApiSpec, isNonTemplatedPathFirst } = spec; 35 | 36 | describe(`res.req.path matches a non-templated OpenAPI path ${ 37 | isNonTemplatedPathFirst ? 'before' : 'after' 38 | } a templated OpenAPI path`, () => { 39 | const res = { 40 | status: 200, 41 | req: { 42 | method: 'GET', 43 | path: '/preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath', 44 | }, 45 | body: 'valid body (string)', 46 | }; 47 | 48 | before(() => { 49 | chai.use(chaiResponseValidator(pathToApiSpec)); 50 | }); 51 | 52 | it('passes', () => { 53 | expect(res).to.satisfyApiSpec; 54 | }); 55 | 56 | it('fails when using .not', () => { 57 | const assertion = () => expect(res).to.not.satisfyApiSpec; 58 | expect(assertion).to.throw( 59 | "not to satisfy the '200' response defined for endpoint 'GET /preferNonTemplatedPathOverTemplatedPath/nonTemplatedPath'", 60 | ); 61 | }); 62 | }); 63 | }); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chai-openapi-response-validator", 3 | "version": "0.14.2", 4 | "description": "Use Chai to assert that HTTP responses satisfy an OpenAPI spec", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "clean": "rimraf dist", 9 | "clean:openapi-validator": "cd ../openapi-validator && yarn clean", 10 | "format": "prettier --write ../../ --ignore-path ../../.prettierignore", 11 | "lint": "tsc --noEmit --project tsconfig.eslint.json && eslint .", 12 | "lint:fix": "yarn lint --fix", 13 | "build": "tsc", 14 | "test": "ts-mocha --extension ts --recursive", 15 | "test:watch": "yarn test --watch", 16 | "test:coverage": "yarn clean && yarn clean:openapi-validator && nyc yarn test && nyc report --reporter=lcov && nyc check-coverage", 17 | "test:coverage:browse": "yarn test:coverage; open ../coverage/lcov-report/index.html", 18 | "test:ci": "yarn format && yarn lint && yarn test:coverage", 19 | "prepack": "yarn build" 20 | }, 21 | "repository": "https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/chai-openapi-response-validator", 22 | "author": "OpenApiChai ", 23 | "contributors": [ 24 | "Jonny Spruce ", 25 | "rwalle61 " 26 | ], 27 | "license": "MIT", 28 | "keywords": [ 29 | "chai", 30 | "chai-plugin", 31 | "http", 32 | "response", 33 | "openapi", 34 | "validate" 35 | ], 36 | "bugs": { 37 | "url": "https://github.com/openapi-library/OpenAPIValidators/issues" 38 | }, 39 | "homepage": "https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/chai-openapi-response-validator#readme", 40 | "files": [ 41 | "dist" 42 | ], 43 | "devDependencies": { 44 | "@types/chai": "^4.2.15", 45 | "@types/mocha": "^8.2.1", 46 | "@types/request-promise": "^4.1.48", 47 | "@types/supertest": "^2.0.11", 48 | "axios": "^0.21.1", 49 | "chai": "^4.2.0", 50 | "chai-http": "^4.3.0", 51 | "eslint": "^7.11.0", 52 | "eslint-plugin-chai-friendly": "^0.6.0", 53 | "eslint-plugin-import": "^2.22.1", 54 | "eslint-plugin-mocha": "^8.0.0", 55 | "express": "^4.17.1", 56 | "fs-extra": "^9.0.1", 57 | "mocha": "^8.2.0", 58 | "nyc": "15.1.0", 59 | "request": "^2.88.2", 60 | "request-promise": "^4.2.6", 61 | "rimraf": "^3.0.2", 62 | "supertest": "^6.0.0", 63 | "ts-mocha": "^8.0.0" 64 | }, 65 | "dependencies": { 66 | "openapi-validator": "^0.14.2" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/openApiSpecFactory.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import yaml from 'js-yaml'; 3 | import OpenAPISchemaValidator from 'openapi-schema-validator'; 4 | import type { OpenAPI, OpenAPIV2, OpenAPIV3 } from 'openapi-types'; 5 | import path from 'path'; 6 | import typeOf from 'typeof'; 7 | import OpenApi2Spec from './classes/OpenApi2Spec'; 8 | import OpenApi3Spec from './classes/OpenApi3Spec'; 9 | import { stringify } from './utils/common.utils'; 10 | 11 | type AnyObject = Record; 12 | 13 | const isObject = (arg: unknown): arg is AnyObject => 14 | typeof arg === 'object' && arg !== null && !Array.isArray(arg); 15 | 16 | export default function makeApiSpec( 17 | filepathOrObject: string | OpenAPI.Document, 18 | ): OpenApi2Spec | OpenApi3Spec { 19 | const spec = loadSpec(filepathOrObject); 20 | validateSpec(spec); 21 | const validSpec = spec as OpenAPI.Document; 22 | if ('swagger' in validSpec) { 23 | return new OpenApi2Spec(validSpec); 24 | } 25 | return new OpenApi3Spec(validSpec as OpenAPIV3.Document); 26 | } 27 | 28 | function loadSpec(arg: unknown): AnyObject { 29 | try { 30 | if (typeof arg === 'string') { 31 | return loadFile(arg); 32 | } 33 | if (isObject(arg)) { 34 | return arg; 35 | } 36 | throw new Error(`Received type '${typeOf(arg)}'`); 37 | } catch (error) { 38 | throw new Error( 39 | `The provided argument must be either an absolute filepath or an object representing an OpenAPI specification.\nError details: ${ 40 | (error as Error).message 41 | }`, 42 | ); 43 | } 44 | } 45 | 46 | function loadFile(filepath: string): AnyObject { 47 | if (!path.isAbsolute(filepath)) { 48 | throw new Error(`'${filepath}' is not an absolute filepath`); 49 | } 50 | const fileData = fs.readFileSync(filepath, { encoding: 'utf8' }); 51 | try { 52 | return yaml.load(fileData) as AnyObject; 53 | } catch (error) { 54 | throw new Error(`Invalid YAML or JSON:\n${(error as Error).message}`); 55 | } 56 | } 57 | 58 | function validateSpec(obj: AnyObject): OpenAPI.Document { 59 | try { 60 | const validator = new OpenAPISchemaValidator({ 61 | version: 62 | (obj as unknown as OpenAPIV2.Document).swagger || // '2.0' 63 | (obj as unknown as OpenAPIV3.Document).openapi, // '3.X.X' 64 | }); 65 | const { errors } = validator.validate(obj as OpenAPI.Document); 66 | if (errors.length > 0) { 67 | throw new Error(stringify(errors)); 68 | } 69 | return obj as OpenAPI.Document; 70 | } catch (error) { 71 | throw new Error(`Invalid OpenAPI spec: ${(error as Error).message}`); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/utils/common.utils.ts: -------------------------------------------------------------------------------- 1 | import { Path } from 'path-parser'; 2 | import url from 'url'; 3 | import { inspect } from 'util'; 4 | import type { ActualRequest } from '../classes/AbstractResponse'; 5 | 6 | export const stringify = (obj: unknown): string => 7 | inspect(obj, { depth: null }); 8 | 9 | /** 10 | * Excludes the query because path = pathname + query 11 | */ 12 | export const getPathname = (request: ActualRequest): string => 13 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 14 | url.parse(request.path).pathname!; 15 | 16 | /** 17 | * Converts all {foo} to :foo 18 | */ 19 | const convertOpenApiPathToColonForm = (openApiPath: string): string => 20 | openApiPath.replace(/{/g, ':').replace(/}/g, ''); 21 | 22 | const doesColonPathMatchPathname = ( 23 | pathInColonForm: string, 24 | pathname: string, 25 | ): boolean => { 26 | /* 27 | * By default, OpenAPI path parameters have `style: simple; explode: false` (https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#parameter-object) 28 | * So array path parameters in the pathname of the actual request should be in the form: `/pathParams/a,b,c` 29 | * `path-parser` fails to match parameter patterns to parameters containing commas. 30 | * So we remove the commas. 31 | */ 32 | const pathWithoutCommas = pathname.replace(/,/g, ''); 33 | const pathParamsInPathname = new Path(pathInColonForm).test( 34 | pathWithoutCommas, 35 | ); // => one of: null, {}, {exampleParam: 'foo'} 36 | return Boolean(pathParamsInPathname); 37 | }; 38 | 39 | const doesOpenApiPathMatchPathname = ( 40 | openApiPath: string, 41 | pathname: string, 42 | ): boolean => { 43 | const pathInColonForm = convertOpenApiPathToColonForm(openApiPath); 44 | return doesColonPathMatchPathname(pathInColonForm, pathname); 45 | }; 46 | 47 | export const findOpenApiPathMatchingPossiblePathnames = ( 48 | possiblePathnames: string[], 49 | OAPaths: string[], 50 | ): string | undefined => { 51 | let openApiPath: string | undefined; 52 | // eslint-disable-next-line no-restricted-syntax 53 | for (const pathname of possiblePathnames) { 54 | // eslint-disable-next-line no-restricted-syntax 55 | for (const OAPath of OAPaths) { 56 | if (OAPath === pathname) { 57 | return OAPath; 58 | } 59 | if (doesOpenApiPathMatchPathname(OAPath, pathname)) { 60 | openApiPath = OAPath; 61 | } 62 | } 63 | } 64 | return openApiPath; 65 | }; 66 | 67 | export const defaultBasePath = '/'; 68 | 69 | export const getPathnameWithoutBasePath = ( 70 | basePath: string, 71 | pathname: string, 72 | ): string => 73 | basePath === defaultBasePath ? pathname : pathname.replace(basePath, ''); 74 | -------------------------------------------------------------------------------- /patches/openapi-response-validator+9.2.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/openapi-response-validator/dist/index.d.ts b/node_modules/openapi-response-validator/dist/index.d.ts 2 | index ee0c508..db5d5b3 100644 3 | --- a/node_modules/openapi-response-validator/dist/index.d.ts 4 | +++ b/node_modules/openapi-response-validator/dist/index.d.ts 5 | @@ -1,7 +1,7 @@ 6 | import { FormatDefinition, Format, ErrorObject } from 'ajv'; 7 | import { IJsonSchema, OpenAPIV2, OpenAPIV3 } from 'openapi-types'; 8 | export interface IOpenAPIResponseValidator { 9 | - validateResponse(statusCode: string, response: any): void | OpenAPIResponseValidatorValidationError; 10 | + validateResponse(statusCode: string, response: any): undefined | OpenAPIResponseValidatorValidationError; 11 | } 12 | export interface OpenAPIResponseValidatorArgs { 13 | customFormats?: { 14 | @@ -9,34 +9,48 @@ export interface OpenAPIResponseValidatorArgs { 15 | }; 16 | definitions?: { 17 | [definitionName: string]: IJsonSchema; 18 | - }; 19 | - components?: OpenAPIV3.ComponentsObject; 20 | + } | undefined; 21 | + components?: OpenAPIV3.ComponentsObject | undefined; 22 | externalSchemas?: { 23 | [index: string]: IJsonSchema; 24 | }; 25 | loggingKey?: string; 26 | responses: { 27 | - [responseCode: string]: { 28 | - schema: OpenAPIV2.Schema | OpenAPIV3.SchemaObject; 29 | - }; 30 | - }; 31 | + [responseCode: string]: 32 | + | { 33 | + schema: 34 | + | OpenAPIV2.Schema 35 | + | OpenAPIV3.SchemaObject 36 | + | OpenAPIV3_1.SchemaObject; 37 | + } 38 | + | { 39 | + content: { 40 | + [contentType: string]: { 41 | + schema: 42 | + | OpenAPIV3.SchemaObject 43 | + | OpenAPIV3_1.SchemaObject; 44 | + }; 45 | + }; 46 | + }; 47 | + } 48 | errorTransformer?(openAPIResponseValidatorValidationError: OpenAPIResponseValidatorError, ajvError: ErrorObject): any; 49 | } 50 | export interface OpenAPIResponseValidatorError { 51 | - path?: string; 52 | + path: string; 53 | errorCode: string; 54 | message: string; 55 | } 56 | export interface OpenAPIResponseValidatorValidationError { 57 | message: string; 58 | - errors?: any[]; 59 | + errors: { 60 | + path?: string; 61 | + errorCode?: string; 62 | + message: string; 63 | + }[] 64 | } 65 | export default class OpenAPIResponseValidator implements IOpenAPIResponseValidator { 66 | private errorMapper; 67 | private validators; 68 | constructor(args: OpenAPIResponseValidatorArgs); 69 | - validateResponse(statusCode: any, response: any): { 70 | - message: string; 71 | - errors: any; 72 | - }; 73 | + validateResponse(statusCode: string, response: any): undefined | OpenAPIResponseValidatorValidationError 74 | } 75 | -------------------------------------------------------------------------------- /packages/jest-openapi/src/matchers/toSatisfySchemaInApiSpec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EXPECTED_COLOR, 3 | matcherErrorMessage, 4 | matcherHint, 5 | printExpected, 6 | printWithType, 7 | RECEIVED_COLOR, 8 | } from 'jest-matcher-utils'; 9 | import type { OpenApiSpec, Schema, ValidationError } from 'openapi-validator'; 10 | import { joinWithNewLines, stringify } from '../utils'; 11 | 12 | export default function ( 13 | this: jest.MatcherContext, 14 | received: unknown, 15 | schemaName: string, 16 | openApiSpec: OpenApiSpec, 17 | ): jest.CustomMatcherResult { 18 | const matcherHintOptions = { 19 | comment: 20 | "Matches 'received' to a schema defined in your API spec, then validates 'received' against it", 21 | isNot: this.isNot, 22 | promise: this.promise, 23 | }; 24 | const hint = matcherHint( 25 | 'toSatisfySchemaInApiSpec', 26 | undefined, 27 | 'schemaName', 28 | matcherHintOptions, 29 | ); 30 | 31 | const schema = openApiSpec.getSchemaObject(schemaName); 32 | if (!schema) { 33 | // alert users they are misusing this assertion 34 | throw new Error( 35 | matcherErrorMessage( 36 | hint, 37 | `${EXPECTED_COLOR('schemaName')} must match a schema in your API spec`, 38 | printWithType('schemaName', schemaName, printExpected), 39 | ), 40 | ); 41 | } 42 | 43 | const validationError = openApiSpec.validateObject(received, schema); 44 | const pass = !validationError; 45 | 46 | const message = pass 47 | ? () => 48 | getExpectReceivedNotToSatisfySchemaInApiSpecMsg( 49 | received, 50 | schemaName, 51 | schema, 52 | hint, 53 | ) 54 | : () => 55 | getExpectReceivedToSatisfySchemaInApiSpecMsg( 56 | received, 57 | schemaName, 58 | schema, 59 | validationError, 60 | hint, 61 | ); 62 | 63 | return { 64 | pass, 65 | message, 66 | }; 67 | } 68 | 69 | function getExpectReceivedToSatisfySchemaInApiSpecMsg( 70 | received: unknown, 71 | schemaName: string, 72 | schema: Schema, 73 | validationError: ValidationError, 74 | hint: string, 75 | ): string { 76 | // prettier-ignore 77 | return joinWithNewLines( 78 | hint, 79 | `expected ${RECEIVED_COLOR('received')} to satisfy the '${schemaName}' schema defined in your API spec`, 80 | `${RECEIVED_COLOR('received')} did not satisfy it because: ${validationError}`, 81 | `${RECEIVED_COLOR('received')} was: ${RECEIVED_COLOR(stringify(received))}`, 82 | `The '${schemaName}' schema in API spec: ${EXPECTED_COLOR(stringify(schema))}`, 83 | ); 84 | } 85 | 86 | function getExpectReceivedNotToSatisfySchemaInApiSpecMsg( 87 | received: unknown, 88 | schemaName: string, 89 | schema: Schema, 90 | hint: string, 91 | ): string { 92 | // prettier-ignore 93 | return joinWithNewLines( 94 | hint, 95 | `expected ${RECEIVED_COLOR('received')} not to satisfy the '${schemaName}' schema defined in your API spec`, 96 | `${RECEIVED_COLOR('received')} was: ${RECEIVED_COLOR(stringify(received))}`, 97 | `The '${schemaName}' schema in API spec: ${EXPECTED_COLOR(stringify(schema))}`, 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/OpenApi3Spec.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV3 } from 'openapi-types'; 2 | import type { ResponseObjectWithSchema } from './AbstractOpenApiSpec'; 3 | import { 4 | defaultBasePath, 5 | findOpenApiPathMatchingPossiblePathnames, 6 | getPathnameWithoutBasePath, 7 | } from '../utils/common.utils'; 8 | import { 9 | serversPropertyNotProvidedOrIsEmptyArray, 10 | getMatchingServerUrlsAndServerBasePaths, 11 | } from '../utils/OpenApi3Spec.utils'; 12 | import AbstractOpenApiSpec from './AbstractOpenApiSpec'; 13 | import ValidationError, { ErrorCode } from './errors/ValidationError'; 14 | 15 | export default class OpenApi3Spec extends AbstractOpenApiSpec { 16 | public didUserDefineServers: boolean; 17 | 18 | constructor(protected override spec: OpenAPIV3.Document) { 19 | super(spec); 20 | this.didUserDefineServers = !serversPropertyNotProvidedOrIsEmptyArray(spec); 21 | this.ensureDefaultServer(); 22 | } 23 | 24 | /** 25 | * "If the servers property is not provided, or is an empty array, the default value would be a Server Object with a url value of '/'" 26 | * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#fixed-fields 27 | */ 28 | ensureDefaultServer(): void { 29 | if (serversPropertyNotProvidedOrIsEmptyArray(this.spec)) { 30 | this.spec.servers = [{ url: defaultBasePath }]; 31 | } 32 | } 33 | 34 | servers(): OpenAPIV3.ServerObject[] { 35 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 36 | return this.spec.servers!; 37 | } 38 | 39 | getServerUrls(): string[] { 40 | return this.servers().map((server) => server.url); 41 | } 42 | 43 | getMatchingServerUrls(pathname: string): string[] { 44 | return getMatchingServerUrlsAndServerBasePaths( 45 | this.servers(), 46 | pathname, 47 | ).map(({ concreteUrl }) => concreteUrl); 48 | } 49 | 50 | getMatchingServerBasePaths(pathname: string): string[] { 51 | return getMatchingServerUrlsAndServerBasePaths( 52 | this.servers(), 53 | pathname, 54 | ).map(({ matchingBasePath }) => matchingBasePath); 55 | } 56 | 57 | findOpenApiPathMatchingPathname(pathname: string): string { 58 | const matchingServerBasePaths = this.getMatchingServerBasePaths(pathname); 59 | if (!matchingServerBasePaths.length) { 60 | throw new ValidationError(ErrorCode.ServerNotFound); 61 | } 62 | const possiblePathnames = matchingServerBasePaths.map((basePath) => 63 | getPathnameWithoutBasePath(basePath, pathname), 64 | ); 65 | const openApiPath = findOpenApiPathMatchingPossiblePathnames( 66 | possiblePathnames, 67 | this.paths(), 68 | ); 69 | if (!openApiPath) { 70 | throw new ValidationError(ErrorCode.PathNotFound); 71 | } 72 | return openApiPath; 73 | } 74 | 75 | findResponseDefinition( 76 | referenceString: string, 77 | ): ResponseObjectWithSchema | undefined { 78 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 79 | const nameOfResponseDefinition = referenceString.split( 80 | '#/components/responses/', 81 | )[1]!; 82 | return this.spec.components?.responses?.[nameOfResponseDefinition] as 83 | | ResponseObjectWithSchema 84 | | undefined; 85 | } 86 | 87 | getComponentDefinitionsProperty(): { 88 | components: OpenAPIV3.Document['components']; 89 | } { 90 | return { components: this.spec.components }; 91 | } 92 | 93 | getSchemaObjects(): OpenAPIV3.ComponentsObject['schemas'] { 94 | return this.spec.components?.schemas; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at openapichai@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | 77 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "OpenAPIValidators", 3 | "projectOwner": "openapi-library", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "rwalle61", 15 | "name": "Richard Waller", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/18170169?v=4", 17 | "profile": "https://github.com/rwalle61", 18 | "contributions": [ 19 | "maintenance", 20 | "code", 21 | "doc", 22 | "review" 23 | ] 24 | }, 25 | { 26 | "login": "JonnySpruce", 27 | "name": "Jonny Spruce", 28 | "avatar_url": "https://avatars3.githubusercontent.com/u/30812276?v=4", 29 | "profile": "https://github.com/JonnySpruce", 30 | "contributions": [ 31 | "code", 32 | "doc", 33 | "review" 34 | ] 35 | }, 36 | { 37 | "login": "AlexDobeck", 38 | "name": "Alex Dobeck", 39 | "avatar_url": "https://avatars2.githubusercontent.com/u/10519388?v=4", 40 | "profile": "https://github.com/AlexDobeck", 41 | "contributions": [ 42 | "code", 43 | "bug" 44 | ] 45 | }, 46 | { 47 | "login": "BenGu3", 48 | "name": "Ben Guthrie", 49 | "avatar_url": "https://avatars2.githubusercontent.com/u/7105857?v=4", 50 | "profile": "https://github.com/BenGu3", 51 | "contributions": [ 52 | "code", 53 | "bug" 54 | ] 55 | }, 56 | { 57 | "login": "mvegter", 58 | "name": "Martijn Vegter", 59 | "avatar_url": "https://avatars3.githubusercontent.com/u/25134477?v=4", 60 | "profile": "https://martijnvegter.com/", 61 | "contributions": [ 62 | "code" 63 | ] 64 | }, 65 | { 66 | "login": "ludeknovy", 67 | "name": "Ludek", 68 | "avatar_url": "https://avatars1.githubusercontent.com/u/13610612?v=4", 69 | "profile": "https://github.com/ludeknovy", 70 | "contributions": [ 71 | "code", 72 | "bug" 73 | ] 74 | }, 75 | { 76 | "login": "tgiardina", 77 | "name": "Tommy Giardina", 78 | "avatar_url": "https://avatars1.githubusercontent.com/u/37459104?v=4", 79 | "profile": "https://github.com/tgiardina", 80 | "contributions": [ 81 | "code", 82 | "bug" 83 | ] 84 | }, 85 | { 86 | "login": "Xotabu4", 87 | "name": "Oleksandr Khotemskyi", 88 | "avatar_url": "https://avatars3.githubusercontent.com/u/3033972?v=4", 89 | "profile": "https://xotabu4.github.io/", 90 | "contributions": [ 91 | "doc" 92 | ] 93 | }, 94 | { 95 | "login": "amitkeinan9", 96 | "name": "Amit Keinan", 97 | "avatar_url": "https://avatars.githubusercontent.com/u/16577335?v=4", 98 | "profile": "https://github.com/amitkeinan9", 99 | "contributions": [ 100 | "code" 101 | ] 102 | }, 103 | { 104 | "login": "kristofferkarlsson93", 105 | "name": "Kristoffer Karlsson", 106 | "avatar_url": "https://avatars.githubusercontent.com/u/20490202?v=4", 107 | "profile": "http://karlssonkristoffer.com/", 108 | "contributions": [ 109 | "doc" 110 | ] 111 | }, 112 | { 113 | "login": "DetachHead", 114 | "name": "DetachHead", 115 | "avatar_url": "https://avatars.githubusercontent.com/u/57028336?v=4", 116 | "profile": "https://github.com/DetachHead", 117 | "contributions": [ 118 | "bug" 119 | ] 120 | } 121 | ], 122 | "contributorsPerLine": 7, 123 | "skipCi": true 124 | } 125 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/utils/OpenApi3Spec.utils.ts: -------------------------------------------------------------------------------- 1 | import generateCombinations from 'combos'; 2 | import type { OpenAPIV3 } from 'openapi-types'; 3 | import { defaultBasePath } from './common.utils'; 4 | 5 | type ServerVariables = OpenAPIV3.ServerObject['variables']; 6 | 7 | const unique = (array: T[]): T[] => [...new Set(array)]; 8 | 9 | export const serversPropertyNotProvidedOrIsEmptyArray = ( 10 | spec: OpenAPIV3.Document, 11 | ): boolean => !spec.servers || !spec.servers.length; 12 | 13 | const getBasePath = (url: string): string => { 14 | const basePathStartIndex = url.replace('//', ' ').indexOf('/'); 15 | return basePathStartIndex !== -1 16 | ? url.slice(basePathStartIndex) 17 | : defaultBasePath; 18 | }; 19 | 20 | const getPossibleValuesOfServerVariable = ({ 21 | default: defaultValue, 22 | enum: enumMembers, 23 | }: OpenAPIV3.ServerVariableObject): string[] => 24 | enumMembers ? unique([defaultValue].concat(enumMembers)) : [defaultValue]; 25 | 26 | const mapServerVariablesToPossibleValues = ( 27 | serverVariables: NonNullable, 28 | ): Record => 29 | Object.entries(serverVariables).reduce( 30 | (currentMap, [variableName, detailsOfPossibleValues]) => ({ 31 | ...currentMap, 32 | [variableName]: getPossibleValuesOfServerVariable( 33 | detailsOfPossibleValues, 34 | ), 35 | }), 36 | {}, 37 | ); 38 | 39 | const convertTemplateExpressionToConcreteExpression = ( 40 | templateExpression: string, 41 | mapOfVariablesToValues: Record, 42 | ) => 43 | Object.entries(mapOfVariablesToValues).reduce( 44 | (currentExpression, [variable, value]) => 45 | currentExpression.replace(`{${variable}}`, value), 46 | templateExpression, 47 | ); 48 | 49 | const getPossibleConcreteBasePaths = ( 50 | basePath: string, 51 | serverVariables: NonNullable, 52 | ): string[] => { 53 | const mapOfServerVariablesToPossibleValues = 54 | mapServerVariablesToPossibleValues(serverVariables); 55 | const combinationsOfBasePathVariableValues = generateCombinations( 56 | mapOfServerVariablesToPossibleValues, 57 | ); 58 | const possibleBasePaths = combinationsOfBasePathVariableValues.map( 59 | (mapOfVariablesToValues) => 60 | convertTemplateExpressionToConcreteExpression( 61 | basePath, 62 | mapOfVariablesToValues, 63 | ), 64 | ); 65 | return possibleBasePaths; 66 | }; 67 | 68 | const getPossibleBasePaths = ( 69 | url: string, 70 | serverVariables: ServerVariables, 71 | ): string[] => { 72 | const basePath = getBasePath(url); 73 | return serverVariables 74 | ? getPossibleConcreteBasePaths(basePath, serverVariables) 75 | : [basePath]; 76 | }; 77 | 78 | export const getMatchingServerUrlsAndServerBasePaths = ( 79 | servers: OpenAPIV3.ServerObject[], 80 | pathname: string, 81 | ): { concreteUrl: string; matchingBasePath: string }[] => { 82 | const matchesPathname = (basePath: string): boolean => 83 | pathname.startsWith(basePath); 84 | return servers 85 | .map(({ url: templatedUrl, variables }) => ({ 86 | templatedUrl, 87 | possibleBasePaths: getPossibleBasePaths(templatedUrl, variables), 88 | })) 89 | .filter(({ possibleBasePaths }) => possibleBasePaths.some(matchesPathname)) 90 | .map(({ templatedUrl, possibleBasePaths }) => { 91 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 92 | const matchingBasePath = possibleBasePaths.find(matchesPathname)!; 93 | return { 94 | concreteUrl: templatedUrl.replace( 95 | getBasePath(templatedUrl), 96 | matchingBasePath, 97 | ), 98 | matchingBasePath, 99 | }; 100 | }); 101 | }; 102 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thanks for being willing to contribute! 4 | 5 | We appreciate [bug reports](https://github.com/openapi-library/OpenAPIValidators/issues/new?assignees=&labels=bug&template=bug_report.md&title=), [feature requests](https://github.com/openapi-library/OpenAPIValidators/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=), doc updates, fixing [open issues](https://github.com/openapi-library/OpenAPIValidators/issues), and other contributions. Please follow our [Code Of Conduct](https://github.com/openapi-library/OpenAPIValidators/blob/master/CODE_OF_CONDUCT.md) and the guide below. 6 | 7 | - [Bug Reports](#bug-reports) 8 | - [Feature Requests](#feature-requests) 9 | - [Pull Requests](#pull-requests) 10 | 11 | ## Bug Reports 12 | 13 | A bug is a **recreatable** problem that is caused by the code in the repository. 14 | 15 | Before submitting bug reports: 16 | 17 | 1. **Check if the [issue has already been reported](https://github.com/openapi-library/OpenAPIValidators/issues)** 18 | 2. **Recreate the bug** — clone `master` and use our bug recreation template for [`chai-openapi-response-validator`](https://github.com/openapi-library/OpenAPIValidators/blob/master/packages/chai-openapi-response-validator/test/bugRecreationTemplate.test.ts) or [`jest-openapi`](https://github.com/openapi-library/OpenAPIValidators/blob/master/packages/jest-openapi/__test__/bugRecreationTemplate.test.ts). 19 | 20 | ## Feature Requests 21 | 22 | Feature requests are welcome. Provide clear reasons for why, how, and when you'd use the new feature. 23 | 24 | Consider whether your idea fits with the scope and aims of the project. It's up to _you_ to convince the project's developers of the merits of this feature. 25 | 26 | ## Pull Requests 27 | 28 | - Good PRs are a fantastic help! 29 | - PRs must pass `yarn test:ci` 30 | - New code should be consistent with existing code. 31 | - PRs should remain focused in scope and not contain unrelated commits or code changes. 32 | - Please ask before embarking on any significant pull request, to ensure we will want to merge into the project. 33 | - If this is your first pull request for this project, please add yourself as a contributor! Just comment on your pull request: `@all-contributors please add for ` ([see example](https://allcontributors.org/docs/en/bot/usage#all-contributors-add)) and the All Contributors bot will raise a PR adding you to the [Contributors section of our main README](https://github.com/openapi-library/OpenAPIValidators#contributors). 34 | 35 | Follow this process if you'd like to work on this project: 36 | 37 | ### 1. [Fork](http://help.github.com/fork-a-repo/) the project, clone your fork, and configure the remotes 38 | 39 | ```bash 40 | # Clone your fork of the repo into the current directory 41 | git clone https://github.com// 42 | 43 | # Navigate to the newly cloned directory 44 | cd 45 | 46 | # Assign the original repo to a remote called "upstream" 47 | git remote add upstream https://github.com// 48 | ``` 49 | 50 | ### 2. If you cloned a while ago, get the latest changes from upstream 51 | 52 | ```bash 53 | git checkout 54 | git pull upstream 55 | ``` 56 | 57 | ### 3. Create a new topic branch (off the main project development branch) to contain your feature, change, or fix 58 | 59 | ```bash 60 | git checkout -b 61 | ``` 62 | 63 | ### 4. Test that your code works 64 | 65 | To test changes to a particular package, you can run these from within the `package/` dir (e.g. `package/jest-openapi`): 66 | 67 | ```bash 68 | # run all tests 69 | yarn test 70 | 71 | # run all tests, with coverage check 72 | yarn test:coverage 73 | 74 | # run all tests, with coverage check, and opens the coverage report in your browser 75 | yarn test:coverage:browse 76 | 77 | # run eslint check 78 | yarn lint 79 | 80 | # [MUST] run all the above checks 81 | yarn test:ci 82 | ``` 83 | 84 | To test both packages, run the above from the root dir. 85 | 86 | ### 5. Commit your changes in logical chunks 87 | 88 | - Use Git's [interactive rebase](https://help.github.com/articles/interactive-rebase) feature to tidy up your commits before making them public. 89 | - We use [Husky](https://github.com/typicode/husky) to run code-quality checks on every commit. This informs you early on if your code is not ready to be saved in Git history. If a commit fails a check, fix the problem then commit again. 90 | 91 | ### 6. Locally merge (or rebase) the upstream development branch into your topic branch 92 | 93 | ```bash 94 | git pull [--rebase] upstream 95 | ``` 96 | 97 | ### 7. Push your topic branch up to your fork 98 | 99 | ```bash 100 | git push origin 101 | ``` 102 | 103 | ### 8. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) with a clear title and description. Link it to the relevant issue 104 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/test/assertions/satisfyApiSpec/basePathDefinedDifferently.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import path from 'path'; 3 | 4 | import { joinWithNewLines } from '../../../../../commonTestResources/utils'; 5 | import chaiResponseValidator from '../../..'; 6 | 7 | const dirContainingApiSpec = path.resolve( 8 | '../../commonTestResources/exampleOpenApiFiles/valid/basePathDefinedDifferently', 9 | ); 10 | const { expect, AssertionError } = chai; 11 | 12 | describe('Using OpenAPI 2 specs that define basePath differently', () => { 13 | describe('spec has no basePath property', () => { 14 | before(() => { 15 | const pathToApiSpec = path.join( 16 | dirContainingApiSpec, 17 | 'noBasePathProperty.yml', 18 | ); 19 | chai.use(chaiResponseValidator(pathToApiSpec)); 20 | }); 21 | 22 | describe('res.req.path matches an endpoint path', () => { 23 | const res = { 24 | status: 200, 25 | req: { 26 | method: 'GET', 27 | path: '/endpointPath', 28 | }, 29 | body: 'valid body (string)', 30 | }; 31 | 32 | it('passes', () => { 33 | expect(res).to.satisfyApiSpec; 34 | }); 35 | 36 | it('fails when using .not', () => { 37 | const assertion = () => expect(res).to.not.satisfyApiSpec; 38 | expect(assertion).to.throw(AssertionError, ''); 39 | }); 40 | }); 41 | 42 | describe('res.req.path matches no endpoint paths', () => { 43 | const res = { 44 | status: 200, 45 | req: { 46 | method: 'GET', 47 | path: '/nonExistentEndpointPath', 48 | }, 49 | body: 'valid body (string)', 50 | }; 51 | 52 | it('fails', () => { 53 | const assertion = () => expect(res).to.satisfyApiSpec; 54 | expect(assertion).to.throw( 55 | new RegExp( 56 | `${joinWithNewLines( 57 | "expected res to satisfy a '200' response defined for endpoint 'GET /nonExistentEndpointPath' in your API spec", 58 | "res had request path '/nonExistentEndpointPath', but your API spec has no matching path", 59 | 'Paths found in API spec: /endpointPath', 60 | )}$`, 61 | ), 62 | ); 63 | }); 64 | 65 | it('passes when using .not', () => { 66 | expect(res).to.not.satisfyApiSpec; 67 | }); 68 | }); 69 | }); 70 | 71 | describe('spec has basePath property', () => { 72 | before(() => { 73 | const pathToApiSpec = path.join( 74 | dirContainingApiSpec, 75 | 'basePathProperty.yml', 76 | ); 77 | chai.use(chaiResponseValidator(pathToApiSpec)); 78 | }); 79 | 80 | describe('res.req.path matches the basePath and an endpoint path', () => { 81 | const res = { 82 | status: 200, 83 | req: { 84 | method: 'GET', 85 | path: '/basePath/endpointPath', 86 | }, 87 | body: 'valid body (string)', 88 | }; 89 | 90 | it('passes', () => { 91 | expect(res).to.satisfyApiSpec; 92 | }); 93 | 94 | it('fails when using .not', () => { 95 | const assertion = () => expect(res).to.not.satisfyApiSpec; 96 | expect(assertion).to.throw(AssertionError, ''); 97 | }); 98 | }); 99 | 100 | describe('res.req.path does not match the basePath', () => { 101 | const res = { 102 | status: 200, 103 | req: { 104 | method: 'GET', 105 | path: '/wrongBasePath', 106 | }, 107 | body: 'valid body (string)', 108 | }; 109 | 110 | it('fails', () => { 111 | const assertion = () => expect(res).to.satisfyApiSpec; 112 | expect(assertion).to.throw( 113 | joinWithNewLines( 114 | "expected res to satisfy a '200' response defined for endpoint 'GET /wrongBasePath' in your API spec", 115 | "res had request path '/wrongBasePath', but your API spec has basePath '/basePath'", 116 | ), 117 | ); 118 | }); 119 | 120 | it('passes when using .not', () => { 121 | expect(res).to.not.satisfyApiSpec; 122 | }); 123 | }); 124 | 125 | describe('res.req.path matches the basePath but no endpoint paths', () => { 126 | const res = { 127 | status: 200, 128 | req: { 129 | method: 'GET', 130 | path: '/basePath/nonExistentEndpointPath', 131 | }, 132 | body: 'valid body (string)', 133 | }; 134 | 135 | it('fails', () => { 136 | const assertion = () => expect(res).to.satisfyApiSpec; 137 | expect(assertion).to.throw( 138 | joinWithNewLines( 139 | "expected res to satisfy a '200' response defined for endpoint 'GET /basePath/nonExistentEndpointPath' in your API spec", 140 | "res had request path '/basePath/nonExistentEndpointPath', but your API spec has no matching path", 141 | 'Paths found in API spec: /endpointPath', 142 | "'/basePath/nonExistentEndpointPath' matches basePath `/basePath` but no combinations", 143 | ), 144 | ); 145 | }); 146 | 147 | it('passes when using .not', () => { 148 | expect(res).to.not.satisfyApiSpec; 149 | }); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /packages/jest-openapi/__test__/setup.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs-extra'; 3 | 4 | import jestOpenAPI from '..'; 5 | 6 | const invalidArgErrorMessage = 7 | 'The provided argument must be either an absolute filepath or an object representing an OpenAPI specification.\nError details: '; 8 | 9 | describe('jestOpenAPI(filepathOrObject)', () => { 10 | describe('number', () => { 11 | it('throws an error', () => { 12 | const func = () => jestOpenAPI(123 as never); 13 | expect(func).toThrow(`${invalidArgErrorMessage}Received type 'number'`); 14 | }); 15 | }); 16 | 17 | describe('array', () => { 18 | it('throws an error', () => { 19 | const func = () => jestOpenAPI([] as never); 20 | expect(func).toThrow(`${invalidArgErrorMessage}Received type 'array'`); 21 | }); 22 | }); 23 | 24 | describe('object that is not an OpenAPI spec', () => { 25 | it('throws an error', () => { 26 | const func = () => jestOpenAPI({} as never); 27 | expect(func).toThrow('Invalid OpenAPI spec: ['); 28 | }); 29 | }); 30 | 31 | describe('object that is an incomplete OpenAPI spec', () => { 32 | it('throws an error', () => { 33 | const func = () => jestOpenAPI({ openapi: '3.0.0' } as never); 34 | expect(func).toThrow('Invalid OpenAPI spec: ['); 35 | }); 36 | }); 37 | 38 | describe('object representing a valid OpenAPI spec', () => { 39 | it("successfully extends jest's `expect`", () => { 40 | const pathToApiSpec = path.resolve( 41 | '../../commonTestResources/exampleOpenApiFiles/valid/openapi3.json', 42 | ); 43 | const apiSpec = fs.readJSONSync(pathToApiSpec); 44 | expect(() => jestOpenAPI(apiSpec)).not.toThrow(); 45 | }); 46 | }); 47 | 48 | describe('non-absolute path', () => { 49 | it('throws an error', () => { 50 | const func = () => jestOpenAPI('./'); 51 | expect(func).toThrow( 52 | `${invalidArgErrorMessage}'./' is not an absolute filepath`, 53 | ); 54 | }); 55 | }); 56 | 57 | describe('absolute path to a non-existent file', () => { 58 | it('throws an error', () => { 59 | const func = () => jestOpenAPI('/non-existent-file.yml'); 60 | expect(func).toThrow( 61 | `${invalidArgErrorMessage}ENOENT: no such file or directory, open '/non-existent-file.yml'`, 62 | ); 63 | }); 64 | }); 65 | 66 | describe('absolute path to a file that is neither YAML nor JSON', () => { 67 | it('throws an error', () => { 68 | const pathToApiSpec = path.resolve( 69 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/neitherYamlNorJson.js', 70 | ); 71 | const func = () => jestOpenAPI(pathToApiSpec); 72 | expect(func).toThrow(`${invalidArgErrorMessage}Invalid YAML or JSON:\n`); 73 | }); 74 | }); 75 | 76 | describe('absolute path to an invalid OpenAPI file', () => { 77 | describe('YAML file that is empty', () => { 78 | it('throws an error', () => { 79 | const pathToApiSpec = path.resolve( 80 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/emptyYaml.yml', 81 | ); 82 | const func = () => jestOpenAPI(pathToApiSpec); 83 | expect(func).toThrow( 84 | "Invalid OpenAPI spec: Cannot read property 'swagger' of undefined", 85 | ); 86 | }); 87 | }); 88 | describe('YAML file that is invalid YAML', () => { 89 | it('throws an error', () => { 90 | const pathToApiSpec = path.resolve( 91 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/invalidYamlFormat.yml', 92 | ); 93 | const func = () => jestOpenAPI(pathToApiSpec); 94 | expect(func).toThrow( 95 | `${invalidArgErrorMessage}Invalid YAML or JSON:\nduplicated mapping key`, 96 | ); 97 | }); 98 | }); 99 | describe('JSON file that is invalid JSON', () => { 100 | it('throws an error', () => { 101 | const pathToApiSpec = path.resolve( 102 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/invalidJsonFormat.json', 103 | ); 104 | const func = () => jestOpenAPI(pathToApiSpec); 105 | expect(func).toThrow( 106 | `${invalidArgErrorMessage}Invalid YAML or JSON:\nduplicated mapping key`, 107 | ); 108 | }); 109 | }); 110 | describe('YAML file that is invalid OpenAPI 3', () => { 111 | it('throws an error', () => { 112 | const pathToApiSpec = path.resolve( 113 | '../../commonTestResources/exampleOpenApiFiles/invalid/openApi/openApi3.yml', 114 | ); 115 | const func = () => jestOpenAPI(pathToApiSpec); 116 | expect(func).toThrow('Invalid OpenAPI spec:'); 117 | }); 118 | }); 119 | describe('JSON file that is invalid OpenAPI 2', () => { 120 | it('throws an error', () => { 121 | const pathToApiSpec = path.resolve( 122 | '../../commonTestResources/exampleOpenApiFiles/invalid/openApi/openApi2.json', 123 | ); 124 | const func = () => jestOpenAPI(pathToApiSpec); 125 | expect(func).toThrow('Invalid OpenAPI spec:'); 126 | }); 127 | }); 128 | }); 129 | 130 | describe('absolute path to a valid OpenAPI YAML file', () => { 131 | it("successfully extends jest's `expect`", () => { 132 | const pathToApiSpec = path.resolve( 133 | '../../commonTestResources/exampleOpenApiFiles/valid/openapi3.yml', 134 | ); 135 | expect(() => jestOpenAPI(pathToApiSpec)).not.toThrow(); 136 | }); 137 | }); 138 | 139 | describe('absolute path to a valid OpenAPI JSON file', () => { 140 | it("successfully extends jest's `expect`", () => { 141 | const pathToApiSpec = path.resolve( 142 | '../../commonTestResources/exampleOpenApiFiles/valid/openapi3.json', 143 | ); 144 | expect(() => jestOpenAPI(pathToApiSpec)).not.toThrow(); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /packages/jest-openapi/__test__/matchers/toSatisfyApiSpec/basePathDefinedDifferently.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { 3 | RECEIVED_COLOR as red, 4 | EXPECTED_COLOR as green, 5 | matcherHint, 6 | } from 'jest-matcher-utils'; 7 | 8 | import { joinWithNewLines } from '../../../../../commonTestResources/utils'; 9 | import jestOpenAPI from '../../..'; 10 | 11 | const expectReceivedToSatisfyApiSpec = matcherHint( 12 | 'toSatisfyApiSpec', 13 | undefined, 14 | '', 15 | { 16 | comment: 17 | "Matches 'received' to a response defined in your API spec, then validates 'received' against it", 18 | isNot: false, 19 | }, 20 | ); 21 | 22 | const startOfAssertionErrorMessage = 'expect'; 23 | 24 | const dirContainingApiSpec = path.resolve( 25 | '../../commonTestResources/exampleOpenApiFiles/valid/basePathDefinedDifferently', 26 | ); 27 | describe('Using OpenAPI 2 specs that define basePath differently', () => { 28 | describe('spec has no basePath property', () => { 29 | beforeAll(() => { 30 | const pathToApiSpec = path.join( 31 | dirContainingApiSpec, 32 | 'noBasePathProperty.yml', 33 | ); 34 | jestOpenAPI(pathToApiSpec); 35 | }); 36 | 37 | describe('res.req.path matches an endpoint path', () => { 38 | const res = { 39 | status: 200, 40 | req: { 41 | method: 'GET', 42 | path: '/endpointPath', 43 | }, 44 | body: 'valid body (string)', 45 | }; 46 | 47 | it('passes', () => { 48 | expect(res).toSatisfyApiSpec(); 49 | }); 50 | 51 | it('fails when using .not', () => { 52 | const assertion = () => expect(res).not.toSatisfyApiSpec(); 53 | expect(assertion).toThrow(startOfAssertionErrorMessage); 54 | }); 55 | }); 56 | 57 | describe('res.req.path matches no endpoint paths', () => { 58 | const res = { 59 | status: 200, 60 | req: { 61 | method: 'GET', 62 | path: '/nonExistentEndpointPath', 63 | }, 64 | body: 'valid body (string)', 65 | }; 66 | 67 | it('fails', () => { 68 | const assertion = () => expect(res).toSatisfyApiSpec(); 69 | expect(assertion).toThrow( 70 | // prettier-ignore 71 | new Error( 72 | joinWithNewLines( 73 | expectReceivedToSatisfyApiSpec, 74 | `expected ${red('received')} to satisfy a '200' response defined for endpoint 'GET /nonExistentEndpointPath' in your API spec`, 75 | `${red('received')} had request path ${red('/nonExistentEndpointPath')}, but your API spec has no matching path`, 76 | `Paths found in API spec: ${green('/endpointPath')}`, 77 | ), 78 | ), 79 | ); 80 | }); 81 | 82 | it('passes when using .not', () => { 83 | expect(res).not.toSatisfyApiSpec(); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('spec has basePath property', () => { 89 | beforeAll(() => { 90 | const pathToApiSpec = path.join( 91 | dirContainingApiSpec, 92 | 'basePathProperty.yml', 93 | ); 94 | jestOpenAPI(pathToApiSpec); 95 | }); 96 | 97 | describe('res.req.path matches the basePath and an endpoint path', () => { 98 | const res = { 99 | status: 200, 100 | req: { 101 | method: 'GET', 102 | path: '/basePath/endpointPath', 103 | }, 104 | body: 'valid body (string)', 105 | }; 106 | 107 | it('passes', () => { 108 | expect(res).toSatisfyApiSpec(); 109 | }); 110 | 111 | it('fails when using .not', () => { 112 | const assertion = () => expect(res).not.toSatisfyApiSpec(); 113 | expect(assertion).toThrow(startOfAssertionErrorMessage); 114 | }); 115 | }); 116 | 117 | describe('res.req.path does not match the basePath', () => { 118 | const res = { 119 | status: 200, 120 | req: { 121 | method: 'GET', 122 | path: '/wrongBasePath', 123 | }, 124 | body: 'valid body (string)', 125 | }; 126 | 127 | it('fails', () => { 128 | const assertion = () => expect(res).toSatisfyApiSpec(); 129 | expect(assertion).toThrow( 130 | // prettier-ignore 131 | joinWithNewLines( 132 | `expected ${red('received')} to satisfy a '200' response defined for endpoint 'GET /wrongBasePath' in your API spec`, 133 | `${red('received')} had request path ${red('/wrongBasePath')}, but your API spec has basePath ${green('/basePath')}`, 134 | ), 135 | ); 136 | }); 137 | 138 | it('passes when using .not', () => { 139 | expect(res).not.toSatisfyApiSpec(); 140 | }); 141 | }); 142 | 143 | describe('res.req.path matches the basePath but no endpoint paths', () => { 144 | const res = { 145 | status: 200, 146 | req: { 147 | method: 'GET', 148 | path: '/basePath/nonExistentEndpointPath', 149 | }, 150 | body: 'valid body (string)', 151 | }; 152 | 153 | it('fails', () => { 154 | const assertion = () => expect(res).toSatisfyApiSpec(); 155 | expect(assertion).toThrow( 156 | // prettier-ignore 157 | joinWithNewLines( 158 | `expected ${red('received')} to satisfy a '200' response defined for endpoint 'GET /basePath/nonExistentEndpointPath' in your API spec`, 159 | `${red('received')} had request path ${red('/basePath/nonExistentEndpointPath')}, but your API spec has no matching path`, 160 | `Paths found in API spec: ${green('/endpointPath')}`, 161 | "'/basePath/nonExistentEndpointPath' matches basePath `/basePath` but no combinations", 162 | ), 163 | ); 164 | }); 165 | 166 | it('passes when using .not', () => { 167 | expect(res).not.toSatisfyApiSpec(); 168 | }); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/test/setup.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | 5 | import chaiResponseValidator from '..'; 6 | 7 | const { expect } = chai; 8 | const invalidArgErrorMessage = 9 | 'The provided argument must be either an absolute filepath or an object representing an OpenAPI specification.\nError details: '; 10 | 11 | describe('chaiResponseValidator(filepathOrObject)', () => { 12 | describe('number', () => { 13 | it('throws an error', () => { 14 | const func = () => chaiResponseValidator(123 as never); 15 | expect(func).to.throw(`${invalidArgErrorMessage}Received type 'number'`); 16 | }); 17 | }); 18 | 19 | describe('array', () => { 20 | it('throws an error', () => { 21 | const func = () => chaiResponseValidator([] as never); 22 | expect(func).to.throw(`${invalidArgErrorMessage}Received type 'array'`); 23 | }); 24 | }); 25 | 26 | describe('object that is not an OpenAPI spec', () => { 27 | it('throws an error', () => { 28 | const func = () => chaiResponseValidator({} as never); 29 | expect(func).to.throw('Invalid OpenAPI spec: ['); 30 | }); 31 | }); 32 | 33 | describe('object that is an incomplete OpenAPI spec', () => { 34 | it('throws an error', () => { 35 | const func = () => chaiResponseValidator({ openapi: '3.0.0' } as never); 36 | expect(func).to.throw('Invalid OpenAPI spec: ['); 37 | }); 38 | }); 39 | 40 | describe('object representing a valid OpenAPI spec', () => { 41 | it('returns a function', () => { 42 | const pathToApiSpec = path.resolve( 43 | '../../commonTestResources/exampleOpenApiFiles/valid/openapi3.json', 44 | ); 45 | const apiSpec = fs.readJSONSync(pathToApiSpec); 46 | expect(chaiResponseValidator(apiSpec)).to.be.a('function'); 47 | }); 48 | }); 49 | 50 | describe('non-absolute path', () => { 51 | it('throws an error', () => { 52 | const func = () => chaiResponseValidator('./'); 53 | expect(func).to.throw( 54 | `${invalidArgErrorMessage}'./' is not an absolute filepath`, 55 | ); 56 | }); 57 | }); 58 | 59 | describe('absolute path to a non-existent file', () => { 60 | it('throws an error', () => { 61 | const func = () => chaiResponseValidator('/non-existent-file.yml'); 62 | expect(func).to.throw( 63 | `${invalidArgErrorMessage}ENOENT: no such file or directory, open '/non-existent-file.yml'`, 64 | ); 65 | }); 66 | }); 67 | 68 | describe('absolute path to a file that is neither YAML nor JSON', () => { 69 | it('throws an error', () => { 70 | const pathToApiSpec = path.resolve( 71 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/neitherYamlNorJson.js', 72 | ); 73 | const func = () => chaiResponseValidator(pathToApiSpec); 74 | expect(func).to.throw(`${invalidArgErrorMessage}Invalid YAML or JSON:\n`); 75 | }); 76 | }); 77 | 78 | describe('absolute path to an invalid OpenAPI file', () => { 79 | describe('YAML file that is empty', () => { 80 | it('throws an error', () => { 81 | const pathToApiSpec = path.resolve( 82 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/emptyYaml.yml', 83 | ); 84 | const func = () => chaiResponseValidator(pathToApiSpec); 85 | expect(func).to.throw( 86 | "Invalid OpenAPI spec: Cannot read property 'swagger' of undefined", 87 | ); 88 | }); 89 | }); 90 | describe('YAML file that is invalid YAML', () => { 91 | it('throws an error', () => { 92 | const pathToApiSpec = path.resolve( 93 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/invalidYamlFormat.yml', 94 | ); 95 | const func = () => chaiResponseValidator(pathToApiSpec); 96 | expect(func).to.throw( 97 | `${invalidArgErrorMessage}Invalid YAML or JSON:\nduplicated mapping key`, 98 | ); 99 | }); 100 | }); 101 | describe('JSON file that is invalid JSON', () => { 102 | it('throws an error', () => { 103 | const pathToApiSpec = path.resolve( 104 | '../../commonTestResources/exampleOpenApiFiles/invalid/fileFormat/invalidJsonFormat.json', 105 | ); 106 | const func = () => chaiResponseValidator(pathToApiSpec); 107 | expect(func).to.throw( 108 | `${invalidArgErrorMessage}Invalid YAML or JSON:\nduplicated mapping key`, 109 | ); 110 | }); 111 | }); 112 | describe('YAML file that is invalid OpenAPI 3', () => { 113 | it('throws an error', () => { 114 | const pathToApiSpec = path.resolve( 115 | '../../commonTestResources/exampleOpenApiFiles/invalid/openApi/openApi3.yml', 116 | ); 117 | const func = () => chaiResponseValidator(pathToApiSpec); 118 | expect(func).to.throw('Invalid OpenAPI spec:'); 119 | }); 120 | }); 121 | describe('JSON file that is invalid OpenAPI 2', () => { 122 | it('throws an error', () => { 123 | const pathToApiSpec = path.resolve( 124 | '../../commonTestResources/exampleOpenApiFiles/invalid/openApi/openApi2.json', 125 | ); 126 | const func = () => chaiResponseValidator(pathToApiSpec); 127 | expect(func).to.throw('Invalid OpenAPI spec:'); 128 | }); 129 | }); 130 | }); 131 | 132 | describe('absolute path to a valid OpenAPI file', () => { 133 | describe('YAML', () => { 134 | it('returns a function', () => { 135 | const pathToApiSpec = path.resolve( 136 | '../../commonTestResources/exampleOpenApiFiles/valid/openapi3.yml', 137 | ); 138 | expect(chaiResponseValidator(pathToApiSpec)).to.be.a('function'); 139 | }); 140 | }); 141 | describe('JSON', () => { 142 | it('returns a function', () => { 143 | const pathToApiSpec = path.resolve( 144 | '../../commonTestResources/exampleOpenApiFiles/valid/openapi3.json', 145 | ); 146 | expect(chaiResponseValidator(pathToApiSpec)).to.be.a('function'); 147 | }); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /packages/jest-openapi/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 6 | 7 | /* Basic Options */ 8 | // "incremental": true, /* Enable incremental compilation */ 9 | // "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 10 | // "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 11 | // "lib": [], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "dist" /* Redirect output structure to the directory. */ 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | // "strict": true, /* Enable all strict type-checking options. */ 31 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | // "strictNullChecks": true, /* Enable strict null checks. */ 33 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | // "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | // "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | // "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/openapi-validator/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["lib"], 4 | "compilerOptions": { 5 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 6 | 7 | /* Basic Options */ 8 | // "incremental": true, /* Enable incremental compilation */ 9 | // "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 10 | // "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 11 | // "lib": [], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "dist" /* Redirect output structure to the directory. */ 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | // "strict": true, /* Enable all strict type-checking options. */ 31 | // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | // "strictNullChecks": true, /* Enable strict null checks. */ 33 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | // "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | // "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | // "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["lib"], 4 | "compilerOptions": { 5 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 6 | 7 | /* Basic Options */ 8 | // "incremental": true, /* Enable incremental compilation */ 9 | // "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 10 | // "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 11 | // "lib": [], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "dist" /* Redirect output structure to the directory. */ 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | // "strict": true, /* Enable all strict type-checking options. */ 31 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | // "strictNullChecks": true, /* Enable strict null checks. */ 33 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | // "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | // "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | // "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/lib/assertions/satisfyApiSpec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActualResponse, 3 | ErrorCode, 4 | makeResponse, 5 | OpenApi2Spec, 6 | OpenApi3Spec, 7 | OpenApiSpec, 8 | ValidationError, 9 | } from 'openapi-validator'; 10 | import { joinWithNewLines, stringify } from '../utils'; 11 | 12 | export default function ( 13 | chai: Chai.ChaiStatic, 14 | openApiSpec: OpenApiSpec, 15 | ): void { 16 | const { Assertion } = chai; 17 | 18 | Assertion.addProperty('satisfyApiSpec', function () { 19 | const actualResponse = makeResponse(this._obj); // eslint-disable-line no-underscore-dangle 20 | 21 | const validationError = openApiSpec.validateResponse(actualResponse); 22 | const pass = !validationError; 23 | this.assert( 24 | pass, 25 | pass 26 | ? '' 27 | : getExpectedResToSatisfyApiSpecMsg( 28 | actualResponse, 29 | openApiSpec, 30 | validationError, 31 | ), 32 | pass 33 | ? getExpectedResNotToSatisfyApiSpecMsg(actualResponse, openApiSpec) 34 | : '', 35 | null, 36 | ); 37 | }); 38 | } 39 | 40 | function getExpectedResToSatisfyApiSpecMsg( 41 | actualResponse: ActualResponse, 42 | openApiSpec: OpenApiSpec, 43 | validationError: ValidationError, 44 | ): string { 45 | const hint = 'expected res to satisfy API spec'; 46 | 47 | const { status, req } = actualResponse; 48 | const { method, path: requestPath } = req; 49 | const unmatchedEndpoint = `${method} ${requestPath}`; 50 | 51 | if (validationError.code === ErrorCode.ServerNotFound) { 52 | return joinWithNewLines( 53 | hint, 54 | `expected res to satisfy a '${status}' response defined for endpoint '${unmatchedEndpoint}' in your API spec`, 55 | `res had request path '${requestPath}', but your API spec has no matching servers`, 56 | `Servers found in API spec: ${(openApiSpec as OpenApi3Spec) 57 | .getServerUrls() 58 | .join(', ')}`, 59 | ); 60 | } 61 | 62 | if (validationError.code === ErrorCode.BasePathNotFound) { 63 | return joinWithNewLines( 64 | hint, 65 | `expected res to satisfy a '${status}' response defined for endpoint '${unmatchedEndpoint}' in your API spec`, 66 | `res had request path '${requestPath}', but your API spec has basePath '${ 67 | (openApiSpec as OpenApi2Spec).spec.basePath 68 | }'`, 69 | ); 70 | } 71 | 72 | if (validationError.code === ErrorCode.PathNotFound) { 73 | const pathNotFoundErrorMessage = joinWithNewLines( 74 | hint, 75 | `expected res to satisfy a '${status}' response defined for endpoint '${unmatchedEndpoint}' in your API spec`, 76 | `res had request path '${requestPath}', but your API spec has no matching path`, 77 | `Paths found in API spec: ${openApiSpec.paths().join(', ')}`, 78 | ); 79 | 80 | if ( 81 | 'didUserDefineBasePath' in openApiSpec && 82 | openApiSpec.didUserDefineBasePath 83 | ) { 84 | return joinWithNewLines( 85 | pathNotFoundErrorMessage, 86 | `'${requestPath}' matches basePath \`${openApiSpec.spec.basePath}\` but no combinations`, 87 | ); 88 | } 89 | 90 | if ( 91 | 'didUserDefineServers' in openApiSpec && 92 | openApiSpec.didUserDefineServers 93 | ) { 94 | return joinWithNewLines( 95 | pathNotFoundErrorMessage, 96 | `'${requestPath}' matches servers ${stringify( 97 | openApiSpec.getMatchingServerUrls(requestPath), 98 | )} but no combinations`, 99 | ); 100 | } 101 | return pathNotFoundErrorMessage; 102 | } 103 | 104 | const path = openApiSpec.findOpenApiPathMatchingRequest(req); 105 | const endpoint = `${method} ${path}`; 106 | 107 | if (validationError.code === ErrorCode.MethodNotFound) { 108 | const expectedPathItem = openApiSpec.findExpectedPathItem(req); 109 | const expectedRequestOperations = Object.keys(expectedPathItem) 110 | .map((operation) => operation.toUpperCase()) 111 | .join(', '); 112 | return joinWithNewLines( 113 | hint, 114 | `expected res to satisfy a '${status}' response defined for endpoint '${endpoint}' in your API spec`, 115 | `res had request method '${method}', but your API spec has no '${method}' operation defined for path '${path}'`, 116 | `Request operations found for path '${path}' in API spec: ${expectedRequestOperations}`, 117 | ); 118 | } 119 | 120 | if (validationError.code === ErrorCode.StatusNotFound) { 121 | const expectedResponseOperation = 122 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 123 | openApiSpec.findExpectedResponseOperation(req)!; 124 | const expectedResponseStatuses = Object.keys( 125 | expectedResponseOperation.responses, 126 | ).join(', '); 127 | return joinWithNewLines( 128 | hint, 129 | `expected res to satisfy a '${status}' response defined for endpoint '${endpoint}' in your API spec`, 130 | `res had status '${status}', but your API spec has no '${status}' response defined for endpoint '${endpoint}'`, 131 | `Response statuses found for endpoint '${endpoint}' in API spec: ${expectedResponseStatuses}`, 132 | ); 133 | } 134 | 135 | // validationError.code === ErrorCode.InvalidBody 136 | const responseDefinition = openApiSpec.findExpectedResponse(actualResponse); 137 | return joinWithNewLines( 138 | hint, 139 | `expected res to satisfy the '${status}' response defined for endpoint '${endpoint}' in your API spec`, 140 | `res did not satisfy it because: ${validationError}`, 141 | `res contained: ${actualResponse.toString()}`, 142 | `The '${status}' response defined for endpoint '${endpoint}' in API spec: ${stringify( 143 | responseDefinition, 144 | )}`, 145 | ); 146 | } 147 | 148 | function getExpectedResNotToSatisfyApiSpecMsg( 149 | actualResponse: ActualResponse, 150 | openApiSpec: OpenApiSpec, 151 | ): string { 152 | const { status, req } = actualResponse; 153 | const responseDefinition = openApiSpec.findExpectedResponse(actualResponse); 154 | const endpoint = `${req.method} ${openApiSpec.findOpenApiPathMatchingRequest( 155 | req, 156 | )}`; 157 | 158 | return joinWithNewLines( 159 | `expected res not to satisfy API spec`, 160 | `expected res not to satisfy the '${status}' response defined for endpoint '${endpoint}' in your API spec`, 161 | `res contained: ${actualResponse.toString()}`, 162 | `The '${status}' response defined for endpoint '${endpoint}' in API spec: ${stringify( 163 | responseDefinition, 164 | )}`, 165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI Validators 2 | 3 | ![build status](https://github.com/openapi-library/OpenAPIValidators/actions/workflows/ci.yml/badge.svg) 4 | ![style](https://img.shields.io/badge/code%20style-airbnb-ff5a5f.svg) 5 | [![codecov](https://codecov.io/gh/openapi-library/OpenAPIValidators/branch/master/graph/badge.svg)](https://codecov.io/gh/openapi-library/OpenAPIValidators) 6 | [![MIT License](https://img.shields.io/npm/l/openapi-validator.svg?style=flat-square)](https://github.com/openapi-library/OpenAPIValidators/blob/master/LICENSE) 7 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/openapi-library/OpenAPIValidators/blob/master/CONTRIBUTING.md) 8 | 9 | Use Jest or Chai to assert that HTTP responses satisfy an OpenAPI spec. 10 | 11 | ## Problem 😕 12 | 13 | If your server's behaviour doesn't match your API documentation, then you need to correct your server, your documentation, or both. The sooner you know the better. 14 | 15 | ## Solution 😄 16 | 17 | These test plugins let you automatically test whether your server's behaviour and documentation match. They extend Jest and Chai to support the [OpenAPI standard](https://swagger.io/docs/specification/about/) for documenting REST APIs. In your JavaScript tests, you can simply assert `expect(responseObject).toSatisfyApiSpec()` 18 | 19 | ### [jest-openapi](https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/jest-openapi#readme) 20 | 21 | [![downloads](https://img.shields.io/npm/dm/jest-openapi)](https://www.npmjs.com/package/jest-openapi) 22 | [![npm](https://img.shields.io/npm/v/jest-openapi.svg)](https://www.npmjs.com/package/jest-openapi) 23 | 24 | ### [Chai OpenAPI Response Validator](https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/chai-openapi-response-validator#readme) 25 | 26 | [![downloads](https://img.shields.io/npm/dm/chai-openapi-response-validator)](https://www.npmjs.com/package/chai-openapi-response-validator) 27 | [![npm](https://img.shields.io/npm/v/chai-openapi-response-validator.svg)](https://www.npmjs.com/package/chai-openapi-response-validator) 28 | 29 | ## Contributors ✨ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |

Richard Waller

🚧 💻 📖 👀

Jonny Spruce

💻 📖 👀

Alex Dobeck

💻 🐛

Ben Guthrie

💻 🐛

Martijn Vegter

💻

Ludek

💻 🐛

Tommy Giardina

💻 🐛

Oleksandr Khotemskyi

📖

Amit Keinan

💻

DetachHead

🐛

Kristoffer Karlsson

📖
51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /packages/openapi-validator/lib/classes/AbstractOpenApiSpec.ts: -------------------------------------------------------------------------------- 1 | import OpenAPIResponseValidator from 'openapi-response-validator'; 2 | import type { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; 3 | import { getPathname } from '../utils/common.utils'; 4 | import type { ActualRequest, ActualResponse } from './AbstractResponse'; 5 | import ValidationError, { ErrorCode } from './errors/ValidationError'; 6 | 7 | type Document = OpenAPIV2.Document | OpenAPIV3.Document; 8 | 9 | type Operation = OpenAPIV2.OperationObject | OpenAPIV3.OperationObject; 10 | 11 | type HttpMethods = OpenAPIV2.HttpMethods; 12 | 13 | type PathItemObject = OpenAPIV2.PathItemObject | OpenAPIV3.PathItemObject; 14 | 15 | export type ResponseObjectWithSchema = 16 | | (OpenAPIV2.ResponseObject & { schema: OpenAPIV2.Schema }) 17 | | (OpenAPIV3.ResponseObject & { 18 | content: { 19 | [media: string]: OpenAPIV3.MediaTypeObject & { 20 | schema: OpenAPIV3.SchemaObject; 21 | }; 22 | }; 23 | }) 24 | | (OpenAPIV3_1.ResponseObject & { 25 | content: { 26 | [media: string]: OpenAPIV3_1.MediaTypeObject & { 27 | schema: OpenAPIV3_1.SchemaObject; 28 | }; 29 | }; 30 | }); 31 | 32 | export type Schema = OpenAPIV2.Schema | OpenAPIV3.SchemaObject; 33 | 34 | export default abstract class OpenApiSpec { 35 | protected abstract getSchemaObjects(): Record | undefined; 36 | 37 | protected abstract findResponseDefinition( 38 | referenceString: string, 39 | ): ResponseObjectWithSchema | undefined; 40 | 41 | protected abstract findOpenApiPathMatchingPathname(pathname: string): string; 42 | 43 | protected abstract getComponentDefinitionsProperty(): 44 | | { 45 | definitions: OpenAPIV2.Document['definitions']; 46 | } 47 | | { 48 | components: OpenAPIV3.Document['components']; 49 | }; 50 | 51 | constructor(protected spec: Document) {} 52 | 53 | pathsObject(): Document['paths'] { 54 | return this.spec.paths; 55 | } 56 | 57 | getPathItem(openApiPath: string): PathItemObject { 58 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 59 | return this.pathsObject()[openApiPath]!; 60 | } 61 | 62 | paths(): string[] { 63 | return Object.keys(this.pathsObject()); 64 | } 65 | 66 | getSchemaObject(schemaName: string): Schema | undefined { 67 | return this.getSchemaObjects()?.[schemaName]; 68 | } 69 | 70 | getExpectedResponse( 71 | responseOperation: Operation, 72 | status: ActualResponse['status'], 73 | ): ResponseObjectWithSchema | undefined { 74 | const response = responseOperation.responses[status]; 75 | if (!response) { 76 | return undefined; 77 | } 78 | if ('$ref' in response) { 79 | return this.findResponseDefinition(response.$ref); 80 | } 81 | return response as ResponseObjectWithSchema; 82 | } 83 | 84 | findExpectedResponse( 85 | actualResponse: ActualResponse, 86 | ): Record { 87 | const actualRequest = actualResponse.req; 88 | const expectedResponseOperation = 89 | this.findExpectedResponseOperation(actualRequest); 90 | if (!expectedResponseOperation) { 91 | throw new ValidationError(ErrorCode.MethodNotFound); 92 | } 93 | 94 | const { status } = actualResponse; 95 | const expectedResponse = this.getExpectedResponse( 96 | expectedResponseOperation, 97 | status, 98 | ); 99 | if (!expectedResponse) { 100 | throw new ValidationError(ErrorCode.StatusNotFound); 101 | } 102 | 103 | return { [status]: expectedResponse }; 104 | } 105 | 106 | findOpenApiPathMatchingRequest(actualRequest: ActualRequest): string { 107 | const actualPathname = getPathname(actualRequest); 108 | const openApiPath = this.findOpenApiPathMatchingPathname(actualPathname); 109 | return openApiPath; 110 | } 111 | 112 | findExpectedPathItem(actualRequest: ActualRequest): PathItemObject { 113 | const actualPathname = getPathname(actualRequest); 114 | const openApiPath = this.findOpenApiPathMatchingPathname(actualPathname); 115 | const pathItemObject = this.getPathItem(openApiPath); 116 | return pathItemObject; 117 | } 118 | 119 | findExpectedResponseOperation( 120 | actualRequest: ActualRequest, 121 | ): Operation | undefined { 122 | const pathItemObject = this.findExpectedPathItem(actualRequest); 123 | const operationObject = 124 | pathItemObject[actualRequest.method.toLowerCase() as HttpMethods]; 125 | return operationObject; 126 | } 127 | 128 | validateResponse(actualResponse: ActualResponse): ValidationError | null { 129 | let expectedResponse: Record; 130 | try { 131 | expectedResponse = this.findExpectedResponse(actualResponse); 132 | } catch (error) { 133 | if (error instanceof ValidationError) { 134 | return error; 135 | } 136 | throw error; 137 | } 138 | const validator = new OpenAPIResponseValidator({ 139 | responses: expectedResponse, 140 | ...this.getComponentDefinitionsProperty(), 141 | }); 142 | 143 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 144 | const expectedResStatus = Object.keys(expectedResponse)[0]!; 145 | const validationError = validator.validateResponse( 146 | expectedResStatus, 147 | actualResponse.getBodyForValidation(), 148 | ); 149 | return validationError 150 | ? new ValidationError( 151 | ErrorCode.InvalidBody, 152 | validationError.errors 153 | .map(({ path, message }) => `${path} ${message}`) 154 | .join(', '), 155 | ) 156 | : null; 157 | } 158 | 159 | /* 160 | * For consistency and to save maintaining another dependency, 161 | * we validate objects using our response validator: 162 | * We put the object inside a mock response, then validate 163 | * the whole response against a mock expected response. 164 | * The 2 mock responses are identical except for the body, 165 | * thus validating the object against its schema. 166 | */ 167 | validateObject( 168 | actualObject: unknown, 169 | schema: Schema, 170 | ): ValidationError | null { 171 | const mockResStatus = '200'; 172 | const mockExpectedResponse = { [mockResStatus]: { schema } }; 173 | const validator = new OpenAPIResponseValidator({ 174 | responses: mockExpectedResponse, 175 | ...this.getComponentDefinitionsProperty(), 176 | errorTransformer: ({ path, message }) => ({ 177 | message: `${path.replace('response', 'object')} ${message}`, 178 | }), 179 | }); 180 | const validationError = validator.validateResponse( 181 | mockResStatus, 182 | actualObject, 183 | ); 184 | return validationError 185 | ? new ValidationError( 186 | ErrorCode.InvalidObject, 187 | validationError.errors.map((error) => error.message).join(', '), 188 | ) 189 | : null; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/openapi3.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example OpenApi 3 spec 4 | description: Has various paths with responses to use in testing 5 | version: 0.1.0 6 | paths: 7 | /responseBody/string: 8 | get: 9 | responses: 10 | 200: 11 | description: Response body should be a string 12 | content: 13 | application/json: 14 | schema: 15 | type: string 16 | /responseBody/boolean: 17 | get: 18 | responses: 19 | 200: 20 | description: Response body should be a boolean 21 | content: 22 | application/json: 23 | schema: 24 | type: boolean 25 | /responseBody/object/depthOver2: 26 | get: 27 | responses: 28 | 200: 29 | description: Response body should be a nested object 30 | content: 31 | application/json: 32 | schema: 33 | type: object 34 | required: 35 | - a 36 | properties: 37 | a: 38 | type: object 39 | required: 40 | - b 41 | properties: 42 | b: 43 | type: object 44 | required: 45 | - c 46 | properties: 47 | c: 48 | type: string 49 | /responseBody/object/withMultipleProperties: 50 | get: 51 | responses: 52 | 200: 53 | description: Response body should be an object with multiple string properties 54 | content: 55 | application/json: 56 | schema: 57 | type: object 58 | required: 59 | - property1 60 | - property2 61 | properties: 62 | property1: 63 | type: string 64 | property2: 65 | type: string 66 | /responseBody/referencesSchemaObject/simple: 67 | get: 68 | responses: 69 | 200: 70 | description: Response body references a simple schema object 71 | content: 72 | application/json: 73 | schema: 74 | $ref: '#/components/schemas/StringSchema' 75 | /responseBody/empty: 76 | get: 77 | responses: 78 | 204: 79 | description: No response body 80 | /responseReferencesResponseDefinitionObject: 81 | get: 82 | responses: 83 | 200: 84 | $ref: '#/components/responses/SimpleResponseDefinitionObject' 85 | /multipleResponsesDefined: 86 | get: 87 | responses: 88 | 201: 89 | description: Response body should be a string 90 | content: 91 | application/json: 92 | schema: 93 | type: string 94 | 202: 95 | description: Response body should be an integer 96 | content: 97 | application/json: 98 | schema: 99 | type: integer 100 | 203: 101 | description: No response body 102 | /queryParams: 103 | get: 104 | responses: 105 | 204: 106 | description: No response body 107 | /pathParams/primitive/{stringParam}: 108 | get: 109 | parameters: 110 | - in: path 111 | name: stringParam 112 | required: true 113 | schema: 114 | type: string 115 | responses: 116 | 204: 117 | description: No response body 118 | /pathParams/array/{arrayParam}: 119 | get: 120 | parameters: 121 | - in: path 122 | name: arrayParam 123 | required: true 124 | schema: 125 | type: array 126 | items: 127 | type: string 128 | responses: 129 | 204: 130 | description: No response body 131 | /multiplePathParams/{param1}/{param2}: 132 | get: 133 | parameters: 134 | - in: path 135 | name: param1 136 | required: true 137 | schema: 138 | type: string 139 | - in: path 140 | name: param2 141 | required: true 142 | schema: 143 | type: string 144 | responses: 145 | 204: 146 | description: No response body 147 | /pathAndQueryParams/{examplePathParam}: 148 | get: 149 | parameters: 150 | - in: path 151 | name: examplePathParam 152 | required: true 153 | schema: 154 | type: string 155 | responses: 156 | 204: 157 | description: No response body 158 | /responseStatus: 159 | get: 160 | responses: 161 | 200: 162 | description: No response body 163 | 204: 164 | description: No response body 165 | /HTTPMethod: 166 | get: 167 | responses: 168 | 204: 169 | description: No response body 170 | post: 171 | responses: 172 | 204: 173 | description: No response body 174 | /header/application/json/and/responseBody/string: 175 | get: 176 | responses: 177 | 200: 178 | description: Response header is application/json, and response body is a string 179 | content: 180 | application/json: 181 | schema: 182 | type: string 183 | /header/application/json/and/responseBody/emptyObject: 184 | get: 185 | responses: 186 | 200: 187 | description: Response header is application/json, and response body is an empty object. (Used to test that res.text is populated) 188 | content: 189 | application/json: 190 | schema: 191 | type: object 192 | /header/application/json/and/responseBody/boolean: 193 | get: 194 | responses: 195 | 200: 196 | description: Response header is application/json, and response body is a boolean. 197 | content: 198 | application/json: 199 | schema: 200 | type: boolean 201 | /header/application/json/and/responseBody/nullable: 202 | get: 203 | responses: 204 | 200: 205 | description: Response header is application/json, and response body is nullable. 206 | content: 207 | application/json: 208 | schema: 209 | type: object 210 | nullable: true 211 | /header/text/html: 212 | get: 213 | responses: 214 | 200: 215 | description: Response header is text/html, and response body or text is a string 216 | content: 217 | text/html: 218 | schema: 219 | type: string 220 | /no/content-type/header/and/no/response/body: 221 | get: 222 | responses: 223 | 204: 224 | description: No content-type response header, and there is no response body 225 | components: 226 | schemas: 227 | StringSchema: 228 | type: string 229 | responses: 230 | SimpleResponseDefinitionObject: 231 | description: Response body should be a string 232 | content: 233 | application/json: 234 | schema: 235 | type: string 236 | -------------------------------------------------------------------------------- /packages/jest-openapi/src/matchers/toSatisfyApiSpec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EXPECTED_COLOR, 3 | matcherHint, 4 | MatcherHintOptions, 5 | RECEIVED_COLOR, 6 | } from 'jest-matcher-utils'; 7 | import { 8 | ActualResponse, 9 | ErrorCode, 10 | makeResponse, 11 | OpenApi2Spec, 12 | OpenApi3Spec, 13 | OpenApiSpec, 14 | RawResponse, 15 | ValidationError, 16 | } from 'openapi-validator'; 17 | import { joinWithNewLines, stringify } from '../utils'; 18 | 19 | export default function ( 20 | this: jest.MatcherContext, 21 | received: unknown, 22 | openApiSpec: OpenApiSpec, 23 | ): jest.CustomMatcherResult { 24 | const actualResponse = makeResponse(received as RawResponse); 25 | 26 | const validationError = openApiSpec.validateResponse(actualResponse); 27 | const pass = !validationError; 28 | 29 | const matcherHintOptions: MatcherHintOptions = { 30 | comment: 31 | "Matches 'received' to a response defined in your API spec, then validates 'received' against it", 32 | isNot: this.isNot, 33 | promise: this.promise, 34 | }; 35 | const hint = matcherHint( 36 | 'toSatisfyApiSpec', 37 | undefined, 38 | '', 39 | matcherHintOptions, 40 | ); 41 | const message = pass 42 | ? () => 43 | getExpectReceivedNotToSatisfyApiSpecMsg( 44 | actualResponse, 45 | openApiSpec, 46 | hint, 47 | ) 48 | : () => 49 | getExpectReceivedToSatisfyApiSpecMsg( 50 | actualResponse, 51 | openApiSpec, 52 | validationError, 53 | hint, 54 | ); 55 | 56 | return { 57 | pass, 58 | message, 59 | }; 60 | } 61 | 62 | function getExpectReceivedToSatisfyApiSpecMsg( 63 | actualResponse: ActualResponse, 64 | openApiSpec: OpenApiSpec, 65 | validationError: ValidationError, 66 | hint: string, 67 | ): string { 68 | const { status, req } = actualResponse; 69 | const { method, path: requestPath } = req; 70 | const unmatchedEndpoint = `${method} ${requestPath}`; 71 | 72 | if (validationError.code === ErrorCode.ServerNotFound) { 73 | // prettier-ignore 74 | return joinWithNewLines( 75 | hint, 76 | `expected ${RECEIVED_COLOR('received')} to satisfy a '${status}' response defined for endpoint '${unmatchedEndpoint}' in your API spec`, 77 | `${RECEIVED_COLOR('received')} had request path ${RECEIVED_COLOR(requestPath)}, but your API spec has no matching servers`, 78 | `Servers found in API spec: ${EXPECTED_COLOR((openApiSpec as OpenApi3Spec).getServerUrls().join(', '))}`, 79 | ); 80 | } 81 | 82 | if (validationError.code === ErrorCode.BasePathNotFound) { 83 | // prettier-ignore 84 | return joinWithNewLines( 85 | hint, 86 | `expected ${RECEIVED_COLOR('received')} to satisfy a '${status}' response defined for endpoint '${unmatchedEndpoint}' in your API spec`, 87 | `${RECEIVED_COLOR('received')} had request path ${RECEIVED_COLOR(requestPath)}, but your API spec has basePath ${EXPECTED_COLOR((openApiSpec as OpenApi2Spec).spec.basePath)}`, 88 | ); 89 | } 90 | 91 | if (validationError.code === ErrorCode.PathNotFound) { 92 | // prettier-ignore 93 | const pathNotFoundErrorMessage = joinWithNewLines( 94 | hint, 95 | `expected ${RECEIVED_COLOR('received')} to satisfy a '${status}' response defined for endpoint '${unmatchedEndpoint}' in your API spec`, 96 | `${RECEIVED_COLOR('received')} had request path ${RECEIVED_COLOR(requestPath)}, but your API spec has no matching path`, 97 | `Paths found in API spec: ${EXPECTED_COLOR(openApiSpec.paths().join(', '))}`, 98 | ); 99 | 100 | if ( 101 | 'didUserDefineBasePath' in openApiSpec && 102 | openApiSpec.didUserDefineBasePath 103 | ) { 104 | // prettier-ignore 105 | return joinWithNewLines( 106 | pathNotFoundErrorMessage, 107 | `'${requestPath}' matches basePath \`${openApiSpec.spec.basePath}\` but no combinations`, 108 | ); 109 | } 110 | 111 | if ( 112 | 'didUserDefineServers' in openApiSpec && 113 | openApiSpec.didUserDefineServers 114 | ) { 115 | return joinWithNewLines( 116 | pathNotFoundErrorMessage, 117 | `'${requestPath}' matches servers ${stringify( 118 | openApiSpec.getMatchingServerUrls(requestPath), 119 | )} but no combinations`, 120 | ); 121 | } 122 | return pathNotFoundErrorMessage; 123 | } 124 | 125 | const path = openApiSpec.findOpenApiPathMatchingRequest(req); 126 | const endpoint = `${method} ${path}`; 127 | 128 | if (validationError.code === ErrorCode.MethodNotFound) { 129 | const expectedPathItem = openApiSpec.findExpectedPathItem(req); 130 | const expectedRequestOperations = Object.keys(expectedPathItem) 131 | .map((operation) => operation.toUpperCase()) 132 | .join(', '); 133 | // prettier-ignore 134 | return joinWithNewLines( 135 | hint, 136 | `expected ${RECEIVED_COLOR('received')} to satisfy a '${status}' response defined for endpoint '${endpoint}' in your API spec`, 137 | `${RECEIVED_COLOR('received')} had request method ${RECEIVED_COLOR(method)}, but your API spec has no ${RECEIVED_COLOR(method)} operation defined for path '${path}'`, 138 | `Request operations found for path '${path}' in API spec: ${EXPECTED_COLOR(expectedRequestOperations)}`, 139 | ); 140 | } 141 | 142 | if (validationError.code === ErrorCode.StatusNotFound) { 143 | const expectedResponseOperation = 144 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 145 | openApiSpec.findExpectedResponseOperation(req)!; 146 | const expectedResponseStatuses = Object.keys( 147 | expectedResponseOperation.responses, 148 | ).join(', '); 149 | // prettier-ignore 150 | return joinWithNewLines( 151 | hint, 152 | `expected ${RECEIVED_COLOR('received')} to satisfy a '${status}' response defined for endpoint '${endpoint}' in your API spec`, 153 | `${RECEIVED_COLOR('received')} had status ${RECEIVED_COLOR(status)}, but your API spec has no ${RECEIVED_COLOR(status)} response defined for endpoint '${endpoint}'`, 154 | `Response statuses found for endpoint '${endpoint}' in API spec: ${EXPECTED_COLOR(expectedResponseStatuses)}`, 155 | ); 156 | } 157 | 158 | // validationError.code === ErrorCode.InvalidBody 159 | const responseDefinition = openApiSpec.findExpectedResponse(actualResponse); 160 | // prettier-ignore 161 | return joinWithNewLines( 162 | hint, 163 | `expected ${RECEIVED_COLOR('received')} to satisfy the '${status}' response defined for endpoint '${endpoint}' in your API spec`, 164 | `${RECEIVED_COLOR('received')} did not satisfy it because: ${validationError}`, 165 | `${RECEIVED_COLOR('received')} contained: ${RECEIVED_COLOR(actualResponse.toString())}`, 166 | `The '${status}' response defined for endpoint '${endpoint}' in API spec: ${EXPECTED_COLOR(stringify(responseDefinition))}`, 167 | ); 168 | } 169 | 170 | function getExpectReceivedNotToSatisfyApiSpecMsg( 171 | actualResponse: ActualResponse, 172 | openApiSpec: OpenApiSpec, 173 | hint: string, 174 | ): string { 175 | const { status, req } = actualResponse; 176 | const responseDefinition = openApiSpec.findExpectedResponse(actualResponse); 177 | const endpoint = `${req.method} ${openApiSpec.findOpenApiPathMatchingRequest( 178 | req, 179 | )}`; 180 | 181 | // prettier-ignore 182 | return joinWithNewLines( 183 | hint, 184 | `expected ${RECEIVED_COLOR('received')} not to satisfy the '${status}' response defined for endpoint '${endpoint}' in your API spec`, 185 | `${RECEIVED_COLOR('received')} contained: ${RECEIVED_COLOR(actualResponse.toString())}`, 186 | `The '${status}' response defined for endpoint '${endpoint}' in API spec: ${EXPECTED_COLOR(stringify(responseDefinition))}`, 187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /commonTestResources/exampleOpenApiFiles/valid/openapi2.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Has various paths with responses to use in testing", 5 | "title": "Example OpenApi 2 spec", 6 | "version": "0.1.0" 7 | }, 8 | "paths": { 9 | "/responseBody/string": { 10 | "get": { 11 | "produces": ["application/json"], 12 | "parameters": [], 13 | "responses": { 14 | "200": { 15 | "description": "Response body should be a string", 16 | "schema": { 17 | "type": "string" 18 | } 19 | } 20 | } 21 | } 22 | }, 23 | "/responseBody/boolean": { 24 | "get": { 25 | "produces": ["application/json"], 26 | "parameters": [], 27 | "responses": { 28 | "200": { 29 | "description": "Response body should be a boolean", 30 | "schema": { 31 | "type": "boolean" 32 | } 33 | } 34 | } 35 | } 36 | }, 37 | "/HTTPMethod": { 38 | "get": { 39 | "parameters": [], 40 | "responses": { 41 | "204": { 42 | "description": "No response body" 43 | } 44 | } 45 | }, 46 | "post": { 47 | "parameters": [], 48 | "responses": { 49 | "204": { 50 | "description": "No response body" 51 | } 52 | } 53 | } 54 | }, 55 | "/multiplePathParams/{param1}/{param2}": { 56 | "get": { 57 | "parameters": [ 58 | { 59 | "in": "path", 60 | "name": "param1", 61 | "required": true, 62 | "type": "string" 63 | }, 64 | { 65 | "in": "path", 66 | "name": "param2", 67 | "required": true, 68 | "type": "string" 69 | } 70 | ], 71 | "responses": { 72 | "204": { 73 | "description": "No response body" 74 | } 75 | } 76 | } 77 | }, 78 | "/multipleResponsesDefined": { 79 | "get": { 80 | "produces": ["application/json"], 81 | "parameters": [], 82 | "responses": { 83 | "201": { 84 | "description": "Response body should be a string", 85 | "schema": { 86 | "type": "string" 87 | } 88 | }, 89 | "202": { 90 | "description": "Response body should be an integer", 91 | "schema": { 92 | "type": "integer" 93 | } 94 | }, 95 | "203": { 96 | "description": "No response body" 97 | } 98 | } 99 | } 100 | }, 101 | "/pathAndQueryParams/{examplePathParam}": { 102 | "get": { 103 | "parameters": [ 104 | { 105 | "in": "path", 106 | "name": "examplePathParam", 107 | "required": true, 108 | "type": "string" 109 | } 110 | ], 111 | "responses": { 112 | "204": { 113 | "description": "No response body" 114 | } 115 | } 116 | } 117 | }, 118 | "/pathParams/primitive/{stringParam}": { 119 | "get": { 120 | "parameters": [ 121 | { 122 | "in": "path", 123 | "name": "stringParam", 124 | "required": true, 125 | "type": "string" 126 | } 127 | ], 128 | "responses": { 129 | "204": { 130 | "description": "No response body" 131 | } 132 | } 133 | } 134 | }, 135 | "/pathParams/array/{arrayParam}": { 136 | "get": { 137 | "parameters": [ 138 | { 139 | "in": "path", 140 | "name": "arrayParam", 141 | "required": true, 142 | "type": "array", 143 | "items": { 144 | "type": "string" 145 | } 146 | } 147 | ], 148 | "responses": { 149 | "204": { 150 | "description": "No response body" 151 | } 152 | } 153 | } 154 | }, 155 | "/queryParams": { 156 | "get": { 157 | "parameters": [], 158 | "responses": { 159 | "204": { 160 | "description": "No response body" 161 | } 162 | } 163 | } 164 | }, 165 | "/responseBody/empty": { 166 | "get": { 167 | "parameters": [], 168 | "responses": { 169 | "204": { 170 | "description": "No response body" 171 | } 172 | } 173 | } 174 | }, 175 | "/responseBody/emptyObject": { 176 | "get": { 177 | "produces": ["application/json"], 178 | "parameters": [], 179 | "responses": { 180 | "200": { 181 | "description": "Response body is an empty object. (Used to test that res.text is populated instead)", 182 | "schema": { 183 | "type": "object" 184 | } 185 | } 186 | } 187 | } 188 | }, 189 | "/responseBody/referencesSchemaObject/simple": { 190 | "get": { 191 | "produces": ["application/json"], 192 | "parameters": [], 193 | "responses": { 194 | "200": { 195 | "description": "Response body references a simple schema object", 196 | "schema": { 197 | "$ref": "#/definitions/StringSchema" 198 | } 199 | } 200 | } 201 | } 202 | }, 203 | "/responseBody/object/depthOver2": { 204 | "get": { 205 | "produces": ["application/json"], 206 | "parameters": [], 207 | "responses": { 208 | "200": { 209 | "description": "Response body should be nested object", 210 | "schema": { 211 | "type": "object", 212 | "required": ["a"], 213 | "properties": { 214 | "a": { 215 | "type": "object", 216 | "required": ["b"], 217 | "properties": { 218 | "b": { 219 | "type": "object", 220 | "required": ["c"], 221 | "properties": { 222 | "c": { 223 | "type": "string" 224 | } 225 | } 226 | } 227 | } 228 | } 229 | } 230 | } 231 | } 232 | } 233 | } 234 | }, 235 | "/responseBody/object/withMultipleProperties": { 236 | "get": { 237 | "produces": ["application/json"], 238 | "parameters": [], 239 | "responses": { 240 | "200": { 241 | "description": "Response body should be an object with multiple string properties", 242 | "schema": { 243 | "type": "object", 244 | "required": ["property1", "property2"], 245 | "properties": { 246 | "property1": { 247 | "type": "string" 248 | }, 249 | "property2": { 250 | "type": "string" 251 | } 252 | } 253 | } 254 | } 255 | } 256 | } 257 | }, 258 | "/responseStatus": { 259 | "get": { 260 | "parameters": [], 261 | "responses": { 262 | "200": { 263 | "description": "No response body" 264 | }, 265 | "204": { 266 | "description": "No response body" 267 | } 268 | } 269 | } 270 | }, 271 | "/responseReferencesResponseDefinitionObject": { 272 | "get": { 273 | "produces": ["application/json"], 274 | "parameters": [], 275 | "responses": { 276 | "200": { 277 | "$ref": "#/responses/SimpleResponseDefinitionObject" 278 | } 279 | } 280 | } 281 | } 282 | }, 283 | "definitions": { 284 | "StringSchema": { 285 | "type": "string" 286 | } 287 | }, 288 | "responses": { 289 | "SimpleResponseDefinitionObject": { 290 | "description": "Response body should be a string", 291 | "schema": { 292 | "type": "string" 293 | } 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["./types/combos.d.ts"], 3 | "compilerOptions": { 4 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 5 | 6 | /* Projects */ 7 | // "incremental": true, /* Enable incremental compilation */ 8 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 9 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 10 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 11 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 12 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 13 | 14 | /* Language and Environment */ 15 | "target": "ES2018" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 17 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 23 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs" /* Specify what module code is generated. */, 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "resolveJsonModule": true, /* Enable importing .json files */ 38 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 39 | 40 | /* JavaScript Support */ 41 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 42 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 43 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 44 | 45 | /* Emit */ 46 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 47 | "declarationMap": true /* Create sourcemaps for d.ts files. */, 48 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 49 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 50 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 51 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 52 | // "removeComments": true, /* Disable emitting comments. */ 53 | // "noEmit": true, /* Disable emitting files from a compilation. */ 54 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 55 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 56 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 57 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 60 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 61 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 62 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 63 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 64 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 65 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 66 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 67 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied `any` type.. */, 79 | "strictNullChecks": true /* When type checking, take into account `null` and `undefined`. */, 80 | "strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */, 81 | "strictBindCallApply": true /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */, 82 | "strictPropertyInitialization": true /* Check for class properties that are declared but not set in the constructor. */, 83 | "noImplicitThis": true /* Enable error reporting when `this` is given the type `any`. */, 84 | "useUnknownInCatchVariables": true /* Type catch clause variables as 'unknown' instead of 'any'. */, 85 | "alwaysStrict": true /* Ensure 'use strict' is always emitted. */, 86 | "noUnusedLocals": true /* Enable error reporting when a local variables aren't read. */, 87 | "noUnusedParameters": true /* Raise an error when a function parameter isn't read */, 88 | "exactOptionalPropertyTypes": true /* Interpret optional property types as written, rather than adding 'undefined'. */, 89 | "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */, 90 | "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */, 91 | "noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */, 92 | "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */, 93 | "noPropertyAccessFromIndexSignature": true /* Enforces using indexed accessors for keys declared using an indexed type */, 94 | "allowUnusedLabels": false /* Disable error reporting for unused labels. */, 95 | "allowUnreachableCode": false /* Disable error reporting for unreachable code. */, 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/jest-openapi/README.md: -------------------------------------------------------------------------------- 1 | # jest-openapi 2 | 3 | [![downloads](https://img.shields.io/npm/dm/jest-openapi)](https://www.npmjs.com/package/jest-openapi) 4 | [![npm](https://img.shields.io/npm/v/jest-openapi.svg)](https://www.npmjs.com/package/jest-openapi) 5 | ![build status](https://github.com/openapi-library/OpenAPIValidators/actions/workflows/ci.yml/badge.svg) 6 | ![style](https://img.shields.io/badge/code%20style-airbnb-ff5a5f.svg) 7 | [![codecov](https://codecov.io/gh/openapi-library/OpenAPIValidators/branch/master/graph/badge.svg)](https://codecov.io/gh/openapi-library/OpenAPIValidators) 8 | [![included](https://badgen.net/npm/types/jest-openapi)](https://github.com/openapi-library/OpenAPIValidators/blob/master/packages/jest-openapi/src/index.ts) 9 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/openapi-library/OpenAPIValidators/blob/master/CONTRIBUTING.md) 10 | 11 | Additional Jest matchers for asserting that HTTP responses satisfy an OpenAPI spec. 12 | 13 | ## Problem 😕 14 | 15 | If your server's behaviour doesn't match your API documentation, then you need to correct your server, your documentation, or both. The sooner you know the better. 16 | 17 | ## Solution 😄 18 | 19 | This plugin lets you automatically test whether your server's behaviour and documentation match. It adds Jest matchers that support the [OpenAPI standard](https://swagger.io/docs/specification/about/) for documenting REST APIs. In your JavaScript tests, you can simply assert [`expect(responseObject).toSatisfyApiSpec()`](#in-api-tests-validate-the-status-and-body-of-http-responses-against-your-openapi-spec) 20 | 21 | Features: 22 | 23 | - Validates the status and body of HTTP responses against your OpenAPI spec [(see example)](#in-api-tests-validate-the-status-and-body-of-http-responses-against-your-openapi-spec) 24 | - Validates objects against schemas defined in your OpenAPI spec [(see example)](#in-unit-tests-validate-objects-against-schemas-defined-in-your-OpenAPI-spec) 25 | - Load your OpenAPI spec just once in your tests (load from a filepath or object) 26 | - Supports OpenAPI [2](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) and [3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md) 27 | - Supports OpenAPI specs in YAML and JSON formats 28 | - Supports `$ref` in response definitions (i.e. `$ref: '#/definitions/ComponentType/ComponentName'`) 29 | - Informs you if your OpenAPI spec is invalid 30 | - Supports responses from `axios`, `request-promise`, `supertest`, `superagent`, and `chai-http` 31 | - Use in [Jest](#usage), or use our [sister package](https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/chai-openapi-response-validator#readme) for Mocha and other test runners that support Chai 32 | 33 | ## Contributing ✨ 34 | 35 | If you've come here to help contribute - thanks! Take a look at the [contributing](https://github.com/openapi-library/OpenAPIValidators/blob/master/CONTRIBUTING.md) docs to get started. 36 | 37 | ## Installation 38 | 39 | [npm](http://npmjs.org) 40 | 41 | ```bash 42 | npm install --save-dev jest-openapi 43 | ``` 44 | 45 | [yarn](https://yarnpkg.com/) 46 | 47 | ```bash 48 | yarn add --dev jest-openapi 49 | ``` 50 | 51 | ## Importing 52 | 53 | ES6 / TypeScript 54 | 55 | ```typescript 56 | import jestOpenAPI from 'jest-openapi'; 57 | ``` 58 | 59 | CommonJS / JavaScript 60 | 61 | ```javascript 62 | const jestOpenAPI = require('jest-openapi').default; 63 | ``` 64 | 65 | ## Usage 66 | 67 | ### In API tests, validate the status and body of HTTP responses against your OpenAPI spec: 68 | 69 | #### 1. Write a test: 70 | 71 | ```javascript 72 | // Import this plugin 73 | import jestOpenAPI from 'jest-openapi'; 74 | 75 | // Load an OpenAPI file (YAML or JSON) into this plugin 76 | jestOpenAPI('path/to/openapi.yml'); 77 | 78 | // Write your test 79 | describe('GET /example/endpoint', () => { 80 | it('should satisfy OpenAPI spec', async () => { 81 | // Get an HTTP response from your server (e.g. using axios) 82 | const res = await axios.get('http://localhost:3000/example/endpoint'); 83 | 84 | expect(res.status).toEqual(200); 85 | 86 | // Assert that the HTTP response satisfies the OpenAPI spec 87 | expect(res).toSatisfyApiSpec(); 88 | }); 89 | }); 90 | ``` 91 | 92 | #### 2. Write an OpenAPI Spec (and save to `path/to/openapi.yml`): 93 | 94 | ```yaml 95 | openapi: 3.0.0 96 | info: 97 | title: Example API 98 | version: 1.0.0 99 | paths: 100 | /example: 101 | get: 102 | responses: 103 | 200: 104 | description: Response body should be an object with fields 'stringProperty' and 'integerProperty' 105 | content: 106 | application/json: 107 | schema: 108 | type: object 109 | required: 110 | - stringProperty 111 | - integerProperty 112 | properties: 113 | stringProperty: 114 | type: string 115 | integerProperty: 116 | type: integer 117 | ``` 118 | 119 | #### 3. Run your test to validate your server's response against your OpenAPI spec: 120 | 121 | ##### The assertion passes if the response status and body satisfy `openapi.yml`: 122 | 123 | ```javascript 124 | // Response includes: 125 | { 126 | status: 200, 127 | body: { 128 | stringProperty: 'string', 129 | integerProperty: 123, 130 | }, 131 | }; 132 | ``` 133 | 134 | ##### The assertion fails if the response body is invalid: 135 | 136 | ```javascript 137 | // Response includes: 138 | { 139 | status: 200, 140 | body: { 141 | stringProperty: 'string', 142 | integerProperty: 'invalid (should be an integer)', 143 | }, 144 | }; 145 | ``` 146 | 147 | ###### Output from test failure: 148 | 149 | ```javascript 150 | expect(received).toSatisfyApiSpec() // Matches 'received' to a response defined in your API spec, then validates 'received' against it 151 | 152 | expected received to satisfy the '200' response defined for endpoint 'GET /example/endpoint' in your API spec 153 | received did not satisfy it because: integerProperty should be integer 154 | 155 | received contained: { 156 | body: { 157 | stringProperty: 'string', 158 | integerProperty: 'invalid (should be an integer)' 159 | } 160 | } 161 | } 162 | 163 | The '200' response defined for endpoint 'GET /example/endpoint' in API spec: { 164 | '200': { 165 | description: 'Response body should be a string', 166 | content: { 167 | 'application/json': { 168 | schema: { 169 | type: 'string' 170 | } 171 | } 172 | } 173 | }, 174 | } 175 | ``` 176 | 177 | ### In unit tests, validate objects against schemas defined in your OpenAPI spec: 178 | 179 | #### 1. Write a test: 180 | 181 | ```javascript 182 | // Import this plugin and the function you want to test 183 | import jestOpenAPI from 'jest-openapi'; 184 | import { functionToTest } from 'path/to/your/code'; 185 | 186 | // Load an OpenAPI file (YAML or JSON) into this plugin 187 | jestOpenAPI('path/to/openapi.yml'); 188 | 189 | // Write your test 190 | describe('functionToTest()', () => { 191 | it('should satisfy OpenAPI spec', async () => { 192 | // Assert that the function returns a value satisfying a schema defined in your OpenAPI spec 193 | expect(functionToTest()).toSatisfySchemaInApiSpec('ExampleSchemaObject'); 194 | }); 195 | }); 196 | ``` 197 | 198 | #### 2. Write an OpenAPI Spec (and save to `path/to/openapi.yml`): 199 | 200 | ```yaml 201 | openapi: 3.0.0 202 | info: 203 | title: Example API 204 | version: 1.0.0 205 | paths: 206 | /example: 207 | get: 208 | responses: 209 | 200: 210 | description: Response body should be an ExampleSchemaObject 211 | content: 212 | application/json: 213 | schema: 214 | $ref: '#/components/schemas/ExampleSchemaObject' 215 | components: 216 | schemas: 217 | ExampleSchemaObject: 218 | type: object 219 | required: 220 | - stringProperty 221 | - integerProperty 222 | properties: 223 | stringProperty: 224 | type: string 225 | integerProperty: 226 | type: integer 227 | ``` 228 | 229 | #### 3. Run your test to validate your object against your OpenAPI spec: 230 | 231 | ##### The assertion passes if the object satisfies the schema `ExampleSchemaObject`: 232 | 233 | ```javascript 234 | // object includes: 235 | { 236 | stringProperty: 'string', 237 | integerProperty: 123, 238 | }; 239 | ``` 240 | 241 | ##### The assertion fails if the object does not satisfy the schema `ExampleSchemaObject`: 242 | 243 | ```javascript 244 | // object includes: 245 | { 246 | stringProperty: 123, 247 | integerProperty: 123, 248 | }; 249 | ``` 250 | 251 | ###### Output from test failure: 252 | 253 | ```javascript 254 | expect(received).not.toSatisfySchemaInApiSpec(schemaName) // Matches 'received' to a schema defined in your API spec, then validates 'received' against it 255 | 256 | expected received to satisfy the 'StringSchema' schema defined in your API spec 257 | object did not satisfy it because: stringProperty should be string 258 | 259 | object was: { 260 | { 261 | stringProperty: 123, 262 | integerProperty: 123 263 | } 264 | } 265 | } 266 | 267 | The 'ExampleSchemaObject' schema in API spec: { 268 | type: 'object', 269 | required: [ 270 | 'stringProperty' 271 | 'integerProperty' 272 | ], 273 | properties: { 274 | stringProperty: { 275 | type: 'string' 276 | }, 277 | integerProperty: { 278 | type: 'integer' 279 | } 280 | } 281 | } 282 | ``` 283 | 284 | ### Loading your OpenAPI spec (3 different ways): 285 | 286 | #### 1. From an absolute filepath ([see above](#usage)) 287 | 288 | #### 2. From an object: 289 | 290 | ```javascript 291 | // Import this plugin 292 | import jestOpenAPI from 'jest-openapi'; 293 | 294 | // Get an object representing your OpenAPI spec 295 | const openApiSpec = { 296 | openapi: '3.0.0', 297 | info: { 298 | title: 'Example API', 299 | version: '0.1.0', 300 | }, 301 | paths: { 302 | '/example/endpoint': { 303 | get: { 304 | responses: { 305 | 200: { 306 | description: 'Response body should be a string', 307 | content: { 308 | 'application/json': { 309 | schema: { 310 | type: 'string', 311 | }, 312 | }, 313 | }, 314 | }, 315 | }, 316 | }, 317 | }, 318 | }, 319 | }; 320 | 321 | // Load that OpenAPI object into this plugin 322 | jestOpenAPI(openApiSpec); 323 | 324 | // Write your test 325 | describe('GET /example/endpoint', () => { 326 | it('should satisfy OpenAPI spec', async () => { 327 | // Get an HTTP response from your server (e.g. using axios) 328 | const res = await axios.get('http://localhost:3000/example/endpoint'); 329 | 330 | expect(res.status).toEqual(200); 331 | 332 | // Assert that the HTTP response satisfies the OpenAPI spec 333 | expect(res).toSatisfyApiSpec(); 334 | }); 335 | }); 336 | ``` 337 | 338 | #### 3. From a web endpoint: 339 | 340 | ```javascript 341 | // Import this plugin and an HTTP client (e.g. axios) 342 | import jestOpenAPI from 'jest-openapi'; 343 | import axios from 'axios'; 344 | 345 | // Write your test 346 | describe('GET /example/endpoint', () => { 347 | // Load your OpenAPI spec from a web endpoint 348 | beforeAll(async () => { 349 | const response = await axios.get('url/to/openapi/spec'); 350 | const openApiSpec = response.data; // e.g. { openapi: '3.0.0', ... }; 351 | jestOpenAPI(openApiSpec); 352 | }); 353 | 354 | it('should satisfy OpenAPI spec', async () => { 355 | // Get an HTTP response from your server 356 | const res = await axios.get('http://localhost:3000/example/endpoint'); 357 | 358 | expect(res.status).toEqual(200); 359 | 360 | // Assert that the HTTP response satisfies the OpenAPI spec 361 | expect(res).toSatisfyApiSpec(); 362 | }); 363 | }); 364 | ``` 365 | -------------------------------------------------------------------------------- /packages/chai-openapi-response-validator/README.md: -------------------------------------------------------------------------------- 1 | # Chai OpenAPI Response Validator 2 | 3 | [![downloads](https://img.shields.io/npm/dm/chai-openapi-response-validator)](https://www.npmjs.com/package/chai-openapi-response-validator) 4 | [![npm](https://img.shields.io/npm/v/chai-openapi-response-validator.svg)](https://www.npmjs.com/package/chai-openapi-response-validator) 5 | ![build status](https://github.com/openapi-library/OpenAPIValidators/actions/workflows/ci.yml/badge.svg) 6 | ![style](https://img.shields.io/badge/code%20style-airbnb-ff5a5f.svg) 7 | [![codecov](https://codecov.io/gh/openapi-library/OpenAPIValidators/branch/master/graph/badge.svg)](https://codecov.io/gh/openapi-library/OpenAPIValidators) 8 | [![included](https://badgen.net/npm/types/chai-openapi-response-validator)](https://github.com/openapi-library/OpenAPIValidators/blob/master/packages/chai-openapi-response-validator/lib/index.ts) 9 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/openapi-library/OpenAPIValidators/blob/master/CONTRIBUTING.md) 10 | 11 | Use Chai to assert that HTTP responses satisfy an OpenAPI spec. 12 | 13 | ## Problem 😕 14 | 15 | If your server's behaviour doesn't match your API documentation, then you need to correct your server, your documentation, or both. The sooner you know the better. 16 | 17 | ## Solution 😄 18 | 19 | This plugin lets you automatically test whether your server's behaviour and documentation match. It extends the [Chai Assertion Library](https://www.chaijs.com/) to support the [OpenAPI standard](https://swagger.io/docs/specification/about/) for documenting REST APIs. In your JavaScript tests, you can simply assert [`expect(responseObject).to.satisfyApiSpec`](#in-api-tests-validate-the-status-and-body-of-http-responses-against-your-openapi-spec) 20 | 21 | Features: 22 | 23 | - Validates the status and body of HTTP responses against your OpenAPI spec [(see example)](#in-api-tests-validate-the-status-and-body-of-http-responses-against-your-openapi-spec) 24 | - Validates objects against schemas defined in your OpenAPI spec [(see example)](#in-unit-tests-validate-objects-against-schemas-defined-in-your-OpenAPI-spec) 25 | - Load your OpenAPI spec just once in your tests (load from a filepath or object) 26 | - Supports OpenAPI [2](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) and [3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md) 27 | - Supports OpenAPI specs in YAML and JSON formats 28 | - Supports `$ref` in response definitions (i.e. `$ref: '#/definitions/ComponentType/ComponentName'`) 29 | - Informs you if your OpenAPI spec is invalid 30 | - Supports responses from `axios`, `request-promise`, `supertest`, `superagent`, and `chai-http` 31 | - Use in [Mocha](#usage) and other test runners 32 | 33 | ## Contributing ✨ 34 | 35 | If you've come here to help contribute - thanks! Take a look at the [contributing](https://github.com/openapi-library/OpenAPIValidators/blob/master/CONTRIBUTING.md) docs to get started. 36 | 37 | ## Installation 38 | 39 | [npm](http://npmjs.org) 40 | 41 | ```bash 42 | npm install --save-dev chai-openapi-response-validator 43 | ``` 44 | 45 | [yarn](https://yarnpkg.com/) 46 | 47 | ```bash 48 | yarn add --dev chai-openapi-response-validator 49 | ``` 50 | 51 | ## Importing 52 | 53 | ES6 / TypeScript 54 | 55 | ```typescript 56 | import chaiResponseValidator from 'chai-openapi-response-validator'; 57 | ``` 58 | 59 | CommonJS / JavaScript 60 | 61 | 62 | ```javascript 63 | const chaiResponseValidator = require('chai-openapi-response-validator').default; 64 | ``` 65 | 66 | ## Usage 67 | 68 | ### In API tests, validate the status and body of HTTP responses against your OpenAPI spec: 69 | 70 | #### 1. Write a test: 71 | 72 | ```javascript 73 | // Set up Chai 74 | import chai from 'chai'; 75 | const expect = chai.expect; 76 | 77 | // Import this plugin 78 | import chaiResponseValidator from 'chai-openapi-response-validator'; 79 | 80 | // Load an OpenAPI file (YAML or JSON) into this plugin 81 | chai.use(chaiResponseValidator('path/to/openapi.yml')); 82 | 83 | // Write your test (e.g. using Mocha) 84 | describe('GET /example/endpoint', () => { 85 | it('should satisfy OpenAPI spec', async () => { 86 | // Get an HTTP response from your server (e.g. using axios) 87 | const res = await axios.get('http://localhost:3000/example/endpoint'); 88 | 89 | expect(res.status).to.equal(200); 90 | 91 | // Assert that the HTTP response satisfies the OpenAPI spec 92 | expect(res).to.satisfyApiSpec; 93 | }); 94 | }); 95 | ``` 96 | 97 | #### 2. Write an OpenAPI Spec (and save to `path/to/openapi.yml`): 98 | 99 | ```yaml 100 | openapi: 3.0.0 101 | info: 102 | title: Example API 103 | version: 1.0.0 104 | paths: 105 | /example: 106 | get: 107 | responses: 108 | 200: 109 | description: Response body should be an object with fields 'stringProperty' and 'integerProperty' 110 | content: 111 | application/json: 112 | schema: 113 | type: object 114 | required: 115 | - stringProperty 116 | - integerProperty 117 | properties: 118 | stringProperty: 119 | type: string 120 | integerProperty: 121 | type: integer 122 | ``` 123 | 124 | #### 3. Run your test to validate your server's response against your OpenAPI spec: 125 | 126 | ##### The assertion passes if the response status and body satisfy `openapi.yml`: 127 | 128 | ```javascript 129 | // Response includes: 130 | { 131 | status: 200, 132 | body: { 133 | stringProperty: 'string', 134 | integerProperty: 123, 135 | }, 136 | }; 137 | ``` 138 | 139 | ##### The assertion fails if the response body is invalid: 140 | 141 | ```javascript 142 | // Response includes: 143 | { 144 | status: 200, 145 | body: { 146 | stringProperty: 'string', 147 | integerProperty: 'invalid (should be an integer)', 148 | }, 149 | }; 150 | ``` 151 | 152 | ###### Output from test failure: 153 | 154 | ```javascript 155 | AssertionError: expected res to satisfy API spec 156 | 157 | expected res to satisfy the '200' response defined for endpoint 'GET /example/endpoint' in your API spec 158 | res did not satisfy it because: integerProperty should be integer 159 | 160 | res contained: { 161 | body: { 162 | stringProperty: 'string', 163 | integerProperty: 'invalid (should be an integer)' 164 | } 165 | } 166 | } 167 | 168 | The '200' response defined for endpoint 'GET /example/endpoint' in API spec: { 169 | '200': { 170 | description: 'Response body should be a string', 171 | content: { 172 | 'application/json': { 173 | schema: { 174 | type: 'string' 175 | } 176 | } 177 | } 178 | }, 179 | } 180 | ``` 181 | 182 | ### In unit tests, validate objects against schemas defined in your OpenAPI spec: 183 | 184 | #### 1. Write a test: 185 | 186 | ```javascript 187 | // Set up Chai 188 | import chai from 'chai'; 189 | const expect = chai.expect; 190 | 191 | // Import this plugin and the function you want to test 192 | import chaiResponseValidator from 'chai-openapi-response-validator'; 193 | import { functionToTest } from 'path/to/your/code'; 194 | 195 | // Load an OpenAPI file (YAML or JSON) into this plugin 196 | chai.use(chaiResponseValidator('path/to/openapi.yml')); 197 | 198 | // Write your test (e.g. using Mocha) 199 | describe('functionToTest()', () => { 200 | it('should satisfy OpenAPI spec', async () => { 201 | // Assert that the function returns a value satisfying a schema defined in your OpenAPI spec 202 | expect(functionToTest()).to.satisfySchemaInApiSpec('ExampleSchemaObject'); 203 | }); 204 | }); 205 | ``` 206 | 207 | #### 2. Write an OpenAPI Spec (and save to `path/to/openapi.yml`): 208 | 209 | ```yaml 210 | openapi: 3.0.0 211 | info: 212 | title: Example API 213 | version: 1.0.0 214 | paths: 215 | /example: 216 | get: 217 | responses: 218 | 200: 219 | description: Response body should be an ExampleSchemaObject 220 | content: 221 | application/json: 222 | schema: 223 | $ref: '#/components/schemas/ExampleSchemaObject' 224 | components: 225 | schemas: 226 | ExampleSchemaObject: 227 | type: object 228 | required: 229 | - stringProperty 230 | - integerProperty 231 | properties: 232 | stringProperty: 233 | type: string 234 | integerProperty: 235 | type: integer 236 | ``` 237 | 238 | #### 3. Run your test to validate your object against your OpenAPI spec: 239 | 240 | ##### The assertion passes if the object satisfies the schema `ExampleSchemaObject`: 241 | 242 | ```javascript 243 | // object includes: 244 | { 245 | stringProperty: 'string', 246 | integerProperty: 123, 247 | }; 248 | ``` 249 | 250 | ##### The assertion fails if the object does not satisfy the schema `ExampleSchemaObject`: 251 | 252 | ```javascript 253 | // object includes: 254 | { 255 | stringProperty: 123, 256 | integerProperty: 123, 257 | }; 258 | ``` 259 | 260 | ###### Output from test failure: 261 | 262 | ```javascript 263 | AssertionError: expected object to satisfy schema 'ExampleSchemaObject' defined in API spec: 264 | object did not satisfy it because: stringProperty should be string 265 | 266 | object was: { 267 | { 268 | stringProperty: 123, 269 | integerProperty: 123 270 | } 271 | } 272 | } 273 | 274 | The 'ExampleSchemaObject' schema in API spec: { 275 | type: 'object', 276 | required: [ 277 | 'stringProperty' 278 | 'integerProperty' 279 | ], 280 | properties: { 281 | stringProperty: { 282 | type: 'string' 283 | }, 284 | integerProperty: { 285 | type: 'integer' 286 | } 287 | } 288 | } 289 | ``` 290 | 291 | ### Loading your OpenAPI spec (3 different ways): 292 | 293 | #### 1. From an absolute filepath ([see above](#usage)) 294 | 295 | #### 2. From an object: 296 | 297 | ```javascript 298 | // Set up Chai 299 | import chai from 'chai'; 300 | const expect = chai.expect; 301 | 302 | // Import this plugin 303 | import chaiResponseValidator from 'chai-openapi-response-validator'; 304 | 305 | // Get an object representing your OpenAPI spec 306 | const openApiSpec = { 307 | openapi: '3.0.0', 308 | info: { 309 | title: 'Example API', 310 | version: '0.1.0', 311 | }, 312 | paths: { 313 | '/example/endpoint': { 314 | get: { 315 | responses: { 316 | 200: { 317 | description: 'Response body should be a string', 318 | content: { 319 | 'application/json': { 320 | schema: { 321 | type: 'string', 322 | }, 323 | }, 324 | }, 325 | }, 326 | }, 327 | }, 328 | }, 329 | }, 330 | }; 331 | 332 | // Load that OpenAPI object into this plugin 333 | chai.use(chaiResponseValidator(openApiSpec)); 334 | 335 | // Write your test (e.g. using Mocha) 336 | describe('GET /example/endpoint', () => { 337 | it('should satisfy OpenAPI spec', async () => { 338 | // Get an HTTP response from your server (e.g. using axios) 339 | const res = await axios.get('http://localhost:3000/example/endpoint'); 340 | 341 | expect(res.status).to.equal(200); 342 | 343 | // Assert that the HTTP response satisfies the OpenAPI spec 344 | expect(res).to.satisfyApiSpec; 345 | }); 346 | }); 347 | ``` 348 | 349 | #### 3. From a web endpoint: 350 | 351 | ```javascript 352 | // Set up Chai 353 | import chai from 'chai'; 354 | const expect = chai.expect; 355 | 356 | // Import this plugin and an HTTP client (e.g. axios) 357 | import chaiResponseValidator from 'chai-openapi-response-validator'; 358 | import axios from 'axios'; 359 | 360 | // Write your test (e.g. using Mocha) 361 | describe('GET /example/endpoint', () => { 362 | // Load your OpenAPI spec from a web endpoint 363 | before(async () => { 364 | const response = await axios.get('url/to/openapi/spec'); 365 | const openApiSpec = response.data; // e.g. { openapi: '3.0.0', }; 366 | chai.use(chaiResponseValidator(openApiSpec)); 367 | }); 368 | 369 | it('should satisfy OpenAPI spec', async () => { 370 | // Get an HTTP response from your server (e.g. using axios) 371 | const res = await axios.get('http://localhost:3000/example/endpoint'); 372 | 373 | expect(res.status).to.equal(200); 374 | 375 | // Assert that the HTTP response satisfies the OpenAPI spec 376 | expect(res).to.satisfyApiSpec; 377 | }); 378 | }); 379 | ``` 380 | --------------------------------------------------------------------------------