├── .npmignore ├── .gitignore ├── src ├── index.ts ├── utils.ts ├── __snapshots__ │ └── index.test.ts.snap ├── Reva.ts └── index.test.ts ├── .github └── workflows │ ├── size.yml │ └── main.yml ├── LICENSE ├── tsconfig.json ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | __snapshots__ 3 | *.test.ts 4 | .github 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Reva, RevaOptions, RevaRequest, RevaValidateOptions } from './Reva'; 2 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | build: 9 | name: Build, lint, and test on Node ${{ matrix.node }} 10 | 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node: ['14.x', '16.x'] 15 | 16 | steps: 17 | - name: Checkout repo 18 | uses: actions/checkout@v2 19 | 20 | - name: Use Node ${{ matrix.node }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node }} 24 | 25 | - name: Install deps and build (with cache) 26 | uses: bahmutov/npm-install@v1 27 | 28 | - name: Lint 29 | run: yarn lint 30 | 31 | - name: Test 32 | run: yarn test --ci --coverage --maxWorkers=2 33 | 34 | - name: Build 35 | run: yarn build 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Elias Meire 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. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "downlevelIteration": true, 20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | // use Node's module resolution algorithm, instead of the legacy TS one 24 | "moduleResolution": "node", 25 | // transpile JSX to React.createElement 26 | "jsx": "react", 27 | // interop between ESM and CJS modules. Recommended by TS 28 | "esModuleInterop": true, 29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 30 | "skipLibCheck": true, 31 | // error out if import and file system have a casing mismatch. Recommended by TS 32 | "forceConsistentCasingInFileNames": true, 33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 34 | "noEmit": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apideck/reva", 3 | "author": "Elias Meire", 4 | "module": "dist/reva.esm.js", 5 | "version": "0.2.1", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "typings": "dist/index.d.ts", 9 | "keywords": [ 10 | "openapi", 11 | "validator", 12 | "jsonschema" 13 | ], 14 | "repository": "https://github.com/apideck-libraries/reva", 15 | "engines": { 16 | "node": ">=12" 17 | }, 18 | "scripts": { 19 | "start": "tsdx watch", 20 | "build": "tsdx build", 21 | "test": "tsdx test", 22 | "lint": "tsdx lint", 23 | "prepare": "tsdx build", 24 | "release": "np --no-publish && npm publish --access public --registry https://registry.npmjs.org", 25 | "size": "size-limit", 26 | "analyze": "size-limit --why" 27 | }, 28 | "dependencies": { 29 | "@apideck/better-ajv-errors": "^0.3.6", 30 | "ajv": "^8.12.0", 31 | "openapi-types": "^12.1.0" 32 | }, 33 | "devDependencies": { 34 | "@size-limit/preset-small-lib": "^8.1.0", 35 | "@types/node": "^18.11.18", 36 | "husky": "^8.0.3", 37 | "np": "^7.6.3", 38 | "size-limit": "^8.1.0", 39 | "tsdx": "^0.14.1", 40 | "tslib": "^2.4.1", 41 | "typescript": "^4.9.4" 42 | }, 43 | "peerDependencies": {}, 44 | "husky": { 45 | "hooks": { 46 | "pre-commit": "tsdx lint" 47 | } 48 | }, 49 | "prettier": { 50 | "printWidth": 80, 51 | "semi": true, 52 | "singleQuote": true, 53 | "trailingComma": "es5" 54 | }, 55 | "size-limit": [ 56 | { 57 | "path": "dist/reva.cjs.production.min.js", 58 | "limit": "50 KB" 59 | }, 60 | { 61 | "path": "dist/reva.esm.js", 62 | "limit": "50 KB" 63 | } 64 | ], 65 | "jest": { 66 | "coverageThreshold": { 67 | "global": { 68 | "branches": 85, 69 | "functions": 95, 70 | "lines": 95, 71 | "statements": 95 72 | } 73 | } 74 | }, 75 | "resolutions": { 76 | "prettier": "^2.3.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV3 } from 'openapi-types'; 2 | 3 | export const lowercaseKeys = < 4 | T extends Record = Record 5 | >( 6 | obj: T 7 | ): T => { 8 | return Object.fromEntries( 9 | Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]) 10 | ) as T; 11 | }; 12 | 13 | export const tryJsonParse = (str: string): Record | null => { 14 | try { 15 | return JSON.parse(str); 16 | } catch (e) { 17 | return null; 18 | } 19 | }; 20 | 21 | export const parseCookies = ( 22 | cookieHeader?: string | null 23 | ): Record | null => { 24 | if (!cookieHeader) return null; 25 | 26 | const cookies = cookieHeader.split('; ').reduce((prev, current) => { 27 | const [name, ...value] = current.split('='); 28 | prev[name] = value.join('='); 29 | return prev; 30 | }, {} as Record); 31 | 32 | return cookies; 33 | }; 34 | 35 | export const parseContentType = ( 36 | contentType: string 37 | ): { mediaType: string; parameters: Record } => { 38 | const [mediaType, ...parts] = contentType 39 | .split(';') 40 | .map((part) => part.trim()); 41 | const parameters = Object.fromEntries(parts.map((part) => part.split('='))); 42 | 43 | return { mediaType, parameters }; 44 | }; 45 | 46 | export const removeReadOnlyProperties = ( 47 | schema: OpenAPIV3.SchemaObject 48 | ): OpenAPIV3.SchemaObject => { 49 | if (!schema.properties) { 50 | return schema; 51 | } 52 | 53 | schema.required = schema.required?.filter((field) => { 54 | const prop = schema.properties?.[field]; 55 | return !( 56 | typeof prop === 'object' && (prop as OpenAPIV3.SchemaObject).readOnly 57 | ); 58 | }); 59 | 60 | schema.properties = Object.fromEntries( 61 | Object.entries(schema.properties) 62 | .map(([key, value]) => { 63 | if (typeof value !== 'object') { 64 | return [key, value]; 65 | } 66 | 67 | const schemaObject = value as OpenAPIV3.SchemaObject; 68 | 69 | if (schemaObject.readOnly) { 70 | return [key, null]; 71 | } 72 | 73 | if (schemaObject.type === 'object') { 74 | return [key, removeReadOnlyProperties(schemaObject)]; 75 | } 76 | 77 | return [key, value]; 78 | }) 79 | .filter(([, value]) => value !== null) 80 | ) as Record; 81 | 82 | return schema; 83 | }; 84 | 85 | export function isPresent(t: T | undefined | null | void): t is T { 86 | return t !== undefined && t !== null; 87 | } 88 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Reva .validate() invalid requests should handle additional parameters (query & path) 1`] = ` 4 | Object { 5 | "errors": Array [ 6 | Object { 7 | "context": Object { 8 | "errorType": "additionalProperties", 9 | }, 10 | "message": "'other' property is not expected to be here", 11 | "path": "request.path", 12 | }, 13 | Object { 14 | "context": Object { 15 | "errorType": "additionalProperties", 16 | }, 17 | "message": "'other' property is not expected to be here", 18 | "path": "request.query", 19 | }, 20 | ], 21 | "ok": false, 22 | } 23 | `; 24 | 25 | exports[`Reva .validate() invalid requests should handle invalid body 1`] = ` 26 | Object { 27 | "errors": Array [ 28 | Object { 29 | "context": Object { 30 | "errorType": "required", 31 | }, 32 | "message": "request.body must have required property 'foo'", 33 | "path": "request.body", 34 | }, 35 | Object { 36 | "context": Object { 37 | "errorType": "additionalProperties", 38 | }, 39 | "message": "'bar' property is not expected to be here", 40 | "path": "request.body", 41 | }, 42 | ], 43 | "ok": false, 44 | } 45 | `; 46 | 47 | exports[`Reva .validate() invalid requests should handle invalid readonly props 1`] = ` 48 | Object { 49 | "errors": Array [ 50 | Object { 51 | "context": Object { 52 | "errorType": "additionalProperties", 53 | }, 54 | "message": "'world' property is not expected to be here", 55 | "path": "request.body.hello", 56 | }, 57 | ], 58 | "ok": false, 59 | } 60 | `; 61 | 62 | exports[`Reva .validate() invalid requests should handle missing required parameters 1`] = ` 63 | Object { 64 | "errors": Array [ 65 | Object { 66 | "context": Object { 67 | "errorType": "required", 68 | }, 69 | "message": "request.header must have required property 'x-foo'", 70 | "path": "request.header", 71 | }, 72 | Object { 73 | "context": Object { 74 | "errorType": "required", 75 | }, 76 | "message": "request.path must have required property 'id'", 77 | "path": "request.path", 78 | }, 79 | Object { 80 | "context": Object { 81 | "errorType": "required", 82 | }, 83 | "message": "request.query must have required property 'foo'", 84 | "path": "request.query", 85 | }, 86 | Object { 87 | "context": Object { 88 | "errorType": "required", 89 | }, 90 | "message": "request.cookie must have required property 'Foo'", 91 | "path": "request.cookie", 92 | }, 93 | ], 94 | "ok": false, 95 | } 96 | `; 97 | 98 | exports[`Reva .validate() invalid requests should handle unsupported Content-Type 1`] = ` 99 | Object { 100 | "errors": Array [ 101 | Object { 102 | "context": Object { 103 | "errorType": "contentType", 104 | "supportedContentTypes": Array [ 105 | "x-www-form-urlencoded", 106 | ], 107 | }, 108 | "message": "\\"application/json\\" Content-Type is not supported.", 109 | "path": "request.header.Content-Type", 110 | }, 111 | ], 112 | "ok": false, 113 | } 114 | `; 115 | 116 | exports[`Reva .validate() options allowAdditionalParameters list of parameter types should allow additional parameters of those types 1`] = ` 117 | Object { 118 | "errors": Array [ 119 | Object { 120 | "context": Object { 121 | "errorType": "additionalProperties", 122 | }, 123 | "message": "'other' property is not expected to be here", 124 | "path": "request.path", 125 | }, 126 | ], 127 | "ok": false, 128 | } 129 | `; 130 | 131 | exports[`Reva .validate() options groupedParameters should group parameters for validation 1`] = ` 132 | Object { 133 | "errors": Array [ 134 | Object { 135 | "context": Object { 136 | "errorType": "additionalProperties", 137 | }, 138 | "message": "'blah' property is not expected to be here", 139 | "path": "request.parameters", 140 | }, 141 | ], 142 | "ok": false, 143 | } 144 | `; 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm (scoped)](https://img.shields.io/npm/v/@apideck/reva?color=brightgreen)](https://npmjs.com/@apideck/reva) [![npm](https://img.shields.io/npm/dm/@apideck/reva)](https://npmjs.com/@apideck/reva) [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/apideck-libraries/reva/main.yml?branch=main)](https://github.com/apideck-libraries/reva/actions/workflows/main.yml?query=branch%3Amain++) 2 | 3 | # @apideck/reva 🕵 4 | 5 | > Server-side **re**quest **va**lidator for Node.js based on OpenAPI 6 | 7 | - Supports all OpenAPI parameters 8 | - Based on [AJV](https://github.com/ajv-validator/ajv) 9 | - Readable and helpful errors (by [@apideck/better-ajv-errors](https://github.com/apideck-libraries/better-ajv-errors)) 10 | - High quality TypeScript definitions included 11 | - Minimal footprint: 42 kB including AJV (gzip + minified) 12 | 13 | ## Install 14 | 15 | ```bash 16 | $ yarn add @apideck/reva 17 | ``` 18 | 19 | or 20 | 21 | ```bash 22 | $ npm i @apideck/reva 23 | ``` 24 | 25 | ## Usage 26 | 27 | Create a Reva instance and call the `validate` method with your [OpenAPI operation](https://spec.openapis.org/oas/v3.1.0#operation-object) and your request data. 28 | 29 | ```ts 30 | import { Reva } from '@apideck/reva'; 31 | 32 | const reva = new Reva(); 33 | 34 | const result = reva.validate({ 35 | operation, // OpenAPI operation 36 | request: { 37 | headers: { 'X-My-Header': 'value', Cookie: 'Key=Value' }, 38 | pathParameters: { id: 'ed55e7a3' }, 39 | queryParameters: { order_by: 'created' }, 40 | body: { name: 'Jane Doe' }, 41 | }, 42 | }); 43 | 44 | if (result.ok) { 45 | // Valid request! 46 | } else { 47 | // Invalid request, result.errors contains validation errors 48 | console.log(result.errors); 49 | // { 50 | // "ok": false, 51 | // "errors": [ 52 | // { 53 | // "path": "request.query", 54 | // "message": "'order_by' property must be equal to one of the allowed values", 55 | // "suggestion": "Did you mean 'created_at'?", 56 | // "context": { "errorType": "enum", "allowedValues": ["created_at", "updated_at"] } 57 | // }, 58 | // { 59 | // "path": "request.header", 60 | // "message": "request.header must have required property 'x-required-header'", 61 | // "context": { "errorType": "required" } 62 | // }, 63 | // { 64 | // "path": "request.body", 65 | // "message": "'name' property is not expected to be here", 66 | // "context": { "errorType": "additionalProperties" } 67 | // } 68 | // ] 69 | // } 70 | 71 | } 72 | ``` 73 | 74 | ## API 75 | 76 | ### Reva 77 | 78 | Reva is the main **Re**quest **va**lidation class. You can optionally pass options to the constructor. 79 | 80 | #### new Reva(options?: RevaOptions) 81 | 82 | **Parameters** 83 | 84 | - `options: RevaOptions` 85 | - `allowAdditionalParameters?: true | OpenApiParameterType[]` Allow additional parameters to be passed that are not defined in the OpenAPI operation. Use `true` to allow all parameter types to have additional parameters. Default value: `['header', 'cookie']` 86 | - `partialBody?: boolean` Ignore required properties on the requestBody. This option is useful for update endpoints where a subset of required properties is allowed. Default value: `false` 87 | - `groupedParameters?: OpenApiParameterType[]` Validate multiple OpenAPI parameter types as one schema. This is useful for APIs where parameters (`query`,`path`, etc) are combined into a single `parameters` object. Default value: `[]` 88 | - `paramAjvOptions?: AjvOptions` Custom AJV options for request param validation. 89 | - `bodyAjvOptions?: AjvOptions` Custom AJV options for request body validation. 90 | 91 | #### reva.validate(options: RevaValidateOptions) 92 | 93 | Validate requests based on OpenAPI. Parameter validation uses [type coercion](https://ajv.js.org/coercion.html), request body validation does not. When a Content-Type header is passed, it has to match a Content-Type defined in the OpenAPI operation. Default Content-Type is `application/json`. 94 | 95 | **Parameters** 96 | 97 | - `options: RevaValidateOptions` 98 | - `operation: OpenApiOperation` Your OpenAPI operation object to validate against 99 | - `request: RevaRequest` The request data to validate. All properties are optional 100 | - `queryParameters?: Record` Query parameters to validate 101 | - `headers?: Record` Headers to validate 102 | - `pathParameters?: Record` Path parameters to validate 103 | - `body?: unknown` Request body to validate 104 | - `options?: RevaOptions` Override options set in the Reva constructor 105 | 106 | **Return Value** 107 | 108 | - `Result` 109 | - `ok: boolean` Indicates if the request is valid or not 110 | - `errors?: ValidationError[]` Array of formatted errors. Only populated when `Result.ok` is `false` 111 | - `message: string` Formatted error message 112 | - `suggestion?: string` Optional suggestion based on provided data and schema 113 | - `path: string` Object path where the error occurred (example: `.foo.bar.0.quz`) 114 | - `context: { errorType: DefinedError['keyword']; [additionalContext: string]: unknown }` `errorType` is `error.keyword` proxied from `ajv`. `errorType` can be used as a key for i18n if needed. There might be additional properties on context, based on the type of error. 115 | -------------------------------------------------------------------------------- /src/Reva.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { Options as _AjvOptions } from 'ajv'; 2 | import type { OpenAPIV3 } from 'openapi-types'; 3 | import { betterAjvErrors, ValidationError } from '@apideck/better-ajv-errors'; 4 | import { 5 | isPresent, 6 | lowercaseKeys, 7 | parseContentType, 8 | parseCookies, 9 | removeReadOnlyProperties, 10 | tryJsonParse, 11 | } from './utils'; 12 | 13 | export type AjvOptions = _AjvOptions; 14 | 15 | type Result = { ok: true } | { ok: false; errors: E[] }; 16 | type ParameterDictionary = Record; 17 | type SchemaByType = Record; 18 | 19 | const OPENAPI_PARAMETER_TYPES = ['header', 'path', 'query', 'cookie'] as const; 20 | type OpenApiParameterType = typeof OPENAPI_PARAMETER_TYPES[number]; 21 | 22 | export interface RevaRequest { 23 | queryParameters?: ParameterDictionary; 24 | pathParameters?: ParameterDictionary; 25 | headers?: ParameterDictionary; 26 | body?: unknown; 27 | } 28 | 29 | interface OpenApiOperation { 30 | parameters?: OpenAPIV3.ParameterObject[]; 31 | requestBody?: OpenAPIV3.RequestBodyObject; 32 | [key: string]: unknown; 33 | } 34 | 35 | export interface RevaOptions { 36 | allowAdditionalParameters: true | OpenApiParameterType[]; 37 | partialBody: boolean; 38 | groupedParameters: OpenApiParameterType[]; 39 | paramAjvOptions?: AjvOptions; 40 | bodyAjvOptions?: AjvOptions; 41 | } 42 | 43 | export interface RevaValidateOptions { 44 | operation: OpenApiOperation; 45 | request: RevaRequest; 46 | options?: Partial; 47 | } 48 | 49 | export const revaDefaultParamAjvOptions = { 50 | allErrors: true, 51 | coerceTypes: true, 52 | strict: false, 53 | }; 54 | export const revaDefaultBodyAjvOptions = { allErrors: true, strict: false }; 55 | 56 | export class Reva { 57 | private paramAjv: Ajv; 58 | private bodyAjv: Ajv; 59 | private options: RevaOptions = { 60 | allowAdditionalParameters: ['header', 'cookie'], 61 | groupedParameters: [], 62 | partialBody: false, 63 | }; 64 | 65 | constructor(options: Partial = {}) { 66 | this.options = { ...this.options, ...options }; 67 | this.paramAjv = new Ajv({ 68 | ...revaDefaultParamAjvOptions, 69 | ...options.paramAjvOptions, 70 | }); 71 | this.bodyAjv = new Ajv({ 72 | ...revaDefaultBodyAjvOptions, 73 | ...options.bodyAjvOptions, 74 | }); 75 | } 76 | 77 | validate({ 78 | operation, 79 | request: { body, headers, pathParameters, queryParameters }, 80 | options: validateOptions = {}, 81 | }: RevaValidateOptions): Result { 82 | const options = { ...this.options, ...validateOptions }; 83 | const errors: ValidationError[] = []; 84 | 85 | const schemaByType = 86 | operation.parameters 87 | ?.filter((param): param is OpenAPIV3.ParameterObject => 'in' in param) 88 | .reduce((schemaMap, param) => { 89 | const schema = (param.schema ?? 90 | param.content?.['application/json'] 91 | ?.schema) as OpenAPIV3.SchemaObject; 92 | if (!schema) return schemaMap; 93 | 94 | if (!schemaMap[param.in]) { 95 | schemaMap[param.in] = { 96 | type: 'object', 97 | required: [], 98 | properties: {}, 99 | additionalProperties: 100 | options.allowAdditionalParameters === true || 101 | options.allowAdditionalParameters.includes( 102 | param.in as OpenApiParameterType 103 | ), // Additional headers are allowed, additional query/path params are not 104 | }; 105 | } 106 | 107 | const paramKey = 108 | param.in === 'header' ? param.name.toLocaleLowerCase() : param.name; 109 | // properties is always assigned above 110 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 111 | schemaMap[param.in].properties![paramKey] = schema; 112 | 113 | if (param.required) { 114 | schemaMap[param.in].required?.push(paramKey); 115 | } 116 | 117 | return schemaMap; 118 | }, {}) ?? {}; 119 | 120 | const safeHeaders = lowercaseKeys(headers ?? {}); 121 | 122 | const validationEntries = OPENAPI_PARAMETER_TYPES.map((type) => { 123 | const schema = schemaByType[type]; 124 | if (!schema) return null; 125 | 126 | let value: Record = {}; 127 | 128 | switch (type) { 129 | case 'header': 130 | value = Object.fromEntries( 131 | Object.entries(safeHeaders).map(([key, value]) => { 132 | const headerSchema = schema.properties?.[ 133 | key 134 | ] as OpenAPIV3.SchemaObject; 135 | if (headerSchema?.type === 'object') { 136 | return [key, tryJsonParse(value as string) ?? {}]; 137 | } 138 | return [key, value]; 139 | }) 140 | ); 141 | break; 142 | case 'path': 143 | value = pathParameters ?? {}; 144 | break; 145 | case 'query': 146 | value = queryParameters ?? {}; 147 | break; 148 | case 'cookie': 149 | value = parseCookies(safeHeaders.cookie as string) ?? {}; 150 | break; 151 | } 152 | 153 | return { schema, value, type }; 154 | }).filter(isPresent); 155 | 156 | if (options.groupedParameters.length > 0) { 157 | const groupedValidationEntry = validationEntries 158 | .filter(({ type }) => options.groupedParameters.includes(type)) 159 | .reduce( 160 | (acc, { value, schema }) => { 161 | acc.schema.required = [ 162 | ...(acc.schema.required ?? []), 163 | ...(schema.required ?? []), 164 | ]; 165 | acc.schema.properties = { 166 | ...acc.schema.properties, 167 | ...schema.properties, 168 | }; 169 | acc.value = { ...acc.value, ...value }; 170 | 171 | return acc; 172 | }, 173 | { 174 | value: {}, 175 | schema: { 176 | type: 'object', 177 | required: [], 178 | properties: {}, 179 | additionalProperties: options.allowAdditionalParameters === true, 180 | } as OpenAPIV3.SchemaObject, 181 | } 182 | ); 183 | 184 | const validParams = this.paramAjv.validate( 185 | groupedValidationEntry.schema, 186 | groupedValidationEntry.value 187 | ); 188 | 189 | if (!validParams) { 190 | errors.push( 191 | ...betterAjvErrors({ 192 | errors: this.paramAjv.errors, 193 | data: groupedValidationEntry.value, 194 | schema: groupedValidationEntry.schema as any, 195 | basePath: 'request.parameters', 196 | }) 197 | ); 198 | } 199 | } 200 | 201 | for (const { schema, value, type } of validationEntries.filter( 202 | ({ type }) => !options.groupedParameters.includes(type) 203 | )) { 204 | const validParams = this.paramAjv.validate(schema, value); 205 | 206 | if (!validParams) { 207 | errors.push( 208 | ...betterAjvErrors({ 209 | errors: this.paramAjv.errors, 210 | data: value, 211 | schema: schema as any, 212 | basePath: `request.${type}`, 213 | }) 214 | ); 215 | } 216 | } 217 | 218 | const requestBody = operation.requestBody as 219 | | OpenAPIV3.RequestBodyObject 220 | | undefined; 221 | 222 | if (requestBody) { 223 | const contentType = parseContentType( 224 | (safeHeaders['content-type'] as string) ?? 'application/json' 225 | ); 226 | const supportedContentTypes = Object.keys(requestBody?.content ?? {}); 227 | const invalidContentType = 228 | !supportedContentTypes.includes('*/*') && 229 | !supportedContentTypes.includes(contentType.mediaType); 230 | 231 | if (invalidContentType) { 232 | errors.push({ 233 | context: { 234 | errorType: 'contentType', 235 | supportedContentTypes, 236 | } as any, 237 | path: 'request.header.Content-Type', 238 | message: `"${contentType.mediaType}" Content-Type is not supported.`, 239 | }); 240 | } else { 241 | const mediaTypeObject = 242 | requestBody?.content?.[contentType.mediaType] ?? 243 | requestBody?.content?.['*/*']; 244 | const requestBodySchema = mediaTypeObject?.schema as 245 | | OpenAPIV3.SchemaObject 246 | | undefined; 247 | 248 | if (requestBodySchema) { 249 | const cleanRequestBodySchema = removeReadOnlyProperties( 250 | requestBodySchema as OpenAPIV3.SchemaObject 251 | ); 252 | if (options.partialBody) { 253 | delete cleanRequestBodySchema.required; 254 | } 255 | 256 | const validBody = this.bodyAjv.validate( 257 | cleanRequestBodySchema, 258 | body ?? {} 259 | ); 260 | 261 | if (!validBody) { 262 | errors.push( 263 | ...betterAjvErrors({ 264 | data: body, 265 | schema: cleanRequestBodySchema as any, 266 | errors: this.bodyAjv.errors, 267 | basePath: 'request.body', 268 | }) 269 | ); 270 | } 271 | } 272 | } 273 | } 274 | 275 | if (errors.length > 0) { 276 | return { ok: false, errors }; 277 | } 278 | 279 | return { ok: true }; 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Reva } from '.'; 2 | 3 | describe('Reva', () => { 4 | describe('.validate()', () => { 5 | describe('valid requests', () => { 6 | it('should validate queryParameters (coerce types)', () => { 7 | const reva = new Reva(); 8 | const result = reva.validate({ 9 | operation: { 10 | parameters: [ 11 | { 12 | in: 'query', 13 | name: 'foo', 14 | required: true, 15 | schema: { type: 'string' }, 16 | }, 17 | { 18 | in: 'query', 19 | name: 'bar', 20 | required: true, 21 | schema: { type: 'number' }, 22 | }, 23 | ], 24 | }, 25 | request: { 26 | queryParameters: { 27 | foo: 'foo', 28 | bar: '5', 29 | }, 30 | }, 31 | }); 32 | expect(result.ok).toBe(true); 33 | }); 34 | 35 | it('should validate headers (case-insensitive + content schema)', () => { 36 | const reva = new Reva(); 37 | const result = reva.validate({ 38 | operation: { 39 | parameters: [ 40 | { 41 | in: 'header', 42 | name: 'X-My-Header', 43 | required: true, 44 | schema: { type: 'number' }, 45 | }, 46 | { 47 | in: 'header', 48 | name: 'X-JSON-Header', 49 | required: true, 50 | content: { 51 | 'application/json': { 52 | schema: { 53 | type: 'object', 54 | required: ['foo'], 55 | additionalProperties: false, 56 | properties: { foo: { type: 'boolean' } }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | ], 62 | }, 63 | request: { 64 | headers: { 65 | 'x-my-header': '53032', 66 | 'x-json-header': '{"foo":true}', 67 | }, 68 | }, 69 | }); 70 | expect(result.ok).toBe(true); 71 | }); 72 | 73 | it('should validate pathParameters', () => { 74 | const reva = new Reva(); 75 | const result = reva.validate({ 76 | operation: { 77 | parameters: [ 78 | { 79 | in: 'path', 80 | name: 'id', 81 | required: true, 82 | schema: { type: 'number' }, 83 | }, 84 | ], 85 | }, 86 | request: { 87 | pathParameters: { id: '123' }, 88 | }, 89 | }); 90 | 91 | expect(result.ok).toBe(true); 92 | }); 93 | 94 | it('should validate body', () => { 95 | const reva = new Reva(); 96 | const result = reva.validate({ 97 | operation: { 98 | requestBody: { 99 | content: { 100 | 'application/json': { 101 | schema: { 102 | type: 'object', 103 | required: ['foo', 'id'], 104 | additionalProperties: false, 105 | properties: { 106 | foo: { type: 'boolean' }, 107 | bar: { type: 'string' }, 108 | id: { type: 'string', readOnly: true }, 109 | }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | request: { 116 | body: { 117 | foo: true, 118 | bar: 'value', 119 | }, 120 | }, 121 | }); 122 | 123 | expect(result.ok).toBe(true); 124 | }); 125 | 126 | it('should validate a full request', () => { 127 | const reva = new Reva(); 128 | const result = reva.validate({ 129 | operation: { 130 | parameters: [ 131 | { 132 | in: 'path', 133 | name: 'id', 134 | required: true, 135 | schema: { type: 'number' }, 136 | }, 137 | { 138 | in: 'header', 139 | name: 'X-My-Header', 140 | required: true, 141 | schema: { type: 'number' }, 142 | }, 143 | { 144 | in: 'cookie', 145 | name: 'Key', 146 | required: true, 147 | schema: { type: 'boolean' }, 148 | }, 149 | { 150 | in: 'query', 151 | name: 'search', 152 | required: true, 153 | schema: { type: 'string' }, 154 | }, 155 | ], 156 | requestBody: { 157 | content: { 158 | 'application/json': { 159 | schema: { 160 | type: 'object', 161 | required: ['foo', 'id'], 162 | additionalProperties: false, 163 | properties: { 164 | foo: { type: 'boolean' }, 165 | bar: { type: 'string' }, 166 | id: { type: 'string', readOnly: true }, 167 | }, 168 | }, 169 | }, 170 | }, 171 | }, 172 | }, 173 | request: { 174 | headers: { 175 | 'x-my-header': '323', 176 | cookie: 'Key=true', 177 | }, 178 | pathParameters: { id: '123' }, 179 | queryParameters: { search: 'foo' }, 180 | body: { 181 | foo: true, 182 | bar: 'value', 183 | }, 184 | }, 185 | }); 186 | 187 | expect(result.ok).toBe(true); 188 | }); 189 | 190 | it('should handle wildcard Content-Type', () => { 191 | const reva = new Reva(); 192 | const result = reva.validate({ 193 | operation: { 194 | requestBody: { 195 | content: { 196 | '*/*': { 197 | schema: { 198 | type: 'object', 199 | properties: { 200 | foo: { 201 | type: 'string', 202 | }, 203 | }, 204 | }, 205 | }, 206 | }, 207 | }, 208 | }, 209 | request: { 210 | headers: { 211 | 'Content-Type': 'anything', 212 | }, 213 | body: {}, 214 | }, 215 | }); 216 | 217 | expect(result.ok).toBe(true); 218 | }); 219 | }); 220 | 221 | describe('invalid requests', () => { 222 | it('should handle missing required parameters', () => { 223 | const reva = new Reva(); 224 | const result = reva.validate({ 225 | operation: { 226 | parameters: [ 227 | { 228 | in: 'query', 229 | name: 'foo', 230 | required: true, 231 | schema: { type: 'string' }, 232 | }, 233 | { 234 | in: 'header', 235 | name: 'X-Foo', 236 | required: true, 237 | schema: { type: 'string' }, 238 | }, 239 | { 240 | in: 'cookie', 241 | name: 'Foo', 242 | required: true, 243 | schema: { type: 'string' }, 244 | }, 245 | { 246 | in: 'path', 247 | name: 'id', 248 | required: true, 249 | schema: { type: 'string' }, 250 | }, 251 | ], 252 | }, 253 | request: { 254 | headers: {}, 255 | body: {}, 256 | pathParameters: {}, 257 | queryParameters: {}, 258 | }, 259 | }); 260 | 261 | expect(result.ok).toBe(false); 262 | expect(result).toMatchSnapshot(); 263 | }); 264 | 265 | it('should handle additional parameters (query & path)', () => { 266 | const reva = new Reva(); 267 | const result = reva.validate({ 268 | operation: { 269 | parameters: [ 270 | { 271 | in: 'query', 272 | name: 'foo', 273 | required: false, 274 | schema: { type: 'string' }, 275 | }, 276 | { 277 | in: 'header', 278 | name: 'X-Foo', 279 | required: false, 280 | schema: { type: 'string' }, 281 | }, 282 | { 283 | in: 'cookie', 284 | name: 'Foo', 285 | required: false, 286 | schema: { type: 'string' }, 287 | }, 288 | { 289 | in: 'path', 290 | name: 'id', 291 | required: true, 292 | schema: { type: 'string' }, 293 | }, 294 | ], 295 | }, 296 | request: { 297 | headers: { 298 | other: '2', 299 | Cookie: 'Other=2', 300 | }, 301 | body: {}, 302 | pathParameters: { 303 | id: '123', 304 | other: '2', 305 | }, 306 | queryParameters: { 307 | other: '2', 308 | }, 309 | }, 310 | }); 311 | 312 | expect(result.ok).toBe(false); 313 | expect(result).toMatchSnapshot(); 314 | }); 315 | 316 | it('should handle unsupported Content-Type', () => { 317 | const reva = new Reva(); 318 | const result = reva.validate({ 319 | operation: { 320 | requestBody: { 321 | content: { 322 | 'x-www-form-urlencoded': { 323 | schema: { 324 | type: 'object', 325 | properties: { 326 | foo: { 327 | type: 'string', 328 | }, 329 | }, 330 | }, 331 | }, 332 | }, 333 | }, 334 | }, 335 | request: { 336 | headers: { 337 | 'Content-Type': 'application/json', 338 | }, 339 | body: {}, 340 | }, 341 | }); 342 | 343 | expect(result.ok).toBe(false); 344 | expect(result).toMatchSnapshot(); 345 | }); 346 | 347 | it('should handle invalid body', () => { 348 | const reva = new Reva(); 349 | const result = reva.validate({ 350 | operation: { 351 | requestBody: { 352 | content: { 353 | 'application/json': { 354 | schema: { 355 | type: 'object', 356 | required: ['foo'], 357 | additionalProperties: false, 358 | properties: { 359 | foo: { 360 | type: 'string', 361 | }, 362 | }, 363 | }, 364 | }, 365 | }, 366 | }, 367 | }, 368 | request: { 369 | headers: { 370 | 'Content-Type': 'application/json', 371 | }, 372 | body: { 373 | bar: 'value', 374 | }, 375 | }, 376 | }); 377 | 378 | expect(result.ok).toBe(false); 379 | expect(result).toMatchSnapshot(); 380 | }); 381 | 382 | it('should handle invalid readonly props', () => { 383 | const reva = new Reva(); 384 | const result = reva.validate({ 385 | operation: { 386 | requestBody: { 387 | content: { 388 | 'application/json': { 389 | schema: { 390 | type: 'object', 391 | required: ['foo'], 392 | additionalProperties: false, 393 | properties: { 394 | foo: { 395 | type: 'string', 396 | }, 397 | hello: { 398 | type: 'object', 399 | additionalProperties: false, 400 | properties: { 401 | world: { 402 | type: 'string', 403 | readOnly: true, 404 | }, 405 | }, 406 | }, 407 | }, 408 | }, 409 | }, 410 | }, 411 | }, 412 | }, 413 | request: { 414 | headers: { 415 | 'Content-Type': 'application/json', 416 | }, 417 | body: { 418 | foo: 'value', 419 | hello: { 420 | world: 'value', 421 | }, 422 | }, 423 | }, 424 | }); 425 | expect(result.ok).toBe(false); 426 | expect(result).toMatchSnapshot(); 427 | }); 428 | }); 429 | 430 | describe('options', () => { 431 | describe('partialBody', () => { 432 | it('should allow missing required properties', () => { 433 | const reva = new Reva({ partialBody: true }); 434 | const result = reva.validate({ 435 | operation: { 436 | requestBody: { 437 | content: { 438 | 'application/json': { 439 | schema: { 440 | type: 'object', 441 | required: ['foo'], 442 | properties: { 443 | foo: { 444 | type: 'string', 445 | }, 446 | bar: { 447 | type: 'string', 448 | }, 449 | }, 450 | }, 451 | }, 452 | }, 453 | }, 454 | }, 455 | request: { 456 | headers: { 457 | 'Content-Type': 'application/json', 458 | }, 459 | body: { bar: 'value' }, 460 | }, 461 | }); 462 | 463 | expect(result.ok).toBe(true); 464 | }); 465 | }); 466 | 467 | describe('allowAdditionalParameters', () => { 468 | describe('true', () => { 469 | it('should allow additional parameters (all types)', () => { 470 | const reva = new Reva(); 471 | const result = reva.validate({ 472 | options: { allowAdditionalParameters: true }, 473 | operation: { 474 | parameters: [ 475 | { 476 | in: 'path', 477 | name: 'id', 478 | required: true, 479 | schema: { type: 'number' }, 480 | }, 481 | { 482 | in: 'header', 483 | name: 'X-My-Header', 484 | required: false, 485 | schema: { type: 'number' }, 486 | }, 487 | { 488 | in: 'cookie', 489 | name: 'Key', 490 | required: false, 491 | schema: { type: 'boolean' }, 492 | }, 493 | { 494 | in: 'query', 495 | name: 'search', 496 | required: false, 497 | schema: { type: 'string' }, 498 | }, 499 | ], 500 | }, 501 | request: { 502 | headers: { 503 | 'Content-Type': 'application/json', 504 | Foo: 'bar', 505 | Cookie: 'Foo=bar', 506 | }, 507 | pathParameters: { 508 | id: '123', 509 | other: '2', 510 | }, 511 | queryParameters: { 512 | other: '2', 513 | }, 514 | body: { bar: 'value' }, 515 | }, 516 | }); 517 | 518 | expect(result.ok).toBe(true); 519 | }); 520 | }); 521 | 522 | describe('list of parameter types', () => { 523 | it('should allow additional parameters of those types', () => { 524 | const reva = new Reva({ 525 | allowAdditionalParameters: ['cookie', 'header', 'query'], 526 | }); 527 | const result = reva.validate({ 528 | operation: { 529 | parameters: [ 530 | { 531 | in: 'path', 532 | name: 'id', 533 | required: true, 534 | schema: { type: 'number' }, 535 | }, 536 | { 537 | in: 'header', 538 | name: 'X-My-Header', 539 | required: false, 540 | schema: { type: 'number' }, 541 | }, 542 | { 543 | in: 'cookie', 544 | name: 'Key', 545 | required: false, 546 | schema: { type: 'boolean' }, 547 | }, 548 | { 549 | in: 'query', 550 | name: 'search', 551 | required: false, 552 | schema: { type: 'string' }, 553 | }, 554 | ], 555 | }, 556 | request: { 557 | headers: { 558 | 'Content-Type': 'application/json', 559 | Foo: 'bar', 560 | Cookie: 'Foo=bar', 561 | }, 562 | pathParameters: { 563 | id: '123', 564 | other: '2', 565 | }, 566 | queryParameters: { 567 | other: '2', 568 | }, 569 | body: { bar: 'value' }, 570 | }, 571 | }); 572 | 573 | expect(result.ok).toBe(false); 574 | expect(result).toMatchSnapshot(); 575 | }); 576 | }); 577 | }); 578 | 579 | describe('groupedParameters', () => { 580 | // Usecase: sometimes parameters of different types are combined into a parameters object, for example GraphQL 581 | it('should group parameters for validation', () => { 582 | const reva = new Reva(); 583 | const result = reva.validate({ 584 | options: { groupedParameters: ['query', 'path'] }, 585 | operation: { 586 | parameters: [ 587 | { 588 | in: 'path', 589 | name: 'id', 590 | required: true, 591 | schema: { type: 'number' }, 592 | }, 593 | { 594 | in: 'header', 595 | name: 'X-My-Header', 596 | required: false, 597 | schema: { type: 'number' }, 598 | }, 599 | { 600 | in: 'cookie', 601 | name: 'Key', 602 | required: false, 603 | schema: { type: 'boolean' }, 604 | }, 605 | { 606 | in: 'query', 607 | name: 'search', 608 | required: true, 609 | schema: { type: 'string' }, 610 | }, 611 | ], 612 | }, 613 | request: { 614 | headers: { 615 | 'Content-Type': 'application/json', 616 | Foo: 'bar', 617 | Cookie: 'Foo=bar', 618 | }, 619 | queryParameters: { 620 | id: '123', 621 | search: 'foo', 622 | blah: 'foo', 623 | }, 624 | body: { bar: 'value' }, 625 | }, 626 | }); 627 | expect(result.ok).toBe(false); 628 | expect(result).toMatchSnapshot(); 629 | }); 630 | }); 631 | }); 632 | }); 633 | }); 634 | --------------------------------------------------------------------------------