├── .npmrc ├── .eslintignore ├── src ├── walker │ ├── index.ts │ └── types.ts ├── index.ts ├── types │ └── index.ts ├── rules │ ├── only-valid-mime-types │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── required-tag-description │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── no-external-refs │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── no-single-allof │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── object-prop-casing │ │ ├── readme.md │ │ └── index.ts │ ├── required-parameter-description │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── no-trailing-slash │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── no-ref-properties │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── no-empty-object-type │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── path-param-required-field │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── parameter-casing │ │ ├── readme.md │ │ └── index.ts │ ├── no-inline-enums │ │ ├── index.ts │ │ ├── readme.md │ │ └── spec │ │ │ └── index.spec.ts │ ├── latin-definitions-only │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── expressive-path-summary │ │ ├── readme.md │ │ ├── index.ts │ │ └── spec │ │ │ └── index.spec.ts │ ├── required-operation-tags │ │ ├── index.ts │ │ ├── readme.md │ │ └── spec │ │ │ └── index.spec.ts │ └── index.ts ├── utils │ ├── validate-json.ts │ ├── tests.ts │ ├── swagger.ts │ ├── common.ts │ ├── swaggerfile.ts │ ├── openapi.ts │ ├── output.ts │ ├── index.ts │ ├── spec │ │ └── config.spec.ts │ └── config.ts ├── defaultConfig.ts ├── bin.ts ├── cli.ts ├── spec │ ├── swaggerlint.spec.ts │ └── cli.spec.ts ├── ruleTester.ts └── swaggerlint.ts ├── jest.config.js ├── prettier.config.js ├── .gitignore ├── .npmignore ├── scripts ├── update-all-files.ts ├── update-rules.ts ├── update-readme.ts └── update-types.ts ├── .github └── workflows │ └── nodejs.yaml ├── tsconfig.json ├── CHANGELOG.md ├── docs ├── how-to-test-a-rule.md └── how-to-write-a-rule.md ├── .eslintrc.js ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /src/walker/index.ts: -------------------------------------------------------------------------------- 1 | export * from './walkOpenAPI'; 2 | export * from './walkSwagger'; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {swaggerlint} from './swaggerlint'; 2 | export {RuleTester} from './ruleTester'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './swaggerlint'; 2 | import * as Swagger from './swagger'; 3 | import * as OpenAPI from './openapi'; 4 | 5 | export {Swagger, OpenAPI}; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | preset: 'ts-jest', 4 | moduleNameMapper: { 5 | '^@/(.*)$': '/src/$1', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSpacing: false, 4 | singleQuote: true, 5 | tabWidth: 4, 6 | trailingComma: 'all', 7 | }; 8 | -------------------------------------------------------------------------------- /src/walker/types.ts: -------------------------------------------------------------------------------- 1 | import {LintError} from '../types'; 2 | 3 | export type WalkerResult = 4 | | { 5 | visitors: T; 6 | } 7 | | { 8 | errors: LintError[]; 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Coverage reports 17 | coverage 18 | 19 | # API keys and secrets 20 | .env 21 | 22 | # Dependency directory 23 | node_modules 24 | 25 | # Editors 26 | *.swp 27 | *.swo 28 | 29 | # OS metadata 30 | .DS_Store 31 | Thumbs.db 32 | 33 | # Ignore built ts files 34 | dist/**/* 35 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .github 3 | 4 | # for npm 5 | node_modules/ 6 | npm-shrinkwrap.json 7 | npm-debug.log 8 | 9 | .tscache/ 10 | 11 | # for https://github.com/Microsoft/TypeScript/issues/4667 12 | lib/**/*.ts 13 | !lib/**/*.d.ts 14 | 15 | # misc 16 | example/ 17 | .gitignore 18 | .editorconfig 19 | .eslintignore 20 | 21 | src/**/* 22 | test 23 | **/spec 24 | .eslintrc.js 25 | prettier.config.js 26 | jest.config.js 27 | **/*.md 28 | -------------------------------------------------------------------------------- /src/rules/only-valid-mime-types/readme.md: -------------------------------------------------------------------------------- 1 | # only-valid-mime-types 2 | 3 | By default this rule is checking the mime types specified in `consumes` and `produces` properties in `SwaggerObject` and `OperationObject`. Verification happens against the [`mime-db`](https://npmjs.org/package/mime-db). 4 | 5 | ```js 6 | // swaggerlint.config.js 7 | module.exports = { 8 | rules: { 9 | 'only-valid-mime-types': true, 10 | }, 11 | }; 12 | ``` 13 | -------------------------------------------------------------------------------- /src/utils/validate-json.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import type {JSONSchema7} from 'json-schema'; 3 | 4 | type JSONValue = object | boolean | string | null | Array; 5 | 6 | export const validate = (schema: JSONSchema7, json: JSONValue) => { 7 | const ajv = new Ajv({allErrors: true, verbose: true}); 8 | const validator = ajv.compile(schema); 9 | 10 | validator(json); 11 | 12 | return validator.errors || []; 13 | }; 14 | -------------------------------------------------------------------------------- /scripts/update-all-files.ts: -------------------------------------------------------------------------------- 1 | import {updateReadme} from './update-readme'; 2 | import {updateRules} from './update-rules'; 3 | import {updateTypes} from './update-types'; 4 | 5 | async function main() { 6 | const callbacks = [updateReadme, updateRules, updateTypes]; 7 | 8 | for (let i = 0; i < callbacks.length; i++) { 9 | await callbacks[i](); 10 | } 11 | } 12 | 13 | main().catch(e => { 14 | process.stderr.write(`Unexpected error during content generation:\n${e}`); 15 | process.exit(1); 16 | }); 17 | -------------------------------------------------------------------------------- /src/rules/required-tag-description/readme.md: -------------------------------------------------------------------------------- 1 | # required-tag-description 2 | 3 | This rule validates that all `TagObject`s have description. 4 | 5 | ## Examples of valid OpenAPI TagObject 6 | 7 | ```yaml 8 | tags: 9 | - name: stocks 10 | description: "all operations associated in working with stocks" # <-- valid 11 | ``` 12 | 13 | ## Examples of invalid OpenAPI TagObject 14 | 15 | ```yaml 16 | tags: 17 | - name: stocks # <-- invalid, no description specified 18 | - name: billing 19 | description: "" # <-- invalid, cannot be empty 20 | ``` 21 | -------------------------------------------------------------------------------- /src/rules/no-external-refs/readme.md: -------------------------------------------------------------------------------- 1 | # no-external-refs 2 | 3 | This rule bans the use of `ReferenceObjects` referring to external files. 4 | 5 | This rule only applies to OpenAPI. 6 | 7 | ## Examples of valid and invalid OpenAPI ReferenceObjects 8 | 9 | ```yaml 10 | components: 11 | schemas: 12 | Example: 13 | type: 'object' 14 | properties: 15 | foo: 16 | $ref: '#/components/schemas/Foo' # <-- valid 17 | bar: 18 | $ref: 'schemas.yaml#/Bar' # <-- invalid 19 | ``` 20 | -------------------------------------------------------------------------------- /src/rules/no-external-refs/index.ts: -------------------------------------------------------------------------------- 1 | import {createRule} from '../../utils'; 2 | 3 | const name = 'no-external-refs'; 4 | 5 | const rule = createRule({ 6 | name, 7 | docs: { 8 | recommended: false, 9 | description: 'Forbids the usage of external ReferenceObjects', 10 | }, 11 | meta: { 12 | messages: { 13 | msg: 'External references are banned.', 14 | }, 15 | }, 16 | openapiVisitor: { 17 | ReferenceObject: ({node, report}): void => { 18 | if (!node.$ref.startsWith('#')) { 19 | report({messageId: 'msg'}); 20 | } 21 | }, 22 | }, 23 | }); 24 | 25 | export default rule; 26 | -------------------------------------------------------------------------------- /src/defaultConfig.ts: -------------------------------------------------------------------------------- 1 | import {SwaggerlintConfig} from './types'; 2 | 3 | const config: SwaggerlintConfig = { 4 | rules: { 5 | 'expressive-path-summary': true, 6 | 'latin-definitions-only': ['', {ignore: []}], 7 | 'no-empty-object-type': true, 8 | 'no-external-refs': false, 9 | 'no-inline-enums': true, 10 | 'no-single-allof': true, 11 | 'no-trailing-slash': true, 12 | 'object-prop-casing': ['camel'], 13 | 'only-valid-mime-types': true, 14 | 'parameter-casing': ['camel', {header: 'kebab'}], 15 | 'required-operation-tags': true, 16 | 'required-parameter-description': true, 17 | 'required-tag-description': true, 18 | }, 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: install dependencies 21 | run: npm ci 22 | - name: build 23 | run: npm run build 24 | - name: check types 25 | run: npm run types 26 | - name: linting 27 | run: npm run lint 28 | - name: unit tests 29 | run: npm run test 30 | - name: generated files 31 | run: npm run update-all-files 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "alwaysStrict": true, 5 | "esModuleInterop": true, 6 | "resolveJsonModule": true, 7 | "lib": ["es2017"], 8 | "noFallthroughCasesInSwitch": true, 9 | "noUnusedParameters": false, 10 | "noErrorTruncation": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "declaration": true, 15 | 16 | "target": "es6", 17 | "module": "commonjs", 18 | "moduleResolution": "node", 19 | "rootDir": "./src", 20 | "outDir": "./dist", 21 | "baseUrl": "./src" 22 | }, 23 | "include": ["src/**/*.ts"], 24 | "exclude": ["src/**/spec", "./package.json"] 25 | } 26 | -------------------------------------------------------------------------------- /src/rules/no-single-allof/readme.md: -------------------------------------------------------------------------------- 1 | # no-single-allof 2 | 3 | This rule validates that no SchemaObject with allOf property contains a single SchemaObject. 4 | 5 | ## Examples of valid OpenAPI SchemaObject types 6 | 7 | ```yaml 8 | components: 9 | schemas: 10 | SomeApiResponse: 11 | type: "object" 12 | allOf: # <-- valid 13 | - $ref: "#/components/schemas/Foo" 14 | - $ref: "#/components/schemas/Bar" 15 | ``` 16 | 17 | ## Examples of invalid OpenAPI SchemaObject 18 | 19 | `SomeApiResponse` object can be avoided by referring to Foo instead 20 | 21 | ```yaml 22 | components: 23 | schemas: 24 | SomeApiResponse: 25 | type: "object" 26 | allOf: # <-- invalid 27 | - $ref: "#/components/schemas/Foo" 28 | ``` 29 | -------------------------------------------------------------------------------- /src/rules/object-prop-casing/readme.md: -------------------------------------------------------------------------------- 1 | # object-prop-casing rule 2 | 3 | By default this rule is checking for all properties of `SchemaObject` with `type: 'object'` to be `camel` cased. Optionally you can specify one of other supported casing names `camel | constant | snake | kebab | pascal`. 4 | 5 | ```js 6 | // swaggerlint.config.js 7 | module.exports = { 8 | rules: { 9 | 'object-prop-casing': ['snake'], 10 | }, 11 | }; 12 | ``` 13 | 14 | 15 | This rule can be configured to ignore specified property names. 16 | 17 | ```js 18 | // swaggerlint.config.js 19 | module.exports = { 20 | rules: { 21 | 'object-prop-casing': [ 22 | 'camel', 23 | { 24 | ignore: ['ODD', 'cAsinG'], // parameter names to ignore 25 | }, 26 | ], 27 | }, 28 | }; 29 | ``` 30 | -------------------------------------------------------------------------------- /src/rules/required-parameter-description/readme.md: -------------------------------------------------------------------------------- 1 | # required-parameter-description rule 2 | 3 | This rule checks for all parameters in `ParameterObject` to have `description`. 4 | 5 | ```js 6 | // swaggerlint.config.js 7 | module.exports = { 8 | rules: { 9 | 'required-parameter-description': true, 10 | }, 11 | }; 12 | ``` 13 | 14 | Examples of valid parameters in OperationObject 15 | 16 | ```yaml 17 | parameters: 18 | - name: "id" 19 | in: "query" 20 | description: "unique identifier" 21 | required: true 22 | type: "string" 23 | ``` 24 | 25 | Examples of **invalid** parameters in OperationObject 26 | 27 | ```yaml 28 | parameters: 29 | - name: "id" 30 | in: "query" 31 | required: true 32 | type: "string" 33 | - name: "type" 34 | in: "query" 35 | description: "" 36 | required: true 37 | type: "string" 38 | ``` 39 | -------------------------------------------------------------------------------- /src/rules/no-trailing-slash/readme.md: -------------------------------------------------------------------------------- 1 | # no-trailing-slash 2 | 3 | This rule validates that all urls do not end with a slash. 4 | 5 | ## Examples of valid urls 6 | 7 | ```yaml 8 | paths: 9 | "/get/stocks": # <-- valid 10 | get: 11 | description: get available stocks 12 | responses: 13 | $ref: #/components/responses/Stocks 14 | servers: 15 | - description: development server 16 | url: http://dev.server.net/v1 # <-- valid 17 | ``` 18 | 19 | ## Examples of invalid urls 20 | 21 | ```yaml 22 | paths: 23 | "/get/stocks/": # <-- invalid 24 | get: 25 | description: get available stocks 26 | responses: 27 | $ref: #/components/responses/Stocks 28 | servers: 29 | - description: development server 30 | url: http://dev.server.net/v1/ # <-- invalid 31 | ``` 32 | -------------------------------------------------------------------------------- /src/rules/no-ref-properties/readme.md: -------------------------------------------------------------------------------- 1 | # no-ref-properties 2 | 3 | This rule disallows to have additional properties in Reference objects. 4 | 5 | ## Examples of valid OpenAPI SchemaObject types 6 | 7 | ```yaml 8 | components: 9 | schemas: 10 | SomeApiResponse: 11 | type: "object" 12 | properties: 13 | allOf: # <-- valid 14 | - $ref: "#/components/schemas/Foo" 15 | - description: "Info about foo" 16 | ``` 17 | 18 | ## Examples of invalid OpenAPI SchemaObject 19 | 20 | Reference Objects cannot contain properties besides `$ref` 21 | 22 | ```yaml 23 | components: 24 | schemas: 25 | SomeApiResponse: 26 | type: "object" 27 | properties: 28 | $ref: "#/components/schemas/Foo" 29 | description: "Info about foo" # <-- invalid 30 | ``` 31 | -------------------------------------------------------------------------------- /src/rules/no-empty-object-type/readme.md: -------------------------------------------------------------------------------- 1 | # no-empty-object-type 2 | 3 | This rule validates that no `SchemaObject`s can be empty object types 4 | 5 | ## Examples of valid OpenAPI SchemaObject types 6 | 7 | ```yaml 8 | components: 9 | schemas: 10 | SomeApiResponse: 11 | type: "object" 12 | properties: # <-- valid because properties are specified 13 | foo: 14 | type: "string" 15 | SomeApiResponse: 16 | type: "object" 17 | allOf: # <-- valid because properties can be discovered via allOf 18 | - $ref: "#/components/schemas/Foo" 19 | - $ref: "#/components/schemas/Bar" 20 | ``` 21 | 22 | ## Examples of invalid OpenAPI SchemaObject 23 | 24 | ```yaml 25 | components: 26 | schemas: 27 | SomeApiResponse: 28 | type: "object" # <-- invalid because no properties are specified 29 | ``` 30 | -------------------------------------------------------------------------------- /src/utils/tests.ts: -------------------------------------------------------------------------------- 1 | import {Swagger, OpenAPI} from '../types'; 2 | import deepmerge from 'deepmerge'; 3 | 4 | export function getSwaggerObject( 5 | schema: Partial, 6 | ): Swagger.SwaggerObject { 7 | return deepmerge( 8 | { 9 | swagger: '2.0', 10 | info: { 11 | title: 'stub', 12 | version: '1.0', 13 | }, 14 | paths: {}, 15 | tags: [], 16 | }, 17 | schema, 18 | ); 19 | } 20 | 21 | export function getOpenAPIObject( 22 | schema: Partial, 23 | ): OpenAPI.OpenAPIObject { 24 | return deepmerge( 25 | { 26 | openapi: '3.0.3', 27 | info: { 28 | title: 'stub', 29 | version: '1.0', 30 | }, 31 | paths: {}, 32 | tags: [], 33 | }, 34 | schema, 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.0.7 2 | Features: 3 | - Improved OpenAPI v3 types, thanks @havok187! 4 | ## v0.0.6 5 | Features: 6 | - Improved OpenAPI v3 types 7 | ## v0.0.5 8 | Features: 9 | - Added documentation on how to write a new rule 10 | - Improved typescript types 11 | - Added OpenAPI v3 types 12 | ## v0.0.4 13 | Features: 14 | - Link to a docker image 15 | - Extendable config 16 | - parameter-casing rule extended settings 17 | ## v0.0.3 18 | Features: 19 | - Visitor based rules 20 | - Rules added 21 | - Colored CLI output 22 | - No need for path and url flags 23 | - Loads config using `cosmiconfig` 24 | New Rules 25 | - `expressive-path-summary` 26 | - `no-single-allof` 27 | - `no-trailing-slash` 28 | - `only-valid-mime-types` 29 | - `parameter-casing` 30 | - `path-param-required-field` 31 | - `required-operation-tags` 32 | - `required-parameter-description` 33 | - `required-tag-description` 34 | ## v0.0.2-0 35 | New Rules 36 | - `latin-definitions-only` 37 | - `no-empty-object-type` 38 | - `object-prop-casing` 39 | -------------------------------------------------------------------------------- /src/utils/swagger.ts: -------------------------------------------------------------------------------- 1 | import {Swagger} from '../types'; 2 | import {oldHttpMethods, isObject, hasKey} from './common'; 3 | 4 | export const httpMethods = oldHttpMethods; 5 | 6 | export function isRef( 7 | arg: Record, 8 | ): arg is Swagger.ReferenceObject { 9 | return typeof arg.$ref === 'string'; 10 | } 11 | 12 | export function isInfoObject(arg: unknown): arg is Swagger.InfoObject { 13 | return ( 14 | isObject(arg) && 15 | hasKey('title', arg) && 16 | hasKey('version', arg) && 17 | typeof arg.title === 'string' && 18 | typeof arg.version === 'string' 19 | ); 20 | } 21 | 22 | export function isSwaggerObject(arg: unknown): arg is Swagger.SwaggerObject { 23 | return ( 24 | isObject(arg) && 25 | hasKey('swagger', arg) && 26 | arg.swagger === '2.0' && 27 | hasKey('info', arg) && 28 | hasKey('paths', arg) && 29 | isObject(arg.paths) && 30 | isInfoObject(arg.info) 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/rules/no-single-allof/index.ts: -------------------------------------------------------------------------------- 1 | import {createRule} from '../../utils'; 2 | 3 | const name = 'no-single-allof'; 4 | 5 | const rule = createRule({ 6 | name, 7 | docs: { 8 | recommended: true, 9 | description: 10 | 'Object types should not have a redundant single `allOf` property', 11 | }, 12 | meta: { 13 | messages: { 14 | msg: 'Redundant use of "allOf" with a single item in it.', 15 | }, 16 | }, 17 | swaggerVisitor: { 18 | SchemaObject: ({node, report, location}): void => { 19 | if ('allOf' in node && node.allOf.length === 1) { 20 | report({messageId: 'msg', location: [...location, 'allOf']}); 21 | } 22 | }, 23 | }, 24 | openapiVisitor: { 25 | SchemaObject: ({node, report, location}): void => { 26 | if (node.allOf && node.allOf.length === 1) { 27 | report({messageId: 'msg', location: [...location, 'allOf']}); 28 | } 29 | }, 30 | }, 31 | }); 32 | 33 | export default rule; 34 | -------------------------------------------------------------------------------- /src/rules/path-param-required-field/readme.md: -------------------------------------------------------------------------------- 1 | # path-param-required-field 2 | 3 | This rule validates that all `ParameterObject`s specify optional parameter field explicitly. 4 | 5 | ## Examples of valid OpenAPI ParameterObject 6 | 7 | ```yaml 8 | components: 9 | parameters: 10 | petId: 11 | name: petId 12 | in: path 13 | description: ID of pet that needs to be updated 14 | required: true # <-- valid 15 | schema: 16 | type: string 17 | petBreed: 18 | name: petBreed 19 | in: query 20 | description: Breed of pet that needs to be updated 21 | required: false # <-- valid 22 | schema: 23 | type: string 24 | ``` 25 | 26 | ## Examples of invalid OpenAPI SchemaObject 27 | 28 | ```yaml 29 | components: 30 | parameters: 31 | petId: # <-- invalid 32 | name: petId 33 | in: path 34 | description: ID of pet that needs to be updated 35 | schema: 36 | type: string 37 | ``` 38 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export const oldHttpMethods = [ 2 | 'get', 3 | 'put', 4 | 'post', 5 | 'delete', 6 | 'options', 7 | 'head', 8 | 'patch', 9 | ] as const; 10 | 11 | export const newerHttpMethods = [...oldHttpMethods, 'trace'] as const; 12 | 13 | export function hasKey( 14 | key: K, 15 | obj: object, 16 | ): obj is {[key in K]: unknown} { 17 | return key in obj; 18 | } 19 | 20 | export function isObject(arg: unknown): arg is object { 21 | return typeof arg === 'object' && arg !== null && !Array.isArray(arg); 22 | } 23 | 24 | export function omit(src: T, fields: S[]): Omit { 25 | const toOmit = new Set(fields); 26 | 27 | return Object.keys(src).reduce((acc, key) => { 28 | if (toOmit.has(key)) return acc; 29 | 30 | // @ts-expect-error: shallow copying an object 31 | acc[key] = src[key]; 32 | 33 | return acc; 34 | }, {} as Omit); 35 | } 36 | 37 | export function capitalize(str: string): string { 38 | return str.charAt(0).toUpperCase() + str.slice(1); 39 | } 40 | -------------------------------------------------------------------------------- /docs/how-to-test-a-rule.md: -------------------------------------------------------------------------------- 1 | # How to test a rule 2 | 3 | To test a rule you should utilize a built-in utility called `RuleTester`. Make sure to run it inside your test files. 4 | 5 | ```js 6 | // nodejs 7 | const {RuleTester} = require('swaggerlint'); 8 | const rule = require('../my-custom-rule'); 9 | 10 | // es modules / typescript 11 | import {RuleTester} from 'swaggerlint'; 12 | import rule from '../my-custom-rule'; 13 | 14 | const ruleTester = new RuleTester(rule); 15 | 16 | ruleTester.run({ 17 | swagger: { 18 | valid: [ 19 | { 20 | it: 'test name', // will be displayed in the test report 21 | schema: {}, // part of the swagger schema to be tested 22 | }, 23 | // other test cases 24 | ], 25 | invalid: [ 26 | { 27 | it: 'test name', 28 | schema: { 29 | path: {/* ... */} 30 | }, 31 | errors: [/* ... */], // list of expected errors 32 | }, 33 | // other test cases 34 | ] 35 | } 36 | }) 37 | ``` 38 | -------------------------------------------------------------------------------- /src/rules/parameter-casing/readme.md: -------------------------------------------------------------------------------- 1 | # parameter-casing rule 2 | 3 | By default this rule is checking for all parameters to be `camel` cased. Optionally you can specify one of other supported casing names `camel | constant | snake | kebab | pascal`. 4 | 5 | ```js 6 | // swaggerlint.config.js 7 | module.exports = { 8 | rules: { 9 | 'parameter-casing': ['snake'], 10 | }, 11 | }; 12 | ``` 13 | 14 | 15 | This rule can be configured to check for different casing depending on parameter location (`in`), ie `query | header | path | formData | body`. You can also specify parameter names to ignore. 16 | 17 | ```js 18 | // swaggerlint.config.js 19 | module.exports = { 20 | rules: { 21 | 'parameter-casing': [ 22 | 'camel', // default casing 23 | { 24 | query: 'snake', // override specific parameters 25 | header: 'kebab', 26 | path: 'pascal', 27 | formData: 'constant', 28 | body: 'camel', 29 | ignore: ['ODD', 'cAsinG'], // parameter names to ignore 30 | }, 31 | ], 32 | }, 33 | }; 34 | ``` 35 | -------------------------------------------------------------------------------- /src/rules/no-inline-enums/index.ts: -------------------------------------------------------------------------------- 1 | import {createRule} from '../../utils'; 2 | 3 | const name = 'no-inline-enums'; 4 | 5 | const rule = createRule({ 6 | name, 7 | docs: { 8 | recommended: true, 9 | description: 10 | 'Enums must be in `DefinitionsObject` or `ComponentsObject`', 11 | }, 12 | meta: { 13 | messages: { 14 | swagger: 15 | 'Inline enums are not allowed. Move this SchemaObject to DefinitionsObject', 16 | openapi: 17 | 'Inline enums are not allowed. Move this SchemaObject to ComponentsObject', 18 | }, 19 | }, 20 | swaggerVisitor: { 21 | SchemaObject: ({node, report, location}): void => { 22 | if (node.enum && location[0] !== 'definitions') { 23 | report({messageId: 'swagger'}); 24 | } 25 | }, 26 | }, 27 | openapiVisitor: { 28 | SchemaObject: ({node, report, location}): void => { 29 | if (node.enum && location[0] !== 'components') { 30 | report({messageId: 'openapi'}); 31 | } 32 | }, 33 | }, 34 | }); 35 | 36 | export default rule; 37 | -------------------------------------------------------------------------------- /src/rules/no-ref-properties/index.ts: -------------------------------------------------------------------------------- 1 | import {createRule} from '../../utils'; 2 | 3 | const name = 'no-ref-properties'; 4 | 5 | const rule = createRule({ 6 | name, 7 | docs: { 8 | recommended: true, 9 | description: 10 | 'Disallows to have additional properties in Reference objects', 11 | }, 12 | meta: { 13 | messages: { 14 | noRefProps: 15 | 'To add properties to Reference Object, wrap it in `allOf` and add the properties to the added sibling SchemaObject', 16 | }, 17 | }, 18 | swaggerVisitor: { 19 | ReferenceObject: ({node, report}) => { 20 | const addonProps = Object.keys(node).filter(x => x !== '$ref'); 21 | 22 | if (addonProps.length) { 23 | report({messageId: 'noRefProps'}); 24 | } 25 | }, 26 | }, 27 | openapiVisitor: { 28 | ReferenceObject: ({node, report}) => { 29 | const addonProps = Object.keys(node).filter(x => x !== '$ref'); 30 | 31 | if (addonProps.length) { 32 | report({messageId: 'noRefProps'}); 33 | } 34 | }, 35 | }, 36 | }); 37 | 38 | export default rule; 39 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:prettier/recommended', 7 | 'prettier/@typescript-eslint', 8 | ], 9 | plugins: ['@typescript-eslint', 'prettier'], 10 | parser: '@typescript-eslint/parser', 11 | rules: { 12 | indent: ['error', 4, {SwitchCase: 1}], 13 | '@typescript-eslint/no-var-requires': 0, 14 | '@typescript-eslint/no-unused-vars': 'error', 15 | '@typescript-eslint/explicit-module-boundary-types': 0, 16 | '@typescript-eslint/ban-types': [ 17 | 1, 18 | { 19 | types: { 20 | object: false, 21 | }, 22 | }, 23 | ], 24 | '@typescript-eslint/ban-ts-comment': [ 25 | 'error', 26 | { 27 | 'ts-expect-error': 'allow-with-description', 28 | 'ts-ignore': true, 29 | 'ts-nocheck': true, 30 | 'ts-check': false, 31 | minimumDescriptionLength: 3, 32 | }, 33 | ], 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/rules/path-param-required-field/index.ts: -------------------------------------------------------------------------------- 1 | import {Swagger, OpenAPI, Report} from '../../types'; 2 | import {createRule} from '../../utils'; 3 | 4 | const name = 'path-param-required-field'; 5 | const messages = { 6 | requiredField: 'Parameter "{{name}}" is missing "required" property', 7 | }; 8 | 9 | type Param = { 10 | node: Swagger.ParameterObject | OpenAPI.ParameterObject; 11 | report: Report; 12 | location: string[]; 13 | }; 14 | 15 | function ParameterObject({node, report}: Param): void { 16 | if (!('required' in node)) { 17 | report({ 18 | messageId: 'requiredField', 19 | data: { 20 | name: node.name, 21 | }, 22 | }); 23 | } 24 | } 25 | 26 | const rule = createRule({ 27 | name, 28 | docs: { 29 | recommended: false, 30 | description: 31 | 'Helps to keep consistently set optional `required` property in path parameters', 32 | }, 33 | meta: { 34 | messages, 35 | }, 36 | swaggerVisitor: { 37 | ParameterObject, 38 | }, 39 | openapiVisitor: { 40 | ParameterObject, 41 | }, 42 | }); 43 | 44 | export default rule; 45 | -------------------------------------------------------------------------------- /src/rules/latin-definitions-only/readme.md: -------------------------------------------------------------------------------- 1 | # latin-definitions-only 2 | 3 | This rule validates that all names in `DefinitionsObject` for Swagger and in `ComponentsObject` for OpenAPI have no non-latin characters. This rule is specially useful when swagger/openapi is being generated from code annotations. 4 | 5 | ## Examples of valid OpenAPI ComponentsObject 6 | 7 | ```yaml 8 | components: 9 | schemas: 10 | SomeApiResponse: # <-- valid 11 | type: "string" 12 | ``` 13 | 14 | ## Examples of invalid OpenAPI ComponentsObject 15 | 16 | ```yaml 17 | components: 18 | schemas: 19 | "Some api reponse": # <-- invalid 20 | type: "string" 21 | "another, thing ! here {}": # <-- invalid 22 | type: "number" 23 | "тоже невалидное название": # <-- invalid 24 | type: "number" 25 | ``` 26 | 27 | ## Configuration 28 | 29 | This rule can be configured to ignore specified characters. 30 | 31 | ```js 32 | // swaggerlint.config.js 33 | module.exports = { 34 | rules: { 35 | 'latin-definitions-only': [ 36 | '', 37 | { 38 | ignore: ['$', '#'], // characters to ignore 39 | }, 40 | ], 41 | }, 42 | }; 43 | ``` 44 | -------------------------------------------------------------------------------- /src/rules/no-inline-enums/readme.md: -------------------------------------------------------------------------------- 1 | # no-inline-enums 2 | 3 | This rule validates that no `SchemaObject` with enum is located outside of `DefinitionsObject` for Swagger or `ComponentsObject` for OpenAPI. 4 | 5 | ## Examples of valid and invalid OpenAPI SchemaObjects 6 | 7 | ```yaml 8 | paths: 9 | '/url': 10 | get: 11 | responses: 12 | '200': 13 | content: 14 | 'application/json': 15 | schema: 16 | type: 'string' # <-- invalid 17 | enum: ['foo', 'bar'] 18 | components: 19 | schemas: 20 | Example: 21 | type: 'string' # <-- valid 22 | enum: ['foo', 'bar'] 23 | ``` 24 | ## Examples of valid and invalid Swagger SchemaObjects 25 | 26 | ```yaml 27 | paths: 28 | '/url': 29 | get: 30 | responses: 31 | default: 32 | description: 'default response' 33 | schema: 34 | type: 'string' # <-- invalid 35 | enum: ['foo', 'bar'] 36 | definitions: 37 | Example: 38 | type: 'string' # <-- valid 39 | enum: ['foo', 'bar'] 40 | ``` 41 | -------------------------------------------------------------------------------- /src/rules/expressive-path-summary/readme.md: -------------------------------------------------------------------------------- 1 | # expressive-path-summary 2 | 3 | This rule validates that all `OperationObject`s have a non-empty `summary` property with at least 2 words in it. Due to some swagger/openapi generation tools auto filling this property with controller names or other generated content. 4 | 5 | ## Examples of valid parameters in OperationObject 6 | 7 | ```yaml 8 | # OpenAPI example 9 | paths: 10 | "/get/stocks": 11 | get: 12 | summary: "returns the amount of stocks" # <-- valid 13 | responses: 14 | "200": 15 | description: "successful operation" 16 | content: 17 | "application/json": 18 | $ref: "#/definitions/StocksDTO" 19 | ``` 20 | 21 | ## Examples of invalid summary in OperationObject 22 | 23 | ```yaml 24 | # OpenAPI example 25 | paths: 26 | "/get/stocks": 27 | get: 28 | summary: "GetStocksController" # <-- invalid 29 | responses: 30 | "200": 31 | description: "successful operation" 32 | content: 33 | "application/json": 34 | $ref: "#/definitions/StocksDTO" 35 | ``` 36 | -------------------------------------------------------------------------------- /src/rules/required-operation-tags/index.ts: -------------------------------------------------------------------------------- 1 | import {Swagger, OpenAPI, Report} from '../../types'; 2 | import {createRule} from '../../utils'; 3 | 4 | const name = 'required-operation-tags'; 5 | const messages = { 6 | missingTags: 'Operation "{{method}}" in "{{url}}" is missing tags.', 7 | }; 8 | 9 | type Param = { 10 | node: Swagger.OperationObject | OpenAPI.OperationObject; 11 | report: Report; 12 | location: string[]; 13 | }; 14 | 15 | function OperationObject({node, report, location}: Param): void { 16 | if (!Array.isArray(node.tags) || node.tags.length < 1) { 17 | const method = location[location.length - 1]; 18 | const url = location[location.length - 2]; 19 | report({ 20 | messageId: 'missingTags', 21 | data: { 22 | method, 23 | url, 24 | }, 25 | }); 26 | } 27 | } 28 | 29 | const rule = createRule({ 30 | name, 31 | docs: { 32 | recommended: true, 33 | description: 'All operations must have tags', 34 | }, 35 | meta: { 36 | messages, 37 | }, 38 | swaggerVisitor: { 39 | OperationObject, 40 | }, 41 | openapiVisitor: { 42 | OperationObject, 43 | }, 44 | }); 45 | 46 | export default rule; 47 | -------------------------------------------------------------------------------- /scripts/update-rules.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs'; 2 | import * as path from 'path'; 3 | import Case from 'case'; 4 | 5 | import {mmac} from 'make-me-a-content'; 6 | 7 | import prettier from 'prettier'; 8 | 9 | const prettierConfig = require('../prettier.config') as prettier.Options; 10 | 11 | export async function updateRules() { 12 | const rulesDirContents = ( 13 | await fs.readdir(path.join(__dirname, '..', 'src', 'rules')) 14 | ).filter(x => x !== 'index.ts'); 15 | 16 | const imports = rulesDirContents.map( 17 | name => `import ${Case.camel(name)} from './${name}';`, 18 | ); 19 | 20 | const objectProperties = rulesDirContents.map( 21 | name => `[${Case.camel(name)}.name]: ${Case.camel(name)},`, 22 | ); 23 | 24 | await mmac({ 25 | updateScript: 'npm run updateDocs', 26 | filepath: path.join(__dirname, '..', 'src', 'rules', 'index.ts'), 27 | lines: [ 28 | ...imports, 29 | '', 30 | 'export const rules: Record> = {', 31 | ...objectProperties, 32 | '}', 33 | ], 34 | transform: content => 35 | prettier.format(content, {parser: 'babel', ...prettierConfig}), 36 | }); 37 | 38 | console.log('✅rules are updated'); 39 | } 40 | 41 | if (require.main === module) updateRules(); 42 | -------------------------------------------------------------------------------- /src/rules/required-tag-description/index.ts: -------------------------------------------------------------------------------- 1 | import {Swagger, OpenAPI, Report} from '../../types'; 2 | import {createRule} from '../../utils'; 3 | 4 | const name = 'required-tag-description'; 5 | const messages = { 6 | missingDesc: 'Tag "{{name}}" is missing description.', 7 | }; 8 | 9 | type Param = { 10 | node: Swagger.TagObject | OpenAPI.TagObject; 11 | report: Report; 12 | location: string[]; 13 | }; 14 | 15 | function TagObject({node, report, location}: Param): void { 16 | if (!('description' in node)) { 17 | report({ 18 | messageId: 'missingDesc', 19 | data: { 20 | name: node.name, 21 | }, 22 | }); 23 | return; 24 | } 25 | if (!node.description) { 26 | report({ 27 | messageId: 'missingDesc', 28 | data: { 29 | name: node.name, 30 | }, 31 | location: [...location, 'description'], 32 | }); 33 | } 34 | } 35 | 36 | const rule = createRule({ 37 | name, 38 | docs: { 39 | recommended: true, 40 | description: 'All tags must have description', 41 | }, 42 | meta: { 43 | messages, 44 | }, 45 | swaggerVisitor: { 46 | TagObject, 47 | }, 48 | openapiVisitor: { 49 | TagObject, 50 | }, 51 | }); 52 | 53 | export default rule; 54 | -------------------------------------------------------------------------------- /src/rules/required-parameter-description/index.ts: -------------------------------------------------------------------------------- 1 | import {Swagger, OpenAPI, Report} from '../../types'; 2 | import {createRule} from '../../utils'; 3 | 4 | const name = 'required-parameter-description'; 5 | const messages = { 6 | missingDesc: '"{{name}}" parameter is missing description.', 7 | }; 8 | 9 | type Param = { 10 | node: Swagger.ParameterObject | OpenAPI.ParameterObject; 11 | report: Report; 12 | location: string[]; 13 | }; 14 | 15 | function ParameterObject({node, report, location}: Param): void { 16 | if (!('description' in node)) { 17 | report({ 18 | messageId: 'missingDesc', 19 | data: { 20 | name: node.name, 21 | }, 22 | }); 23 | } else if (typeof node.description === 'string' && !node.description) { 24 | report({ 25 | messageId: 'missingDesc', 26 | data: { 27 | name: node.name, 28 | }, 29 | location: [...location, 'description'], 30 | }); 31 | } 32 | } 33 | 34 | const rule = createRule({ 35 | name, 36 | docs: { 37 | recommended: true, 38 | description: 'All parameters must have description', 39 | }, 40 | meta: { 41 | messages, 42 | }, 43 | swaggerVisitor: { 44 | ParameterObject, 45 | }, 46 | openapiVisitor: { 47 | ParameterObject, 48 | }, 49 | }); 50 | 51 | export default rule; 52 | -------------------------------------------------------------------------------- /src/rules/expressive-path-summary/index.ts: -------------------------------------------------------------------------------- 1 | import {createRule} from '../../utils'; 2 | import {RuleVisitorFunction, Swagger, OpenAPI} from '../../types'; 3 | 4 | const name = 'expressive-path-summary'; 5 | const messages = { 6 | noSummary: 'Every path has to have a summary.', 7 | nonExpressive: `Every path summary should contain at least 2 words. This has "{{summary}}"`, 8 | }; 9 | const operationObjectValidator: RuleVisitorFunction< 10 | OpenAPI.OperationObject | Swagger.OperationObject, 11 | keyof typeof messages 12 | > = ({node, report, location}) => { 13 | const {summary} = node; 14 | if (typeof summary === 'string') { 15 | if (summary.split(' ').length < 2) { 16 | report({ 17 | messageId: 'nonExpressive', 18 | data: { 19 | summary, 20 | }, 21 | location: [...location, 'summary'], 22 | }); 23 | } 24 | } else { 25 | report({messageId: 'noSummary'}); 26 | } 27 | }; 28 | const rule = createRule({ 29 | name, 30 | docs: { 31 | recommended: true, 32 | description: 'Enforces an intentional path summary', 33 | }, 34 | meta: { 35 | messages, 36 | }, 37 | swaggerVisitor: { 38 | OperationObject: operationObjectValidator, 39 | }, 40 | openapiVisitor: { 41 | OperationObject: operationObjectValidator, 42 | }, 43 | }); 44 | 45 | export default rule; 46 | -------------------------------------------------------------------------------- /src/utils/swaggerfile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import yaml from 'js-yaml'; 4 | import fetch from 'node-fetch'; 5 | import {Swagger} from '../types'; 6 | 7 | function isYamlPath(p: string): boolean { 8 | const ext = path.extname(p); 9 | 10 | return ext === '.yml' || ext === '.yaml'; 11 | } 12 | 13 | type ResultSuccess = { 14 | swagger: Swagger.SwaggerObject; 15 | }; 16 | type ResultFail = { 17 | swagger: null; 18 | error: string; 19 | }; 20 | 21 | type Result = ResultSuccess | ResultFail; 22 | 23 | export function getSwaggerByPath(pth: string): Result { 24 | const swaggerPath = path.resolve(pth); 25 | // non existing path 26 | if (!fs.existsSync(swaggerPath)) { 27 | return { 28 | error: 'File at the provided path does not exist.', 29 | swagger: null, 30 | }; 31 | } 32 | 33 | const isYaml = isYamlPath(swaggerPath); 34 | 35 | try { 36 | const swagger: Swagger.SwaggerObject = isYaml 37 | ? yaml.safeLoad(fs.readFileSync(swaggerPath, 'utf8')) 38 | : require(swaggerPath); 39 | 40 | return { 41 | swagger, 42 | }; 43 | } catch (e) { 44 | return { 45 | swagger: null, 46 | error: 'Error requiring swaggerlint config', 47 | }; 48 | } 49 | } 50 | 51 | export async function getSwaggerByUrl( 52 | url: string, 53 | ): Promise { 54 | return fetch(url).then(x => 55 | isYamlPath(url) ? x.text().then(yaml.safeLoad) : x.json(), 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/rules/required-operation-tags/readme.md: -------------------------------------------------------------------------------- 1 | # required-operation-tags 2 | 3 | This rule validates that all `OperationObject`s have at least one tag specified. 4 | 5 | ## Examples of valid OpenAPI ParameterObject 6 | 7 | ```yaml 8 | paths: 9 | "/get/stocks": 10 | get: 11 | summary: "returns the amount of stocks" 12 | tags: 13 | - stocks # <-- valid 14 | responses: 15 | "200": 16 | description: "successful operation" 17 | content: 18 | "application/json": 19 | $ref: "#/definitions/StocksDTO" 20 | ``` 21 | 22 | ## Examples of invalid OpenAPI OperationObject 23 | 24 | ```yaml 25 | paths: 26 | "/get/stocks": 27 | get: # <-- invalid, no tags property 28 | summary: "returns the amount of stocks" 29 | responses: 30 | "200": 31 | description: "successful operation" 32 | content: 33 | "application/json": 34 | $ref: "#/definitions/StocksDTO" 35 | post: 36 | summary: "returns the amount of stocks" 37 | tags: [] # <-- invalid, no tags present 38 | parameters: 39 | - $ref: '#/components/parameters/StocksDTO' 40 | responses: 41 | "200": 42 | description: "successful operation" 43 | content: 44 | "application/json": 45 | $ref: "#/definitions/StocksDTO" 46 | ``` 47 | -------------------------------------------------------------------------------- /scripts/update-readme.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs'; 2 | import * as path from 'path'; 3 | 4 | import {mmac} from 'make-me-a-content'; 5 | 6 | import prettier from 'prettier'; 7 | 8 | const prettierConfig = require('../prettier.config') as prettier.Options; 9 | 10 | const PATHS = { 11 | readme: path.join(__dirname, '..', 'README.md'), 12 | rulesDir: path.join(__dirname, '..', 'src', 'rules'), 13 | }; 14 | 15 | console.log(PATHS); 16 | 17 | export async function updateReadme() { 18 | const rulesDirContents = (await fs.readdir(PATHS.rulesDir)).filter( 19 | x => x !== 'index.ts', 20 | ); 21 | 22 | const rules = await Promise.all( 23 | rulesDirContents.map(dirName => 24 | import(path.join(PATHS.rulesDir, dirName)).then(rule => ({ 25 | rule: rule.default, 26 | dirName, 27 | })), 28 | ), 29 | ); 30 | 31 | const rows = rules.map( 32 | ({rule, dirName}) => 33 | `| [\`${rule.name}\`](./src/rules/${dirName}/readme.md) | ${ 34 | rule.docs.description 35 | } | ${ 36 | rule.defaultSetting ? JSON.stringify(rule.defaultSetting) : ' ' 37 | } |`, 38 | ); 39 | 40 | await mmac({ 41 | updateScript: 'npm run updateDocs', 42 | filepath: PATHS.readme, 43 | lines: [ 44 | '| rule name | description | default |', 45 | '| --- | --- | --- |', 46 | ...rows, 47 | ], 48 | id: 'rulestable', 49 | transform: content => 50 | prettier.format(content, {parser: 'markdown', ...prettierConfig}), 51 | }); 52 | 53 | console.log('✅readme is updated'); 54 | } 55 | 56 | if (require.main === module) updateReadme(); 57 | -------------------------------------------------------------------------------- /src/rules/no-external-refs/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | openapi: { 8 | valid: [ 9 | { 10 | it: 'should not error for an empty openapi sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for an external reference object', 17 | schema: { 18 | components: { 19 | schemas: { 20 | Example: { 21 | type: 'object', 22 | properties: { 23 | foo: { 24 | $ref: '#/components/schemas/Foo', 25 | }, 26 | bar: { 27 | $ref: 'schemas.yaml#/Bar', 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | errors: [ 35 | { 36 | msg: 'External references are banned.', 37 | messageId: 'msg', 38 | name: rule.name, 39 | location: [ 40 | 'components', 41 | 'schemas', 42 | 'Example', 43 | 'properties', 44 | 'bar', 45 | ], 46 | }, 47 | ], 48 | }, 49 | ], 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/utils/openapi.ts: -------------------------------------------------------------------------------- 1 | import {OpenAPI, OpenAPIVisitorName} from '../types'; 2 | import {isObject, hasKey, newerHttpMethods} from './common'; 3 | 4 | export const httpMethods = newerHttpMethods; 5 | 6 | export function isRef( 7 | arg: Record, 8 | ): arg is OpenAPI.ReferenceObject { 9 | return typeof arg.$ref === 'string'; 10 | } 11 | 12 | const openapiVisitorSet = new Set([ 13 | 'CallbackObject', 14 | 'ComponentsObject', 15 | 'ContactObject', 16 | 'DiscriminatorObject', 17 | 'EncodingObject', 18 | 'ExampleObject', 19 | 'ExternalDocumentationObject', 20 | 'HeaderObject', 21 | 'InfoObject', 22 | 'LicenseObject', 23 | 'LinkObject', 24 | 'MediaTypeObject', 25 | 'OAuthFlowObject', 26 | 'OAuthFlowsObject', 27 | 'OpenAPIObject', 28 | 'OperationObject', 29 | 'ParameterObject', 30 | 'PathItemObject', 31 | 'PathsObject', 32 | 'ReferenceObject', 33 | 'RequestBodyObject', 34 | 'ResponseObject', 35 | 'ResponsesObject', 36 | 'SchemaObject', 37 | 'SecurityRequirementObject', 38 | 'SecuritySchemeObject', 39 | 'ServerObject', 40 | 'ServerVariableObject', 41 | 'SpecificationExtensions', 42 | 'TagObject', 43 | 'XMLObject', 44 | ]); 45 | export function isValidVisitorName(name: string): name is OpenAPIVisitorName { 46 | return openapiVisitorSet.has(name); 47 | } 48 | 49 | export function isValidOpenAPIObject( 50 | arg: unknown, 51 | ): arg is OpenAPI.OpenAPIObject { 52 | return ( 53 | isObject(arg) && 54 | hasKey('openapi', arg) && 55 | typeof arg.openapi === 'string' && 56 | ['3.0.0', '3.0.1', '3.0.2', '3.0.3'].includes(arg.openapi) 57 | ); 58 | } 59 | 60 | export const componentsKeys = [ 61 | 'schemas', 62 | 'responses', 63 | 'parameters', 64 | 'examples', 65 | 'requestBodies', 66 | 'headers', 67 | 'securitySchemes', 68 | 'links', 69 | 'callbacks', 70 | ] as const; 71 | -------------------------------------------------------------------------------- /src/rules/index.ts: -------------------------------------------------------------------------------- 1 | import {SwaggerlintRule} from '../types'; 2 | 3 | /* GENERATED_START(id:main;hash:b40e1cfc8d3333a2dd472ed4f410070e) This is generated content, do not modify by hand, to regenerate run "npm run updateDocs" */ 4 | import expressivePathSummary from './expressive-path-summary'; 5 | import latinDefinitionsOnly from './latin-definitions-only'; 6 | import noEmptyObjectType from './no-empty-object-type'; 7 | import noExternalRefs from './no-external-refs'; 8 | import noInlineEnums from './no-inline-enums'; 9 | import noRefProperties from './no-ref-properties'; 10 | import noSingleAllof from './no-single-allof'; 11 | import noTrailingSlash from './no-trailing-slash'; 12 | import objectPropCasing from './object-prop-casing'; 13 | import onlyValidMimeTypes from './only-valid-mime-types'; 14 | import parameterCasing from './parameter-casing'; 15 | import pathParamRequiredField from './path-param-required-field'; 16 | import requiredOperationTags from './required-operation-tags'; 17 | import requiredParameterDescription from './required-parameter-description'; 18 | import requiredTagDescription from './required-tag-description'; 19 | 20 | export const rules: Record> = { 21 | [expressivePathSummary.name]: expressivePathSummary, 22 | [latinDefinitionsOnly.name]: latinDefinitionsOnly, 23 | [noEmptyObjectType.name]: noEmptyObjectType, 24 | [noExternalRefs.name]: noExternalRefs, 25 | [noInlineEnums.name]: noInlineEnums, 26 | [noRefProperties.name]: noRefProperties, 27 | [noSingleAllof.name]: noSingleAllof, 28 | [noTrailingSlash.name]: noTrailingSlash, 29 | [objectPropCasing.name]: objectPropCasing, 30 | [onlyValidMimeTypes.name]: onlyValidMimeTypes, 31 | [parameterCasing.name]: parameterCasing, 32 | [pathParamRequiredField.name]: pathParamRequiredField, 33 | [requiredOperationTags.name]: requiredOperationTags, 34 | [requiredParameterDescription.name]: requiredParameterDescription, 35 | [requiredTagDescription.name]: requiredTagDescription, 36 | }; 37 | /* GENERATED_END(id:main) */ 38 | -------------------------------------------------------------------------------- /src/rules/no-empty-object-type/index.ts: -------------------------------------------------------------------------------- 1 | import {createRule} from '../../utils'; 2 | 3 | const name = 'no-empty-object-type'; 4 | 5 | const rule = createRule({ 6 | name, 7 | docs: { 8 | recommended: true, 9 | description: 10 | 'Object types have to have their properties specified explicitly', 11 | }, 12 | meta: { 13 | messages: { 14 | swagger: `has "object" type but is missing "properties" | "additionalProperties" | "allOf"`, 15 | openapi: `has "object" type but is missing "properties" | "additionalProperties" | "allOf" | "anyOf" | "oneOf"`, 16 | }, 17 | }, 18 | swaggerVisitor: { 19 | SchemaObject: ({node, report}): void => { 20 | if (node.type !== 'object') return; 21 | 22 | const hasProperties = 'properties' in node && !!node.properties; 23 | const hasAllOf = 'allOf' in node && !!node.allOf; 24 | const hasAdditionalProperties = 25 | 'additionalProperties' in node && !!node.additionalProperties; 26 | 27 | if (hasProperties || hasAllOf || hasAdditionalProperties) return; 28 | 29 | report({ 30 | messageId: 'swagger', 31 | }); 32 | }, 33 | }, 34 | openapiVisitor: { 35 | SchemaObject: ({node, report}): void => { 36 | if (node.type !== 'object') return; 37 | 38 | const hasProperties = 'properties' in node && !!node.properties; 39 | const hasAllOf = 'allOf' in node && !!node.allOf; 40 | const hasAnyOf = 'anyOf' in node && !!node.anyOf; 41 | const hasOneOf = 'oneOf' in node && !!node.oneOf; 42 | const hasAdditionalProperties = 43 | 'additionalProperties' in node && !!node.additionalProperties; 44 | 45 | if ( 46 | hasProperties || 47 | hasAllOf || 48 | hasAnyOf || 49 | hasOneOf || 50 | hasAdditionalProperties 51 | ) 52 | return; 53 | 54 | report({ 55 | messageId: 'openapi', 56 | }); 57 | }, 58 | }, 59 | }); 60 | 61 | export default rule; 62 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import minimist from 'minimist'; 4 | import {cli} from './cli'; 5 | import {logErrors, logErrorCount} from './utils/output'; 6 | import {CliOptions} from './types'; 7 | import {green} from 'kleur'; 8 | const pkg = require('../package.json'); 9 | 10 | function program(): void { 11 | const {version, v, help, h, ...options} = minimist( 12 | process.argv.slice(2), 13 | ); 14 | 15 | if (version || v) { 16 | console.log(pkg.version); 17 | 18 | process.exit(0); 19 | } 20 | 21 | if (help || h) { 22 | console.log( 23 | ` 24 | 25 | swaggerlint version ${pkg.version} 26 | <${pkg.repository.url.replace('git+', '').replace('.git', '')}> 27 | swaggerlint is a program to lint swagger (OpenAPI v2.0) API specification. 28 | 29 | Usage: 30 | swaggerlint ./path/to/swagger/file.json 31 | swaggerlint http://url.to/swagger/file.yml 32 | 33 | Options: 34 | -v, --version print version number 35 | -h, --help show this help 36 | --config add user config (swaggerlint.config.js) 37 | 38 | `.trim(), 39 | ); 40 | process.exit(0); 41 | } 42 | 43 | cli(options).then(({results, code}) => { 44 | if (results.length > 1) { 45 | results.forEach(result => { 46 | logErrors(result.errors, result.schema, { 47 | filename: result.src, 48 | count: false, 49 | }); 50 | }); 51 | 52 | const totalErros = results.reduce( 53 | (acc, el) => acc + el.errors.length, 54 | 0, 55 | ); 56 | if (totalErros > 0) { 57 | logErrorCount(totalErros); 58 | } else { 59 | console.log(green('No errors found')); 60 | } 61 | } else { 62 | const {errors, schema} = results[0]; 63 | 64 | if (code === 1) { 65 | logErrors(errors, schema, {count: true}); 66 | } else { 67 | console.log(green('No errors found')); 68 | } 69 | } 70 | 71 | process.exit(code); 72 | }); 73 | } 74 | 75 | program(); 76 | -------------------------------------------------------------------------------- /src/rules/no-trailing-slash/index.ts: -------------------------------------------------------------------------------- 1 | import {createRule} from '../../utils'; 2 | 3 | const name = 'no-trailing-slash'; 4 | 5 | const rule = createRule({ 6 | name, 7 | docs: { 8 | recommended: true, 9 | description: 'URLs must NOT end with a slash', 10 | }, 11 | meta: { 12 | messages: { 13 | url: 'Url cannot end with a slash "{{url}}".', 14 | host: 'Host cannot end with a slash, your host url is "{{url}}".', 15 | server: 'Server url cannot end with a slash "{{url}}".', 16 | }, 17 | }, 18 | swaggerVisitor: { 19 | PathsObject: ({node, report, location}): void => { 20 | const urls = Object.keys(node); 21 | 22 | urls.forEach(url => { 23 | if (url.endsWith('/')) { 24 | report({ 25 | messageId: 'url', 26 | data: { 27 | url, 28 | }, 29 | location: [...location, url], 30 | }); 31 | } 32 | }); 33 | }, 34 | SwaggerObject: ({node, report}): void => { 35 | const {host} = node; 36 | 37 | if (typeof host === 'string' && host.endsWith('/')) { 38 | report({ 39 | messageId: 'host', 40 | data: {url: host}, 41 | location: ['host'], 42 | }); 43 | } 44 | }, 45 | }, 46 | openapiVisitor: { 47 | PathItemObject: ({report, location}): void => { 48 | const url = location[location.length - 1]; 49 | 50 | if (url.endsWith('/')) { 51 | report({ 52 | messageId: 'url', 53 | data: { 54 | url, 55 | }, 56 | }); 57 | } 58 | }, 59 | ServerObject: ({node, report, location}): void => { 60 | if (node.url.endsWith('/')) { 61 | report({ 62 | messageId: 'server', 63 | data: {url: node.url}, 64 | location: [...location, 'url'], 65 | }); 66 | } 67 | }, 68 | }, 69 | }); 70 | 71 | export default rule; 72 | -------------------------------------------------------------------------------- /src/rules/only-valid-mime-types/index.ts: -------------------------------------------------------------------------------- 1 | import {Report, Swagger} from '../../types'; 2 | import {createRule} from '../../utils'; 3 | import mimeDB from 'mime-db'; 4 | 5 | const name = 'only-valid-mime-types'; 6 | const messages = { 7 | invalid: '"{{mimeType}}" is not a valid mime type.', 8 | }; 9 | 10 | function isValidMimeType(maybeMime: string): boolean { 11 | return maybeMime in mimeDB; 12 | } 13 | 14 | type Param = { 15 | node: Swagger.SwaggerObject | Swagger.OperationObject; 16 | report: Report; 17 | location: string[]; 18 | }; 19 | function onlyValidMimeTypeCheck({node, report, location}: Param): void { 20 | const {consumes, produces} = node; 21 | if (consumes) { 22 | consumes.forEach((mType, i) => { 23 | if (!isValidMimeType(mType)) { 24 | report({ 25 | messageId: 'invalid', 26 | data: { 27 | mimeType: mType, 28 | }, 29 | location: [...location, 'consumes', String(i)], 30 | }); 31 | } 32 | }); 33 | } 34 | if (produces) { 35 | produces.forEach((mType, i) => { 36 | if (!isValidMimeType(mType)) { 37 | report({ 38 | messageId: 'invalid', 39 | data: { 40 | mimeType: mType, 41 | }, 42 | location: [...location, 'produces', String(i)], 43 | }); 44 | } 45 | }); 46 | } 47 | } 48 | 49 | const rule = createRule({ 50 | name, 51 | docs: { 52 | recommended: true, 53 | description: 54 | 'Checks mime types against known from [`mime-db`](https://npm.im/mime-db)', 55 | }, 56 | meta: {messages}, 57 | swaggerVisitor: { 58 | SwaggerObject: onlyValidMimeTypeCheck, 59 | OperationObject: onlyValidMimeTypeCheck, 60 | }, 61 | openapiVisitor: { 62 | MediaTypeObject: ({location, report}): void => { 63 | const mimeType = location[location.length - 1]; 64 | if (!isValidMimeType(mimeType)) { 65 | report({ 66 | messageId: 'invalid', 67 | data: { 68 | mimeType, 69 | }, 70 | }); 71 | } 72 | }, 73 | }, 74 | }); 75 | 76 | export default rule; 77 | -------------------------------------------------------------------------------- /src/utils/output.ts: -------------------------------------------------------------------------------- 1 | import {LintError, Swagger, OpenAPI} from '../types'; 2 | import {bold, red, dim, grey} from 'kleur'; 3 | 4 | const PAD = 6; 5 | 6 | function shallowStringify( 7 | schema: Swagger.SwaggerObject | OpenAPI.OpenAPIObject, 8 | location: string[], 9 | ): string { 10 | let topLevelObject = true; 11 | const target = location.reduce( 12 | // @ts-expect-error: acc is any and it is okay 13 | (acc, key) => acc?.[key], 14 | schema, 15 | ); 16 | const stringifiedObj = JSON.stringify( 17 | target, 18 | (_, value) => { 19 | if (Array.isArray(value)) { 20 | return `Array(${value.length})`; 21 | } 22 | if (typeof value === 'object' && value !== null) { 23 | return topLevelObject 24 | ? ((topLevelObject = false), value) 25 | : 'Object'; 26 | } 27 | return value; 28 | }, 29 | PAD + 2, 30 | ); 31 | 32 | return grey( 33 | (stringifiedObj || '') 34 | .split('\n') 35 | .map((x: string) => x.padStart(PAD + 1, ' ')) 36 | .join('\n'), 37 | ); 38 | } 39 | 40 | function toOneLinerFormat( 41 | {msg, name, location}: LintError, 42 | swagger: Swagger.SwaggerObject | OpenAPI.OpenAPIObject | void, 43 | ): string { 44 | const hasLocation = location.length; 45 | const locationInfo = hasLocation ? ` in ${location.join('.')}` : ''; 46 | const ruleName = dim(name); 47 | 48 | return [ 49 | `${red('error')}${locationInfo}`, 50 | ruleName.padStart(ruleName.length + PAD, ' '), 51 | msg.padStart(msg.length + PAD, ' '), 52 | hasLocation && swagger && shallowStringify(swagger, location), 53 | ] 54 | .filter(Boolean) 55 | .join('\n'); 56 | } 57 | 58 | export function logErrorCount(count: number): void { 59 | console.log(bold(`You have ${count} error${!!count ? 's' : ''}.`)); 60 | } 61 | 62 | type LogErrorsOptions = Partial<{ 63 | filename: string; 64 | count: boolean; 65 | }>; 66 | 67 | export function logErrors( 68 | errors: LintError[], 69 | schema: Swagger.SwaggerObject | OpenAPI.OpenAPIObject | void, 70 | options: LogErrorsOptions = {}, 71 | ): void { 72 | if (options.filename) console.log(`\n${bold(options.filename)}`); 73 | 74 | console.log(errors.map(x => toOneLinerFormat(x, schema)).join('\n')); 75 | 76 | if (options.count) logErrorCount(errors.length); 77 | } 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swaggerlint", 3 | "version": "0.0.10", 4 | "description": "Swaggerlint helps you to have a consistent API style by linting your swagger / OpenAPI Scheme.", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "build": "rm -rf ./dist && tsc", 8 | "types": "tsc --noEmit", 9 | "lint": "eslint . --ext=.js,.ts", 10 | "dev": "ts-node ./src/bin.ts", 11 | "test": "jest", 12 | "update-rules": "ts-node ./scripts/update-rules.ts", 13 | "update-types": "ts-node ./scripts/update-types.ts", 14 | "update-readme": "ts-node ./scripts/update-readme.ts", 15 | "update-all-files": "ts-node ./scripts/update-all-files.ts && mmac-check --update-script \"npm run update-all-files\"", 16 | "preversion": "npm run test && npm run lint && npm run build", 17 | "postversion": "npm publish && git push --follow-tags" 18 | }, 19 | "bin": { 20 | "swaggerlint": "./dist/bin.js" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/antonk52/swaggerlint.git" 25 | }, 26 | "keywords": [ 27 | "swagger", 28 | "openapi" 29 | ], 30 | "author": "antonk52", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/antonk52/swaggerlint/issues" 34 | }, 35 | "devDependencies": { 36 | "@types/jest": "^26.0.15", 37 | "@types/js-yaml": "^3.12.5", 38 | "@types/mime-db": "^1.43.0", 39 | "@types/minimist": "^1.2.0", 40 | "@types/node": "^14.14.6", 41 | "@types/node-fetch": "^2.5.7", 42 | "@typescript-eslint/eslint-plugin": "^4.6.0", 43 | "@typescript-eslint/parser": "^4.6.0", 44 | "deepmerge": "^4.2.2", 45 | "eslint": "^7.12.1", 46 | "eslint-config-prettier": "^6.15.0", 47 | "eslint-plugin-prettier": "^3.1.4", 48 | "jest": "^26.6.1", 49 | "make-me-a-content": "^0.3.0", 50 | "prettier": "^2.1.2", 51 | "ts-jest": "^26.4.3", 52 | "ts-node": "^9.0.0", 53 | "typescript": "^4.0.5" 54 | }, 55 | "dependencies": { 56 | "ajv": "^6.12.6", 57 | "case": "1.6.3", 58 | "cosmiconfig": "7.0.0", 59 | "escape-string-regexp": "4.0.0", 60 | "js-yaml": "3.14.0", 61 | "kleur": "^4.1.3", 62 | "lodash.get": "4.4.2", 63 | "mime-db": "^1.45.0", 64 | "minimist": "1.2.6", 65 | "node-fetch": "2.6.7" 66 | }, 67 | "engines": { 68 | "node": ">=10" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/rules/required-tag-description/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for a tag missing description', 17 | schema: { 18 | tags: [ 19 | { 20 | name: 'no-description', 21 | }, 22 | { 23 | name: 'with-description', 24 | description: 'some description about the tag', 25 | }, 26 | ], 27 | }, 28 | errors: [ 29 | { 30 | msg: 'Tag "no-description" is missing description.', 31 | location: ['tags', '0'], 32 | messageId: 'missingDesc', 33 | data: { 34 | name: 'no-description', 35 | }, 36 | }, 37 | ], 38 | }, 39 | ], 40 | }, 41 | openapi: { 42 | valid: [ 43 | { 44 | it: 'should NOT error for an empty openapi sample', 45 | schema: {}, 46 | }, 47 | ], 48 | invalid: [ 49 | { 50 | it: 'should error for a tag missing description', 51 | schema: { 52 | tags: [ 53 | { 54 | name: 'no-description', 55 | }, 56 | { 57 | name: 'with-description', 58 | description: 'some description about the tag', 59 | }, 60 | ], 61 | }, 62 | errors: [ 63 | { 64 | msg: 'Tag "no-description" is missing description.', 65 | name: 'required-tag-description', 66 | messageId: 'missingDesc', 67 | data: { 68 | name: 'no-description', 69 | }, 70 | location: ['tags', '0'], 71 | }, 72 | ], 73 | }, 74 | ], 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /src/rules/no-ref-properties/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for a ReferenceObject containing description', 17 | schema: { 18 | paths: {}, 19 | definitions: { 20 | Example: { 21 | type: 'object', 22 | properties: { 23 | prop: { 24 | $ref: '#/definitions/Foo', 25 | description: 'Foo bar', 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | errors: [ 32 | { 33 | name: rule.name, 34 | messageId: 'noRefProps', 35 | location: [ 36 | 'definitions', 37 | 'Example', 38 | 'properties', 39 | 'prop', 40 | ], 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | openapi: { 47 | valid: [ 48 | { 49 | it: 'should NOT error for an empty openapi sample', 50 | schema: {}, 51 | }, 52 | ], 53 | invalid: [ 54 | { 55 | it: 'errors for a reference object containing description', 56 | schema: { 57 | paths: {}, 58 | components: { 59 | schemas: { 60 | Example: { 61 | type: 'object', 62 | properties: { 63 | foo: { 64 | $ref: '#/components/schemas/Foo', 65 | description: 'Foo prop', 66 | }, 67 | }, 68 | }, 69 | }, 70 | }, 71 | }, 72 | errors: [ 73 | { 74 | name: rule.name, 75 | messageId: 'noRefProps', 76 | location: [ 77 | 'components', 78 | 'schemas', 79 | 'Example', 80 | 'properties', 81 | 'foo', 82 | ], 83 | }, 84 | ], 85 | }, 86 | ], 87 | }, 88 | }); 89 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type {JSONSchema7} from 'json-schema'; 2 | import {SwaggerVisitorName, SwaggerlintRule} from '../types'; 3 | export * from './common'; 4 | import {validate} from './validate-json'; 5 | 6 | const isDev = process.env.NODE_ENV === 'development'; 7 | 8 | export const log = isDev 9 | ? (x: string): void => console.log(`--> ${x}`) 10 | : (): null => null; 11 | 12 | /** 13 | * TODO: extract swagger v2 specific utils into separate file 14 | */ 15 | 16 | const swaggerVisitorSet = new Set([ 17 | 'SwaggerObject', 18 | 'InfoObject', 19 | 'PathsObject', 20 | 'DefinitionsObject', 21 | 'ParametersDefinitionsObject', 22 | 'ResponsesDefinitionsObject', 23 | 'SecurityDefinitionsObject', 24 | 'SecuritySchemeObject', 25 | 'ScopesObject', 26 | 'SecurityRequirementObject', 27 | 'TagObject', 28 | 'ExternalDocumentationObject', 29 | 'ContactObject', 30 | 'LicenseObject', 31 | 'PathItemObject', 32 | 'OperationObject', 33 | 'ParameterObject', 34 | 'ResponsesObject', 35 | 'ReferenceObject', 36 | 'ResponseObject', 37 | 'SchemaObject', 38 | 'XMLObject', 39 | 'HeadersObject', 40 | 'HeaderObject', 41 | 'ItemsObject', 42 | 'ExampleObject', 43 | ]); 44 | export function isValidSwaggerVisitorName( 45 | name: string, 46 | ): name is SwaggerVisitorName { 47 | return swaggerVisitorSet.has(name); 48 | } 49 | 50 | type ValidCasesObj = { 51 | camel: Set<'camel' | 'lower'>; 52 | constant: Set<'constant'>; 53 | kebab: Set<'kebab' | 'lower'>; 54 | pascal: Set<'pascal'>; 55 | snake: Set<'snake' | 'lower'>; 56 | }; 57 | export const validCases: ValidCasesObj = { 58 | camel: new Set(['camel', 'lower']), // someName 59 | constant: new Set(['constant']), // SOME_NAME 60 | kebab: new Set(['kebab', 'lower']), // some-name 61 | pascal: new Set(['pascal']), // SomeName 62 | snake: new Set(['snake', 'lower']), // some_name 63 | }; 64 | 65 | export function isValidCaseName( 66 | name: string | void, 67 | ): name is keyof typeof validCases { 68 | return typeof name === 'string' && name in validCases; 69 | } 70 | 71 | const ruleJsonSchema: JSONSchema7 = { 72 | type: 'object', 73 | required: ['name'], 74 | properties: { 75 | name: { 76 | type: 'string', 77 | }, 78 | meta: { 79 | type: 'object', 80 | properties: { 81 | messages: { 82 | type: 'object', 83 | }, 84 | schema: { 85 | type: 'object', 86 | }, 87 | }, 88 | }, 89 | openapiVisitor: { 90 | type: 'object', 91 | }, 92 | swaggerVisitor: { 93 | type: 'object', 94 | }, 95 | }, 96 | additionalProperties: true, 97 | }; 98 | 99 | export function createRule( 100 | rule: SwaggerlintRule, 101 | ): typeof rule { 102 | const errors = validate(ruleJsonSchema, rule); 103 | 104 | if (errors[0]) throw errors[0]; 105 | 106 | return rule; 107 | } 108 | -------------------------------------------------------------------------------- /src/rules/required-operation-tags/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for a tag missing description', 17 | schema: { 18 | paths: { 19 | '/url': { 20 | get: { 21 | responses: { 22 | default: { 23 | description: 'default response', 24 | schema: { 25 | type: 'string', 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | errors: [ 34 | { 35 | msg: 'Operation "get" in "/url" is missing tags.', 36 | name: 'required-operation-tags', 37 | data: { 38 | method: 'get', 39 | url: '/url', 40 | }, 41 | location: ['paths', '/url', 'get'], 42 | messageId: 'missingTags', 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | openapi: { 49 | valid: [ 50 | { 51 | it: 'should NOT error for an empty swagger sample', 52 | schema: {}, 53 | }, 54 | ], 55 | invalid: [ 56 | { 57 | it: 'should error for a tag missing description', 58 | schema: { 59 | paths: { 60 | '/url': { 61 | get: { 62 | responses: { 63 | default: { 64 | description: 'default response', 65 | schema: { 66 | type: 'string', 67 | }, 68 | }, 69 | }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | errors: [ 75 | { 76 | msg: 'Operation "get" in "/url" is missing tags.', 77 | name: 'required-operation-tags', 78 | data: { 79 | method: 'get', 80 | url: '/url', 81 | }, 82 | location: ['paths', '/url', 'get'], 83 | messageId: 'missingTags', 84 | }, 85 | ], 86 | }, 87 | ], 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /src/rules/path-param-required-field/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should not error for an empty schema', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for parameters missing "required" property', 17 | schema: { 18 | paths: { 19 | '/url': { 20 | get: { 21 | parameters: [ 22 | { 23 | in: 'query', 24 | name: 'sample', 25 | type: 'string', 26 | }, 27 | ], 28 | responses: { 29 | default: { 30 | description: 'default response', 31 | schema: { 32 | $ref: '#/definitions/lolkekDTO', 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | errors: [ 41 | { 42 | data: { 43 | name: 'sample', 44 | }, 45 | messageId: 'requiredField', 46 | msg: 47 | 'Parameter "sample" is missing "required" property', 48 | name: rule.name, 49 | location: ['paths', '/url', 'get', 'parameters', '0'], 50 | }, 51 | ], 52 | }, 53 | ], 54 | }, 55 | openapi: { 56 | valid: [ 57 | { 58 | it: 'should NOT error for an empty swagger sample', 59 | schema: {}, 60 | }, 61 | ], 62 | invalid: [ 63 | { 64 | it: 'should error for parameters missing "required" property', 65 | schema: { 66 | components: { 67 | parameters: { 68 | sample: { 69 | in: 'query', 70 | name: 'sample', 71 | schema: { 72 | $ref: '', 73 | }, 74 | }, 75 | }, 76 | }, 77 | }, 78 | errors: [ 79 | { 80 | data: { 81 | name: 'sample', 82 | }, 83 | messageId: 'requiredField', 84 | msg: 85 | 'Parameter "sample" is missing "required" property', 86 | location: ['components', 'parameters', 'sample'], 87 | }, 88 | ], 89 | }, 90 | ], 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import {swaggerlint} from './swaggerlint'; 2 | import { 3 | LintError, 4 | CliOptions, 5 | CliResult, 6 | EntryResult, 7 | Swagger, 8 | OpenAPI, 9 | } from './types'; 10 | import {getConfig} from './utils/config'; 11 | import {getSwaggerByPath, getSwaggerByUrl} from './utils/swaggerfile'; 12 | import {log} from './utils'; 13 | 14 | const name = 'swaggerlint-core'; 15 | 16 | function preLintError({src, msg}: {src: string; msg: string}): EntryResult { 17 | return { 18 | src, 19 | errors: [{name, msg, location: []}], 20 | schema: undefined, 21 | }; 22 | } 23 | 24 | export async function cli(opts: CliOptions): Promise { 25 | if (opts._.length === 0) { 26 | return { 27 | code: 1, 28 | results: [ 29 | preLintError({ 30 | msg: 31 | 'Neither url nor path were provided for your swagger scheme', 32 | src: '', 33 | }), 34 | ], 35 | }; 36 | } 37 | 38 | let schema: void | Swagger.SwaggerObject | OpenAPI.OpenAPIObject; 39 | 40 | const configResult = getConfig(opts.config); 41 | if (configResult.type === 'fail') { 42 | return { 43 | code: 1, 44 | results: [preLintError({msg: configResult.error, src: ''})], 45 | }; 46 | } 47 | 48 | if (configResult.type === 'error') { 49 | return { 50 | code: 1, 51 | results: [ 52 | preLintError({ 53 | msg: configResult.error, 54 | src: configResult.filepath, 55 | }), 56 | ], 57 | }; 58 | } 59 | 60 | const {config} = configResult; 61 | 62 | const result: Promise[] = opts._.map(async schemaPath => { 63 | /** 64 | * handling `swagger-lint https://...` 65 | */ 66 | if (schemaPath.startsWith('http')) { 67 | const url = schemaPath; 68 | log(`fetching for ${url}`); 69 | type FromUrl = Swagger.SwaggerObject | OpenAPI.OpenAPIObject | null; 70 | const swaggerFromUrl: FromUrl = await getSwaggerByUrl(url).catch( 71 | (e: string) => { 72 | log('error fetching url'); 73 | log(e); 74 | 75 | return null; 76 | }, 77 | ); 78 | if (swaggerFromUrl === null) { 79 | return preLintError({ 80 | msg: 'Cannot fetch swagger scheme from the provided url', 81 | src: url, 82 | }); 83 | } else { 84 | log(`got response`); 85 | schema = Object.freeze(swaggerFromUrl); 86 | const errors: LintError[] = swaggerlint(swaggerFromUrl, config); 87 | 88 | const res: EntryResult = { 89 | src: url, 90 | schema, 91 | errors, 92 | }; 93 | 94 | return res; 95 | } 96 | } else { 97 | /** 98 | * handling `swagger-lint /path/to/swagger.json` 99 | */ 100 | const result = getSwaggerByPath(schemaPath); 101 | 102 | if ('error' in result) { 103 | return preLintError({msg: result.error, src: schemaPath}); 104 | } 105 | 106 | const errors: LintError[] = swaggerlint(result.swagger, config); 107 | 108 | const res: EntryResult = { 109 | src: schemaPath, 110 | errors, 111 | schema: Object.freeze(result.swagger), 112 | }; 113 | 114 | return res; 115 | } 116 | }); 117 | 118 | const results = await Promise.all(result); 119 | 120 | return { 121 | results, 122 | code: results.every(x => x.errors.length === 0) ? 0 : 1, 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /src/rules/latin-definitions-only/index.ts: -------------------------------------------------------------------------------- 1 | import escapeStringRegexp from 'escape-string-regexp'; 2 | import {componentsKeys} from '../../utils/openapi'; 3 | import {createRule} from '../../utils'; 4 | 5 | const name = 'latin-definitions-only'; 6 | 7 | function replaceLatinCharacters(str: string): string { 8 | return ( 9 | str 10 | // def name may contain latin chars 11 | .replace(/[a-z]+/gi, '') 12 | // or numerals 13 | .replace(/\d/gi, '') 14 | ); 15 | } 16 | 17 | function replaceIgnoredChars(str: string, ignoredChars: string[]): string { 18 | if (ignoredChars.length === 0) return str; 19 | 20 | const re = new RegExp( 21 | `[${ignoredChars.map(x => escapeStringRegexp(x)).join('')}]`, 22 | 'g', 23 | ); 24 | return str.replace(re, ''); 25 | } 26 | 27 | const rule = createRule({ 28 | name, 29 | docs: { 30 | recommended: true, 31 | description: 'Bans non Latin characters usage in definition names', 32 | }, 33 | meta: { 34 | messages: { 35 | msg: `Definition name "{{name}}" contains non latin characters.`, 36 | }, 37 | schema: { 38 | type: 'array', 39 | items: [ 40 | { 41 | type: 'string', 42 | }, 43 | { 44 | type: 'object', 45 | required: ['ignore'], 46 | properties: { 47 | ignore: { 48 | type: 'array', 49 | items: { 50 | type: 'string', 51 | minLength: 1, 52 | maxLength: 1, 53 | }, 54 | }, 55 | }, 56 | }, 57 | ], 58 | minItems: 1, 59 | maxItems: 2, 60 | }, 61 | }, 62 | swaggerVisitor: { 63 | DefinitionsObject: ({node, report, location, setting}): void => { 64 | const charsToIgnore = (Array.isArray(setting) 65 | ? setting[1]?.ignore || [] 66 | : []) as string[]; 67 | const definitionNames = Object.keys(node); 68 | definitionNames.forEach(name => { 69 | const cleanStr = replaceLatinCharacters(name); 70 | const rest = replaceIgnoredChars(cleanStr, charsToIgnore); 71 | 72 | if (rest.length > 0) { 73 | report({ 74 | messageId: 'msg', 75 | data: { 76 | name, 77 | }, 78 | location: [...location, name], 79 | }); 80 | } 81 | }); 82 | }, 83 | }, 84 | openapiVisitor: { 85 | ComponentsObject: ({node, location, report, setting}): void => { 86 | const charsToIgnore = (Array.isArray(setting) 87 | ? setting[1]?.ignore || [] 88 | : []) as string[]; 89 | componentsKeys.forEach(compName => { 90 | const val = node[compName]; 91 | if (val === undefined) return; 92 | Object.keys(val).forEach(recName => { 93 | const cleanStr = replaceLatinCharacters(recName); 94 | const rest = replaceIgnoredChars(cleanStr, charsToIgnore); 95 | 96 | if (rest.length > 0) { 97 | report({ 98 | messageId: 'msg', 99 | data: { 100 | name: recName, 101 | }, 102 | location: [...location, compName, recName], 103 | }); 104 | } 105 | }); 106 | }); 107 | }, 108 | }, 109 | defaultSetting: ['placeholder_to_be_removed', {ignore: []}], 110 | }); 111 | 112 | export default rule; 113 | -------------------------------------------------------------------------------- /scripts/update-types.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs'; 2 | import * as path from 'path'; 3 | import {mmac} from 'make-me-a-content'; 4 | import {format, Options} from 'prettier'; 5 | 6 | const prettierConfig = require('../prettier.config') as Options; 7 | 8 | const typesDir = path.join(__dirname, '..', 'src', 'types'); 9 | const paths = { 10 | swaggerlint: path.join(typesDir, 'swaggerlint.ts'), 11 | openapi: path.join(typesDir, 'openapi.ts'), 12 | swagger: path.join(typesDir, 'swagger.ts'), 13 | }; 14 | 15 | const alwaysOneVisitor = (_namespace: string) => (name: string) => 16 | `${name}: [NodeWithLocation<${_namespace}.${name}>];`; 17 | const oneOrNoVisitors = (_namespace: string) => (name: string) => 18 | `${name}: [NodeWithLocation<${_namespace}.${name}>] | [];`; 19 | const manyVisitors = (_namespace: string) => (name: string) => 20 | `${name}: NodeWithLocation<${_namespace}.${name}>[];`; 21 | 22 | const openapiTypeToVisitorPredicate: [string[], (arg: string) => string][] = [ 23 | [ 24 | ['InfoObject', 'OpenAPIObject', 'PathsObject'], 25 | alwaysOneVisitor('OpenAPI'), 26 | ], 27 | [ 28 | // prettier 29 | ['ComponentsObject'], 30 | oneOrNoVisitors('OpenAPI'), 31 | ], 32 | ]; 33 | 34 | const swaggerTypeToVisitorPredicate: [string[], (arg: string) => string][] = [ 35 | [ 36 | ['InfoObject', 'SwaggerObject', 'PathsObject'], 37 | alwaysOneVisitor('Swagger'), 38 | ], 39 | [ 40 | [ 41 | 'DefinitionsObject', 42 | 'ParametersDefinitionsObject', 43 | 'ResponsesDefinitionsObject', 44 | 'SecurityDefinitionsObject', 45 | 'ContactObject', 46 | 'LicenseObject', 47 | ], 48 | oneOrNoVisitors('Swagger'), 49 | ], 50 | ]; 51 | 52 | function isString(arg: unknown): arg is string { 53 | return typeof arg === 'string'; 54 | } 55 | 56 | async function makeTypesFor(target: 'swagger' | 'openapi') { 57 | const {nameSpace, predicates} = { 58 | swagger: { 59 | nameSpace: 'Swagger', 60 | predicates: swaggerTypeToVisitorPredicate, 61 | }, 62 | openapi: { 63 | nameSpace: 'OpenAPI', 64 | predicates: openapiTypeToVisitorPredicate, 65 | }, 66 | }[target]; 67 | 68 | const typesContent = await fs.readFile(paths[target]); 69 | const typeNames = typesContent 70 | .toString() 71 | .split('\n') 72 | .filter(line => line.startsWith('export type ')) 73 | .map(line => line.match(/export type (\w+).*/)?.[1]) 74 | .filter(isString) 75 | .sort(); 76 | 77 | const visitorsLines = [ 78 | `export type ${nameSpace}Visitors = {`, 79 | ...typeNames.map(name => { 80 | const foundPredicate = predicates.find(x => x[0].includes(name)); 81 | 82 | return foundPredicate?.[1](name) ?? manyVisitors(nameSpace)(name); 83 | }), 84 | '};', 85 | '', 86 | `export type ${nameSpace}VisitorName = keyof ${nameSpace}RuleVisitor<''>;`, 87 | ]; 88 | 89 | const typesLines = [ 90 | `export type ${nameSpace}Types = {`, 91 | ...typeNames.map(name => `${name}: ${nameSpace}.${name};`), 92 | '};', 93 | ]; 94 | 95 | const ruleVisitorLines = [ 96 | `export type ${nameSpace}RuleVisitor = Partial<{`, 97 | ...typeNames.map( 98 | name => `${name}: RuleVisitorFunction<${nameSpace}.${name}, M>;`, 99 | ), 100 | '}>;', 101 | ]; 102 | 103 | const lines = [ 104 | ...visitorsLines, 105 | '', 106 | ...typesLines, 107 | '', 108 | ...ruleVisitorLines, 109 | ]; 110 | 111 | await mmac({ 112 | id: target, 113 | filepath: paths.swaggerlint, 114 | updateScript: 'npm run update-types', 115 | lines, 116 | transform: x => format(x, {parser: 'typescript', ...prettierConfig}), 117 | }); 118 | } 119 | 120 | export async function updateTypes() { 121 | await makeTypesFor('swagger'); 122 | await makeTypesFor('openapi'); 123 | 124 | console.log('✅types are updated'); 125 | } 126 | 127 | if (require.main === module) 128 | updateTypes().catch(e => { 129 | console.error(e); 130 | process.exit(1); 131 | }); 132 | -------------------------------------------------------------------------------- /src/rules/no-inline-enums/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'errors for "allOf" property containing a single item', 17 | schema: { 18 | paths: { 19 | '/url': { 20 | get: { 21 | responses: { 22 | default: { 23 | description: 'default response', 24 | schema: { 25 | type: 'string', 26 | enum: ['foo', 'bar'], 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | definitions: { 34 | Example: { 35 | type: 'string', 36 | enum: ['foo', 'bar'], 37 | }, 38 | }, 39 | }, 40 | errors: [ 41 | { 42 | msg: 43 | 'Inline enums are not allowed. Move this SchemaObject to DefinitionsObject', 44 | name: rule.name, 45 | messageId: 'swagger', 46 | location: [ 47 | 'paths', 48 | '/url', 49 | 'get', 50 | 'responses', 51 | 'default', 52 | 'schema', 53 | ], 54 | }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | openapi: { 60 | valid: [ 61 | { 62 | it: 'should NOT error for an empty swagger sample', 63 | schema: {}, 64 | }, 65 | ], 66 | invalid: [ 67 | { 68 | it: 'errors for "allOf" property containing a single item', 69 | schema: { 70 | paths: { 71 | '/url': { 72 | get: { 73 | responses: { 74 | '200': { 75 | content: { 76 | 'application/json': { 77 | schema: { 78 | type: 'string', 79 | enum: ['foo', 'bar'], 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | components: { 89 | schemas: { 90 | Example: { 91 | type: 'string', 92 | enum: ['foo', 'bar'], 93 | }, 94 | }, 95 | }, 96 | }, 97 | errors: [ 98 | { 99 | msg: 100 | 'Inline enums are not allowed. Move this SchemaObject to ComponentsObject', 101 | name: rule.name, 102 | messageId: 'openapi', 103 | location: [ 104 | 'paths', 105 | '/url', 106 | 'get', 107 | 'responses', 108 | '200', 109 | 'content', 110 | 'application/json', 111 | 'schema', 112 | ], 113 | }, 114 | ], 115 | }, 116 | ], 117 | }, 118 | }); 119 | -------------------------------------------------------------------------------- /src/spec/swaggerlint.spec.ts: -------------------------------------------------------------------------------- 1 | import {swaggerlint} from '../swaggerlint'; 2 | import {SwaggerlintConfig} from '../types'; 3 | import * as walker from '../walker'; 4 | import {getSwaggerObject} from '../utils/tests'; 5 | 6 | jest.mock('../walker', () => ({ 7 | walkSwagger: jest.fn(), 8 | walkOpenAPI: jest.fn(), 9 | })); 10 | jest.mock('../defaultConfig', () => ({ 11 | rules: { 12 | 'known-rule': true, 13 | }, 14 | })); 15 | jest.mock('../rules', () => ({ 16 | rules: { 17 | 'known-rule': { 18 | name: 'known-rule', 19 | meta: { 20 | schema: { 21 | type: 'array', 22 | items: [ 23 | { 24 | type: 'string', 25 | enum: ['known', 'words'], 26 | }, 27 | { 28 | type: 'object', 29 | required: ['ignore'], 30 | properties: { 31 | ignore: { 32 | type: 'array', 33 | items: { 34 | type: 'string', 35 | }, 36 | }, 37 | }, 38 | }, 39 | ], 40 | }, 41 | }, 42 | swaggerVisitor: {}, 43 | defaultSetting: ['known', {}], 44 | }, 45 | 'always-valid-rule': { 46 | swaggerVisitor: {}, 47 | }, 48 | }, 49 | })); 50 | 51 | describe('swaggerlint', () => { 52 | const swagger = getSwaggerObject({}); 53 | 54 | it('returns and error when walker returns errors', () => { 55 | const errors = [ 56 | { 57 | msg: 'err parsing swagger', 58 | location: [], 59 | name: 'swaggerlint-core', 60 | }, 61 | ]; 62 | const config = {rules: {}}; 63 | 64 | (walker.walkSwagger as jest.Mock).mockReturnValueOnce({errors}); 65 | 66 | const result = swaggerlint(swagger, config); 67 | 68 | expect(result).toEqual(errors); 69 | }); 70 | 71 | it('throws an error only for unknown rules', () => { 72 | (walker.walkSwagger as jest.Mock).mockReturnValueOnce({ 73 | visitors: { 74 | VisitorExample: [], 75 | }, 76 | }); 77 | 78 | const config = { 79 | rules: { 80 | 'unknown-rule': true, 81 | 'always-valid-rule': true, 82 | }, 83 | }; 84 | 85 | const result = swaggerlint(swagger, config); 86 | 87 | expect(result).toEqual([ 88 | { 89 | msg: `swaggerlint.config.js contains unknown rule "unknown-rule"`, 90 | name: 'swaggerlint-core', 91 | location: [], 92 | }, 93 | ]); 94 | }); 95 | 96 | it('has an error when no rules are enabled', () => { 97 | (walker.walkSwagger as jest.Mock).mockReturnValueOnce({ 98 | visitors: { 99 | visitorexample: [], 100 | }, 101 | }); 102 | 103 | const config = { 104 | rules: { 105 | 'known-rule': false, 106 | }, 107 | }; 108 | 109 | const result = swaggerlint(swagger, config); 110 | 111 | expect(result).toEqual([ 112 | { 113 | location: [], 114 | msg: 115 | 'Found 0 enabled rules. Swaggerlint requires at least one rule enabled.', 116 | name: 'swaggerlint-core', 117 | }, 118 | ]); 119 | }); 120 | 121 | it('returns an error when rule setting validation does not pass', () => { 122 | (walker.walkSwagger as jest.Mock).mockReturnValueOnce({ 123 | visitors: { 124 | visitorexample: [], 125 | }, 126 | }); 127 | 128 | const config: SwaggerlintConfig = { 129 | rules: { 130 | 'known-rule': ['invalid-setting'], 131 | 'always-valid-rule': true, 132 | }, 133 | }; 134 | const result = swaggerlint(swagger, config); 135 | expect(result).toEqual([ 136 | { 137 | msg: 138 | 'Invalid rule setting. Got "invalid-setting", expected: "known", "words"', 139 | location: [], 140 | name: 'known-rule', 141 | }, 142 | ]); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/rules/only-valid-mime-types/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../ruleTester'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for all non camel cased property names', 17 | schema: { 18 | paths: { 19 | '/url': { 20 | get: { 21 | responses: { 22 | default: { 23 | description: 'default response', 24 | schema: { 25 | $ref: '#/definitions/lolkekDTO', 26 | }, 27 | }, 28 | }, 29 | consumes: ['not/valid'], 30 | produces: ['*/*'], 31 | }, 32 | }, 33 | }, 34 | produces: ['application/typescript'], 35 | consumes: ['lol/kek'], 36 | }, 37 | errors: [ 38 | { 39 | data: { 40 | mimeType: 'lol/kek', 41 | }, 42 | messageId: 'invalid', 43 | msg: '"lol/kek" is not a valid mime type.', 44 | location: ['consumes', '0'], 45 | }, 46 | { 47 | data: { 48 | mimeType: 'application/typescript', 49 | }, 50 | msg: 51 | '"application/typescript" is not a valid mime type.', 52 | messageId: 'invalid', 53 | location: ['produces', '0'], 54 | }, 55 | { 56 | data: { 57 | mimeType: 'not/valid', 58 | }, 59 | messageId: 'invalid', 60 | msg: '"not/valid" is not a valid mime type.', 61 | location: ['paths', '/url', 'get', 'consumes', '0'], 62 | }, 63 | { 64 | data: { 65 | mimeType: '*/*', 66 | }, 67 | messageId: 'invalid', 68 | msg: '"*/*" is not a valid mime type.', 69 | location: ['paths', '/url', 'get', 'produces', '0'], 70 | }, 71 | ], 72 | }, 73 | ], 74 | }, 75 | openapi: { 76 | valid: [ 77 | { 78 | it: 'should NOT error for an empty swagger sample', 79 | schema: {}, 80 | }, 81 | ], 82 | invalid: [ 83 | { 84 | it: 'should error for all non camel cased property names', 85 | schema: { 86 | components: { 87 | responses: { 88 | someReponse: { 89 | content: { 90 | 'application/foo': { 91 | schema: { 92 | $ref: '#/components/schemas/resp', 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | }, 99 | }, 100 | errors: [ 101 | { 102 | data: { 103 | mimeType: 'application/foo', 104 | }, 105 | messageId: 'invalid', 106 | msg: '"application/foo" is not a valid mime type.', 107 | location: [ 108 | 'components', 109 | 'responses', 110 | 'someReponse', 111 | 'content', 112 | 'application/foo', 113 | ], 114 | }, 115 | ], 116 | }, 117 | ], 118 | }, 119 | }); 120 | -------------------------------------------------------------------------------- /src/rules/object-prop-casing/index.ts: -------------------------------------------------------------------------------- 1 | import Case from 'case'; 2 | import {createRule} from '../../utils'; 3 | import {validCases, isValidCaseName} from '../../utils'; 4 | 5 | const name = 'object-prop-casing'; 6 | 7 | const rule = createRule({ 8 | name, 9 | docs: { 10 | recommended: true, 11 | description: 'Casing for your object property names', 12 | }, 13 | meta: { 14 | messages: { 15 | casing: 16 | 'Property "{{propName}}" has wrong casing. Should be "{{correctVersion}}".', 17 | }, 18 | schema: { 19 | type: 'array', 20 | items: [ 21 | { 22 | type: 'string', 23 | enum: Object.keys(validCases), 24 | }, 25 | { 26 | type: 'object', 27 | required: ['ignore'], 28 | properties: { 29 | ignore: { 30 | type: 'array', 31 | items: { 32 | type: 'string', 33 | }, 34 | }, 35 | }, 36 | }, 37 | ], 38 | minItems: 1, 39 | maxItems: 2, 40 | }, 41 | }, 42 | swaggerVisitor: { 43 | SchemaObject: ({node, report, setting, location}): void => { 44 | if (typeof setting === 'boolean') return; 45 | 46 | const [settingCasingName, opts = {}] = setting; 47 | const IGNORE_PROPERTIES = new Set( 48 | Array.isArray(opts.ignore) ? opts.ignore : [], 49 | ); 50 | if ( 51 | typeof settingCasingName === 'string' && 52 | isValidCaseName(settingCasingName) 53 | ) { 54 | const validPropCases: Set = 55 | validCases[settingCasingName]; 56 | if ('properties' in node && node.properties) { 57 | Object.keys(node.properties).forEach(propName => { 58 | if (IGNORE_PROPERTIES.has(propName)) return; 59 | const propCase = Case.of(propName); 60 | if (!validPropCases.has(propCase)) { 61 | const correctVersion = Case[settingCasingName]( 62 | propName, 63 | ); 64 | 65 | report({ 66 | messageId: 'casing', 67 | data: { 68 | propName, 69 | correctVersion, 70 | }, 71 | location: [...location, 'properties', propName], 72 | }); 73 | } 74 | }); 75 | } 76 | return; 77 | } 78 | }, 79 | }, 80 | openapiVisitor: { 81 | SchemaObject: ({node, report, location, setting}): void => { 82 | if (typeof setting === 'boolean') return; 83 | 84 | const [settingCasingName, opts = {}] = setting; 85 | const IGNORE_PROPERTIES = new Set( 86 | Array.isArray(opts.ignore) ? opts.ignore : [], 87 | ); 88 | if ( 89 | !( 90 | typeof settingCasingName === 'string' && 91 | isValidCaseName(settingCasingName) 92 | ) 93 | ) 94 | return; 95 | 96 | const validPropCases: Set = validCases[settingCasingName]; 97 | if ( 98 | 'properties' in node && 99 | node.properties && 100 | typeof node.properties === 'object' 101 | ) { 102 | Object.keys(node.properties).forEach(propName => { 103 | if (IGNORE_PROPERTIES.has(propName)) return; 104 | const propCase = Case.of(propName); 105 | if (!validPropCases.has(propCase)) { 106 | const correctVersion = Case[settingCasingName]( 107 | propName, 108 | ); 109 | 110 | report({ 111 | messageId: 'casing', 112 | data: { 113 | propName, 114 | correctVersion, 115 | }, 116 | location: [...location, 'properties', propName], 117 | }); 118 | } 119 | }); 120 | } 121 | }, 122 | }, 123 | defaultSetting: ['camel'], 124 | }); 125 | 126 | export default rule; 127 | -------------------------------------------------------------------------------- /src/rules/no-trailing-slash/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for a url ending with a slash', 17 | schema: { 18 | paths: { 19 | '/url/': { 20 | get: { 21 | responses: { 22 | default: { 23 | description: 'default response', 24 | schema: { 25 | type: 'string', 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | '/correct-url': { 32 | get: { 33 | responses: { 34 | default: { 35 | description: 'default response', 36 | schema: { 37 | type: 'string', 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | errors: [ 46 | { 47 | data: { 48 | url: '/url/', 49 | }, 50 | messageId: 'url', 51 | msg: 'Url cannot end with a slash "/url/".', 52 | name: 'no-trailing-slash', 53 | location: ['paths', '/url/'], 54 | }, 55 | ], 56 | }, 57 | { 58 | it: 'should error for a host url ending with a slash', 59 | schema: { 60 | host: 'http://some.url/', 61 | }, 62 | errors: [ 63 | { 64 | data: { 65 | url: 'http://some.url/', 66 | }, 67 | msg: 68 | 'Host cannot end with a slash, your host url is "http://some.url/".', 69 | messageId: 'host', 70 | name: 'no-trailing-slash', 71 | location: ['host'], 72 | }, 73 | ], 74 | }, 75 | ], 76 | }, 77 | openapi: { 78 | valid: [ 79 | { 80 | it: 'should not error for an empty schema', 81 | schema: {}, 82 | }, 83 | ], 84 | invalid: [ 85 | { 86 | it: 'should error for a url ending with a slash', 87 | schema: { 88 | paths: { 89 | '/url/': { 90 | get: { 91 | responses: { 92 | default: { 93 | description: 'default response', 94 | schema: { 95 | type: 'string', 96 | }, 97 | }, 98 | }, 99 | }, 100 | }, 101 | '/correct-url': { 102 | get: { 103 | responses: { 104 | default: { 105 | description: 'default response', 106 | schema: { 107 | type: 'string', 108 | }, 109 | }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | errors: [ 116 | { 117 | data: { 118 | url: '/url/', 119 | }, 120 | messageId: 'url', 121 | msg: 'Url cannot end with a slash "/url/".', 122 | name: 'no-trailing-slash', 123 | location: ['paths', '/url/'], 124 | }, 125 | ], 126 | }, 127 | { 128 | it: 'should error for a server url ending with a slash', 129 | schema: { 130 | servers: [{url: 'http://some.url/'}], 131 | }, 132 | errors: [ 133 | { 134 | data: { 135 | url: 'http://some.url/', 136 | }, 137 | msg: 138 | 'Server url cannot end with a slash "http://some.url/".', 139 | messageId: 'server', 140 | name: 'no-trailing-slash', 141 | location: ['servers', '0', 'url'], 142 | }, 143 | ], 144 | }, 145 | ], 146 | }, 147 | }); 148 | -------------------------------------------------------------------------------- /src/rules/no-empty-object-type/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for empty object type', 17 | schema: { 18 | definitions: { 19 | InvalidDTO: { 20 | type: 'object', 21 | }, 22 | ValidDTO: { 23 | type: 'object', 24 | additionalProperties: { 25 | type: 'string', 26 | }, 27 | properties: { 28 | a: { 29 | type: 'string', 30 | }, 31 | AllOf: { 32 | type: 'object', 33 | allOf: [ 34 | { 35 | $ref: 36 | '#/components/schemas/InvalidDTO', 37 | }, 38 | { 39 | $ref: 40 | '#/components/schemas/ValidDTO', 41 | }, 42 | ], 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | errors: [ 49 | { 50 | msg: `has "object" type but is missing "properties" | "additionalProperties" | "allOf"`, 51 | messageId: 'swagger', 52 | name: rule.name, 53 | location: ['definitions', 'InvalidDTO'], 54 | }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | openapi: { 60 | valid: [ 61 | { 62 | it: 'should NOT error for an empty swagger sample', 63 | schema: {}, 64 | }, 65 | ], 66 | invalid: [ 67 | { 68 | it: 'should error for empty object type', 69 | schema: { 70 | components: { 71 | schemas: { 72 | InvalidDTO: { 73 | type: 'object', 74 | }, 75 | ValidDTO: { 76 | type: 'object', 77 | additionalProperties: { 78 | type: 'string', 79 | }, 80 | properties: { 81 | a: { 82 | type: 'string', 83 | }, 84 | AllOf: { 85 | type: 'object', 86 | allOf: [ 87 | { 88 | $ref: 89 | '#/components/schemas/InvalidDTO', 90 | }, 91 | { 92 | $ref: 93 | '#/components/schemas/ValidDTO', 94 | }, 95 | ], 96 | }, 97 | OneOf: { 98 | type: 'object', 99 | oneOf: [ 100 | { 101 | $ref: 102 | '#/components/schemas/InvalidDTO', 103 | }, 104 | { 105 | $ref: 106 | '#/components/schemas/ValidDTO', 107 | }, 108 | ], 109 | }, 110 | AnyOf: { 111 | type: 'object', 112 | anyOf: [ 113 | { 114 | $ref: 115 | '#/components/schemas/InvalidDTO', 116 | }, 117 | { 118 | $ref: 119 | '#/components/schemas/ValidDTO', 120 | }, 121 | ], 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | }, 128 | errors: [ 129 | { 130 | msg: `has "object" type but is missing "properties" | "additionalProperties" | "allOf" | "anyOf" | "oneOf"`, 131 | messageId: 'openapi', 132 | name: rule.name, 133 | location: ['components', 'schemas', 'InvalidDTO'], 134 | }, 135 | ], 136 | }, 137 | ], 138 | }, 139 | }); 140 | -------------------------------------------------------------------------------- /src/rules/required-parameter-description/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | ], 14 | invalid: [ 15 | { 16 | it: 'should error for parameters with no description', 17 | schema: { 18 | paths: { 19 | '/url': { 20 | get: { 21 | responses: { 22 | default: { 23 | description: 'default response', 24 | schema: { 25 | type: 'string', 26 | }, 27 | }, 28 | }, 29 | parameters: [ 30 | { 31 | name: 'petId', 32 | in: 'path', 33 | required: true, 34 | type: 'string', 35 | }, 36 | ], 37 | }, 38 | parameters: [ 39 | { 40 | name: 'petName', 41 | in: 'query', 42 | required: true, 43 | type: 'string', 44 | }, 45 | ], 46 | }, 47 | }, 48 | parameters: { 49 | petAge: { 50 | name: 'petAge', 51 | in: 'body', 52 | required: true, 53 | schema: { 54 | type: 'string', 55 | }, 56 | }, 57 | petColor: { 58 | name: 'petColor', 59 | in: 'body', 60 | description: 'color of required pet', 61 | required: true, 62 | schema: { 63 | type: 'string', 64 | }, 65 | }, 66 | emptyDesc: { 67 | name: 'emptyDesc', 68 | in: 'query', 69 | description: '', 70 | type: 'string', 71 | }, 72 | }, 73 | }, 74 | errors: [ 75 | { 76 | msg: '"petId" parameter is missing description.', 77 | messageId: 'missingDesc', 78 | data: { 79 | name: 'petId', 80 | }, 81 | name: 'required-parameter-description', 82 | location: ['paths', '/url', 'get', 'parameters', '0'], 83 | }, 84 | { 85 | msg: '"petName" parameter is missing description.', 86 | messageId: 'missingDesc', 87 | data: { 88 | name: 'petName', 89 | }, 90 | name: 'required-parameter-description', 91 | location: ['paths', '/url', 'parameters', '0'], 92 | }, 93 | { 94 | msg: '"petAge" parameter is missing description.', 95 | messageId: 'missingDesc', 96 | data: { 97 | name: 'petAge', 98 | }, 99 | name: 'required-parameter-description', 100 | location: ['parameters', 'petAge'], 101 | }, 102 | { 103 | msg: '"emptyDesc" parameter is missing description.', 104 | messageId: 'missingDesc', 105 | data: { 106 | name: 'emptyDesc', 107 | }, 108 | name: 'required-parameter-description', 109 | location: ['parameters', 'emptyDesc', 'description'], 110 | }, 111 | ], 112 | }, 113 | ], 114 | }, 115 | openapi: { 116 | valid: [ 117 | { 118 | it: 'should NOT error for an empty schema', 119 | schema: {}, 120 | }, 121 | ], 122 | invalid: [ 123 | { 124 | it: 'should error for a parameters with no description', 125 | schema: { 126 | components: { 127 | parameters: { 128 | petId: { 129 | name: 'petId', 130 | in: 'path', 131 | required: true, 132 | schema: { 133 | type: 'string', 134 | }, 135 | }, 136 | }, 137 | }, 138 | }, 139 | errors: [ 140 | { 141 | msg: '"petId" parameter is missing description.', 142 | messageId: 'missingDesc', 143 | data: { 144 | name: 'petId', 145 | }, 146 | name: rule.name, 147 | location: ['components', 'parameters', 'petId'], 148 | }, 149 | ], 150 | }, 151 | ], 152 | }, 153 | }); 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swaggerlint 2 | 3 | `Swaggerlint` helps you to have a consistent API style by linting your swagger / OpenAPI Scheme. 4 | 5 |

npm command

6 | 7 | ## Installation 8 | 9 | Install it in your project 10 | 11 | ```sh 12 | npm install swaggerlint 13 | ``` 14 | 15 | Install it globally 16 | 17 | ```sh 18 | npm install --global swaggerlint 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### CLI 24 | 25 | You can lint your swagger scheme by path 26 | 27 | ```sh 28 | swaggerlint /path/to/swagger.json 29 | ``` 30 | 31 | Or by providing a URL 32 | 33 | ```sh 34 | swaggerlint https://... 35 | ``` 36 | 37 | #### Config flag 38 | 39 | `swaggerlint` will automatically search up the directory tree for a `swaggerlint.config.js` file. Or you can specify it explicitly 40 | 41 | ```sh 42 | swaggerlint --config /path/to/swaggerlint.config.js 43 | ``` 44 | 45 | ### Nodejs 46 | 47 | ```js 48 | const {swaggerlint} = require('swaggerlint'); 49 | const config = require('./configs/swaggerlint.config.js'); 50 | const swaggerScheme = require('./swagger.json'); 51 | 52 | const result = swaggerlint(swaggerScheme, config); 53 | 54 | console.log(result); // an array or errors 55 | 56 | /** 57 | * [{ 58 | * name: 'string', // rule name 59 | * msg: 'string', // message from the rule checker 60 | * location: ['path', 'to', 'error'] // what caused an error 61 | * }] 62 | */ 63 | ``` 64 | 65 | ### Docker image 66 | 67 | If you do not have nodejs installed you can use the [swaggerlint docker image](https://hub.docker.com/r/antonk52/alpine-swaggerlint). 68 | 69 | ## Config 70 | 71 | ```js 72 | // swaggerlint.config.js 73 | module.exports = { 74 | rules: { 75 | 'object-prop-casing': ['camel'], 76 | 'properties-for-object-type': true, 77 | 'latin-definitions-only': true, 78 | }, 79 | }; 80 | ``` 81 | 82 | ## Rules 83 | 84 | You can set any rule value to `false` to disable it or to `true` to enable and set its setting to default value. 85 | 86 | 87 | 88 | | rule name | description | default | 89 | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | 90 | | [`expressive-path-summary`](./src/rules/expressive-path-summary/readme.md) | Enforces an intentional path summary | | 91 | | [`latin-definitions-only`](./src/rules/latin-definitions-only/readme.md) | Bans non Latin characters usage in definition names | ["placeholder_to_be_removed",{"ignore":[]}] | 92 | | [`no-empty-object-type`](./src/rules/no-empty-object-type/readme.md) | Object types have to have their properties specified explicitly | | 93 | | [`no-external-refs`](./src/rules/no-external-refs/readme.md) | Forbids the usage of external ReferenceObjects | | 94 | | [`no-inline-enums`](./src/rules/no-inline-enums/readme.md) | Enums must be in `DefinitionsObject` or `ComponentsObject` | | 95 | | [`no-ref-properties`](./src/rules/no-ref-properties/readme.md) | Disallows to have additional properties in Reference objects | | 96 | | [`no-single-allof`](./src/rules/no-single-allof/readme.md) | Object types should not have a redundant single `allOf` property | | 97 | | [`no-trailing-slash`](./src/rules/no-trailing-slash/readme.md) | URLs must NOT end with a slash | | 98 | | [`object-prop-casing`](./src/rules/object-prop-casing/readme.md) | Casing for your object property names | ["camel"] | 99 | | [`only-valid-mime-types`](./src/rules/only-valid-mime-types/readme.md) | Checks mime types against known from [`mime-db`](https://npm.im/mime-db) | | 100 | | [`parameter-casing`](./src/rules/parameter-casing/readme.md) | Casing for your parameters | ["camel",{"header":"kebab"}] | 101 | | [`path-param-required-field`](./src/rules/path-param-required-field/readme.md) | Helps to keep consistently set optional `required` property in path parameters | | 102 | | [`required-operation-tags`](./src/rules/required-operation-tags/readme.md) | All operations must have tags | | 103 | | [`required-parameter-description`](./src/rules/required-parameter-description/readme.md) | All parameters must have description | | 104 | | [`required-tag-description`](./src/rules/required-tag-description/readme.md) | All tags must have description | | 105 | 106 | 107 | 108 | ## Documentation 109 | 110 | - [How to write a rule](./docs/how-to-write-a-rule.md) 111 | 112 | ### Acknowledgments 113 | 114 | This tool has been inspired by already existing swagger validation checkers: 115 | 116 | - [api lint](https://github.com/danielgtaylor/apilint) 117 | - [speccy](https://github.com/wework/speccy) 118 | - [zally](https://github.com/zalando/zally) 119 | - [openapi-validator](https://github.com/IBM/openapi-validator) 120 | -------------------------------------------------------------------------------- /src/rules/parameter-casing/index.ts: -------------------------------------------------------------------------------- 1 | import type {JSONSchema7} from 'json-schema'; 2 | import Case from 'case'; 3 | import {CaseName, Swagger, OpenAPI} from '../../types'; 4 | import {createRule, isValidCaseName, validCases} from '../../utils'; 5 | 6 | const name = 'parameter-casing'; 7 | 8 | function getCasesSetFromOptions( 9 | option: unknown, 10 | defaultCase: Set, 11 | ): Set { 12 | return typeof option === 'string' && isValidCaseName(option) 13 | ? validCases[option] 14 | : defaultCase; 15 | } 16 | 17 | const PARAMETER_LOCATIONS: ( 18 | | Swagger.ParameterObject['in'] 19 | | OpenAPI.ParameterObject['in'] 20 | )[] = [ 21 | 'query', 22 | 'header', 23 | 'path', 24 | 'cookie', // OpenAPI specific 25 | 'formData', // Swagger specific 26 | 'body', 27 | ]; 28 | 29 | const validCasesArr = Object.keys(validCases); 30 | 31 | const paramsSchema = PARAMETER_LOCATIONS.reduce((acc, el) => { 32 | acc[el] = { 33 | type: 'string', 34 | enum: validCasesArr, 35 | }; 36 | return acc; 37 | }, {} as Record); 38 | 39 | const rule = createRule({ 40 | name, 41 | docs: { 42 | recommended: true, 43 | description: 'Casing for your parameters', 44 | }, 45 | meta: { 46 | messages: { 47 | casing: 48 | 'Parameter "{{name}}" has wrong casing. Should be "{{correctVersion}}".', 49 | }, 50 | schema: { 51 | type: 'array', 52 | items: [ 53 | { 54 | type: 'string', 55 | enum: Object.keys(validCases), 56 | }, 57 | { 58 | type: 'object', 59 | properties: { 60 | ...paramsSchema, 61 | ignore: { 62 | type: 'array', 63 | items: { 64 | type: 'string', 65 | }, 66 | }, 67 | }, 68 | additionalProperties: false, 69 | }, 70 | ], 71 | minItems: 1, 72 | maxItems: 2, 73 | }, 74 | }, 75 | swaggerVisitor: { 76 | ParameterObject: ({node, report, location, setting}): void => { 77 | if (typeof setting === 'boolean') return; 78 | 79 | const [settingCasingName, opts = {}] = setting; 80 | if ( 81 | typeof settingCasingName === 'string' && 82 | isValidCaseName(settingCasingName) 83 | ) { 84 | const defaultParamCase = validCases[settingCasingName]; 85 | const cases = { 86 | query: getCasesSetFromOptions(opts.query, defaultParamCase), 87 | header: getCasesSetFromOptions( 88 | opts.header, 89 | defaultParamCase, 90 | ), 91 | path: getCasesSetFromOptions(opts.path, defaultParamCase), 92 | formData: getCasesSetFromOptions( 93 | opts.formData, 94 | defaultParamCase, 95 | ), 96 | body: getCasesSetFromOptions(opts.body, defaultParamCase), 97 | }; 98 | 99 | const IGNORE_PARAMETER_NAMES = new Set( 100 | Array.isArray(opts.ignore) ? opts.ignore : [], 101 | ); 102 | 103 | if (IGNORE_PARAMETER_NAMES.has(node.name)) return; 104 | 105 | const nodeCase = Case.of(node.name); 106 | const paramLocation = node.in; 107 | if (!cases[paramLocation].has(nodeCase)) { 108 | const shouldBeCase: CaseName = cases[paramLocation] 109 | .values() 110 | .next().value; 111 | 112 | const correctVersion = Case[shouldBeCase](node.name); 113 | 114 | report({ 115 | messageId: 'casing', 116 | data: { 117 | name: node.name, 118 | correctVersion, 119 | }, 120 | location: [...location, 'name'], 121 | }); 122 | } 123 | } 124 | }, 125 | }, 126 | openapiVisitor: { 127 | ParameterObject: ({node, report, location, setting}): void => { 128 | if (typeof setting === 'boolean') return; 129 | const [settingCasingName, opts = {}] = setting; 130 | if ( 131 | typeof settingCasingName === 'string' && 132 | isValidCaseName(settingCasingName) 133 | ) { 134 | const defaultParamCase = validCases[settingCasingName]; 135 | const cases = { 136 | query: getCasesSetFromOptions(opts.query, defaultParamCase), 137 | header: getCasesSetFromOptions( 138 | opts.header, 139 | defaultParamCase, 140 | ), 141 | path: getCasesSetFromOptions(opts.path, defaultParamCase), 142 | cookie: getCasesSetFromOptions( 143 | opts.cookie, 144 | defaultParamCase, 145 | ), 146 | }; 147 | 148 | const IGNORE_PARAMETER_NAMES = new Set( 149 | Array.isArray(opts.ignore) ? opts.ignore : [], 150 | ); 151 | 152 | if (IGNORE_PARAMETER_NAMES.has(node.name)) return; 153 | 154 | const nodeCase = Case.of(node.name); 155 | const paramLocation = node.in; 156 | if (!cases[paramLocation].has(nodeCase)) { 157 | const shouldBeCase: CaseName = cases[paramLocation] 158 | .values() 159 | .next().value; 160 | 161 | const correctVersion = Case[shouldBeCase](node.name); 162 | 163 | report({ 164 | messageId: 'casing', 165 | data: { 166 | name: node.name, 167 | correctVersion, 168 | }, 169 | location: [...location, 'name'], 170 | }); 171 | } 172 | } 173 | }, 174 | }, 175 | defaultSetting: ['camel', {header: 'kebab'}], 176 | }); 177 | 178 | export default rule; 179 | -------------------------------------------------------------------------------- /src/rules/no-single-allof/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../..'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should not error for an empty swagger sample', 11 | schema: {}, 12 | }, 13 | { 14 | it: 'should not error for the "allOf" with multiple items', 15 | schema: { 16 | paths: { 17 | '/url': { 18 | get: { 19 | responses: { 20 | default: { 21 | description: 'default response', 22 | schema: { 23 | $ref: '#/definitions/Example', 24 | }, 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | definitions: { 31 | Example: { 32 | type: 'object', 33 | allOf: [ 34 | { 35 | type: 'object', 36 | properties: { 37 | prop: {type: 'string'}, 38 | anotherProp: {type: 'string'}, 39 | }, 40 | }, 41 | { 42 | type: 'object', 43 | }, 44 | ], 45 | }, 46 | }, 47 | }, 48 | }, 49 | ], 50 | invalid: [ 51 | { 52 | it: 'should error for "allOf" with a single item', 53 | schema: { 54 | paths: { 55 | '/url': { 56 | get: { 57 | responses: { 58 | default: { 59 | description: 'default response', 60 | schema: { 61 | $ref: '#/definitions/Example', 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | definitions: { 69 | Example: { 70 | type: 'object', 71 | allOf: [ 72 | { 73 | type: 'object', 74 | properties: { 75 | prop: {type: 'string'}, 76 | anotherProp: {type: 'string'}, 77 | }, 78 | }, 79 | ], 80 | }, 81 | }, 82 | }, 83 | errors: [ 84 | { 85 | messageId: 'msg', 86 | msg: 87 | 'Redundant use of "allOf" with a single item in it.', 88 | name: rule.name, 89 | location: ['definitions', 'Example', 'allOf'], 90 | }, 91 | ], 92 | }, 93 | ], 94 | }, 95 | openapi: { 96 | valid: [ 97 | { 98 | it: 'does not error for an empty swagger sample', 99 | schema: {}, 100 | }, 101 | { 102 | it: 'does not error for "allOf" property with multiple items', 103 | schema: { 104 | paths: { 105 | '/url': { 106 | get: { 107 | responses: { 108 | default: { 109 | description: 'default response', 110 | schema: { 111 | $ref: '#/definitions/Example', 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | }, 118 | definitions: { 119 | Example: { 120 | type: 'object', 121 | allOf: [ 122 | { 123 | type: 'object', 124 | properties: { 125 | prop: {type: 'string'}, 126 | anotherProp: {type: 'string'}, 127 | }, 128 | }, 129 | { 130 | type: 'object', 131 | }, 132 | ], 133 | }, 134 | }, 135 | }, 136 | }, 137 | ], 138 | invalid: [ 139 | { 140 | it: 'should error for "allOf" property with a single item', 141 | schema: { 142 | paths: { 143 | '/url': { 144 | get: { 145 | responses: { 146 | default: { 147 | description: 'default response', 148 | schema: { 149 | $ref: '#/definitions/Example', 150 | }, 151 | }, 152 | }, 153 | }, 154 | }, 155 | }, 156 | components: { 157 | schemas: { 158 | Example: { 159 | type: 'object', 160 | allOf: [ 161 | { 162 | type: 'object', 163 | properties: { 164 | prop: {type: 'string'}, 165 | anotherProp: {type: 'string'}, 166 | }, 167 | }, 168 | ], 169 | }, 170 | }, 171 | }, 172 | }, 173 | errors: [ 174 | { 175 | msg: 176 | 'Redundant use of "allOf" with a single item in it.', 177 | messageId: 'msg', 178 | name: rule.name, 179 | location: ['components', 'schemas', 'Example', 'allOf'], 180 | }, 181 | ], 182 | }, 183 | ], 184 | }, 185 | }); 186 | -------------------------------------------------------------------------------- /docs/how-to-write-a-rule.md: -------------------------------------------------------------------------------- 1 | # How to write a swaggerlint rule 2 | 3 | Contents: 4 | - [Rule base](#rule-base) 5 | - [Visitor](#visitor) 6 | - [Configurable rules](#configurable-rules) 7 | - [Default setting](#default-setting) 8 | - [Validate setting](#validate-setting) 9 | - [Typescript](#typescript) 10 | - [How to add a rule to core `swaggerlint` rules](#how-to-add-a-rule-to-core-swaggerlint-rules) 11 | 12 | ## Rule base 13 | 14 | Any `swaggerlint` rule is an object and it has to have 2 required properties: 15 | - your rule's `name` 16 | - the `visitor`. 17 | 18 | To create a rule you have to run it through `createRule` function which can verify the rule and improve the DX for typescript and VS-Code users. 19 | 20 | ```js 21 | const {createRule} = require('swaggerlint') 22 | module.exports = createRule({ 23 | name: 'my-custom-rule', 24 | visitor: { /* visitors */} 25 | }) 26 | ``` 27 | 28 | ## Visitor 29 | Visitor pattern allows us to match on some part of the swagger scheme. This makes it easy to write rules that target different portions of the swagger scheme, ie you do not need to traverse the scheme to get all the objects to run your rule against. In the example below we match on [`ResponseObject`](https://swagger.io/specification/v2/#responseObject). 30 | 31 | ```js 32 | const {createRule} = require('swaggerlint') 33 | module.exports = createRule({ 34 | name: 'my-custom-rule', 35 | // optional 36 | meta: { 37 | messages: { 38 | plainMessage: 'text to be displayed as an error message', 39 | templatedMessage: 'error message for {{name}}', 40 | } 41 | }, 42 | visitor: { 43 | ResponseObject(param) { 44 | const { 45 | node, // ResponseObject itself 46 | location, // node location string[] 47 | report, // function to report an error 48 | setting // plugin setting 49 | } = param 50 | 51 | if (/* check for error */) { 52 | // to report an error call `report` function, ie 53 | report({message: 'message from your rule'}) 54 | 55 | // alternatively you can specify a more precise location 56 | report({ 57 | message: 'message', 58 | location: [...location, 'path', 'error', 'cause'] 59 | }) 60 | 61 | // you can also provide a messageId 62 | report({ 63 | messageId: 'plainMessage', 64 | }) 65 | 66 | // if messageId has placeholders you can provide the data as an object 67 | report({ 68 | messageId: 'templatedMessage', 69 | data: { 70 | name: 'someString' 71 | } 72 | }) 73 | } 74 | } 75 | } 76 | }) 77 | ``` 78 | 79 | Swaggerlint allows you to match on any of the following objects: 80 | 81 | * [SwaggerObject](https://swagger.io/specification/v2/#swagger-object) 82 | * [ContactObject](https://swagger.io/specification/v2/#contactObject) 83 | * [LicenseObject](https://swagger.io/specification/v2/#licenseObject) 84 | * [InfoObject](https://swagger.io/specification/v2/#infoObject) 85 | * [XmlObject](https://swagger.io/specification/v2/#xmlObject) 86 | * [ExternalDocumentationObject](https://swagger.io/specification/v2/#externalDocumentationObject) 87 | * [ItemsObject](https://swagger.io/specification/v2/#itemsObject) 88 | * [ParameterObject](https://swagger.io/specification/v2/#parameterObject) 89 | * [ReferenceObject](https://swagger.io/specification/v2/#referenceObject) 90 | * [SecurityRequirementObject](https://swagger.io/specification/v2/#security-requirement-object) 91 | * [ResponsesObject](https://swagger.io/specification/v2/#responsesObject) 92 | * [OperationObject](https://swagger.io/specification/v2/#operationObject) 93 | * [PathItemObject](https://swagger.io/specification/v2/#pathItemObject) 94 | * [PathsObject](https://swagger.io/specification/v2/#pathsObject) 95 | * [SchemaObject](https://swagger.io/specification/v2/#schemaObject) 96 | * [HeadersObject](https://swagger.io/specification/v2/#headers-object) 97 | * [ResponseObject](https://swagger.io/specification/v2/#responseObject) 98 | * [ExampleObject](https://swagger.io/specification/v2/#example-object) 99 | * [HeaderObject](https://swagger.io/specification/v2/#headerObject) 100 | * [SecuritySchemeObject](https://swagger.io/specification/v2/#security-scheme-object) 101 | * [ScopesObject](https://swagger.io/specification/v2/#scopes-object) 102 | * [TagObject](https://swagger.io/specification/v2/#tagObject) 103 | * [ParametersDefinitionsObject](https://swagger.io/specification/v2/#parametersDefinitionsObject) 104 | * [ResponsesDefinitionsObject](https://swagger.io/specification/v2/#responses-definitions-object) 105 | * [SecurityDefinitionsObject](https://swagger.io/specification/v2/#securityDefinitionsObject) 106 | * [DefinitionsObject](https://swagger.io/specification/v2/#definitionsObject) 107 | 108 | [Full swagger (v2.0) specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) 109 | 110 | ## Configurable rules 111 | 112 | If your rule can be configured it has to contain **both** `defaultSetting` and `meta.schema` properties. 113 | 114 | ### Default setting 115 | 116 | This will be used when the rule's value in swaggerlint config is set to `true`. Example: 117 | 118 | ```js 119 | const {createRule} = require('swaggerlint') 120 | module.exports = createRule({ 121 | name: 'my-custom-rule', 122 | meta: {/* ... */}, 123 | visitor: {/* ... */}, 124 | defaultSetting: ['sensible-default'] 125 | }) 126 | ``` 127 | ### Setting schema 128 | 129 | If a user has your rule set to anything other than `true` or `false` in `swaggerlint.config.js` we need to validate that your rule can be run with the supplied setting. `meta.schema` is a [JSON schema](http://json-schema.org/), it describes all possible values that the setting for your rule can be set to. If the setting validation fails, the rule won't run and the output will contain an error about invalid rule setting. 130 | 131 | ```js 132 | const {createRule} = require('swaggerlint') 133 | module.exports = createRule({ 134 | name: 'my-custom-rule', 135 | meta: { 136 | schema: { 137 | type: 'array', 138 | items: { 139 | type: 'string', 140 | }, 141 | }, 142 | }, 143 | }, 144 | visitor: {/* ... */}, 145 | defaultSetting: [/* sensible default */] 146 | }) 147 | ``` 148 | 149 | ## Typescript 150 | 151 | If you use `typescript` you can leverage the types. 152 | 153 | ```ts 154 | import {createRule} from 'swaggerlint' 155 | 156 | const myCustomRule = createRule({ 157 | name: 'my-custom-rule', 158 | meta: { 159 | messages: { 160 | foo: 'some string', 161 | } 162 | }, 163 | visitor: { 164 | /** 165 | * param already has correct types about `node`, `report`, `location` and `setting` 166 | */ 167 | ResponseObject(param) { 168 | /* your rule logic */ 169 | 170 | report({ 171 | messageId: 'foo' 172 | // ^ correct type 173 | }) 174 | } 175 | } 176 | }) 177 | 178 | export default myCustomRule 179 | ``` 180 | 181 | ## How to add a rule to core `swaggerlint` rules 182 | 183 | After you wrote your rule you can propose to add it to default `swaggerlint` rules. To do so you need to add it to [`rules` directory](../src/rules/) and [`src/rules/index.ts` file](../src/rules/index.ts). After this you can create a PR, it is a good idea to explain on why you think adding this rule is a good idea. 184 | 185 | Do not forget to add your rule to the project README to make it discoverable. 186 | -------------------------------------------------------------------------------- /src/utils/spec/config.spec.ts: -------------------------------------------------------------------------------- 1 | import {getConfig, resolveConfigExtends} from '../config'; 2 | import {SwaggerlintConfig} from '../../types'; 3 | import {cosmiconfigSync} from 'cosmiconfig'; 4 | 5 | jest.mock('cosmiconfig', () => ({ 6 | cosmiconfigSync: jest.fn(), 7 | })); 8 | jest.mock('../../defaultConfig', () => ({rules: {defaultRule: true}})); 9 | /** 10 | * jest does not support mocking non existing packages/files 11 | * let's pretend the followings are swaggerlint config packages 12 | */ 13 | jest.mock('fs', () => ({extends: ['os'], rules: {fs: true}})); 14 | jest.mock('os', () => ({extends: ['process'], rules: {os: true}})); 15 | jest.mock('process', () => ({extends: ['fs'], rules: {process: true}})); 16 | jest.mock('path', () => ({ 17 | resolve: (filepath: string) => filepath, 18 | join: (...args: string[]) => args.join('/'), 19 | })); 20 | 21 | jest.mock('case', () => ({ 22 | rules: { 23 | case: true, 24 | }, 25 | })); 26 | 27 | jest.mock('http', () => ({rules: {http: true}})); 28 | jest.mock('https', () => ({rules: {http: false, https: true}})); 29 | 30 | describe('utils/config', () => { 31 | describe('resolveConfigExtends function', () => { 32 | it('resolves config with single extends value', () => { 33 | const result = resolveConfigExtends({ 34 | extends: ['http'], 35 | rules: {}, 36 | }); 37 | 38 | expect(result.rules.http).toBe(true); 39 | }); 40 | 41 | it('resolves config with cyclic dependent extends', () => { 42 | const result = resolveConfigExtends({ 43 | extends: ['fs', 'http'], 44 | rules: {}, 45 | }); 46 | const expected = { 47 | rules: { 48 | fs: true, 49 | os: true, 50 | process: true, 51 | http: true, 52 | }, 53 | }; 54 | 55 | expect(result.rules).toMatchObject(expected.rules); 56 | }); 57 | 58 | it('configs are merged left to right', () => { 59 | const result = resolveConfigExtends({ 60 | extends: ['http', 'https'], 61 | rules: {}, 62 | }); 63 | 64 | expect(result.rules.http).toBe(false); 65 | expect(result.rules.https).toBe(true); 66 | }); 67 | 68 | it('throws when extending from non existing config', () => { 69 | expect(() => 70 | resolveConfigExtends({ 71 | extends: ['foobar'], 72 | rules: {}, 73 | }), 74 | ).toThrowError( 75 | '"foobar" in extends of your config cannot be found. Make sure it exists in your node_modules.', 76 | ); 77 | }); 78 | }); 79 | describe('getConfig function', () => { 80 | it('returns config by path', () => { 81 | const config = {rules: {userRule: true}}; 82 | 83 | (cosmiconfigSync as jest.Mock).mockImplementationOnce(() => ({ 84 | load: (): {config: SwaggerlintConfig} => ({config}), 85 | })); 86 | 87 | const result = getConfig('./some-path'); 88 | const expected = { 89 | config: { 90 | rules: { 91 | ...config.rules, 92 | defaultRule: true, 93 | }, 94 | ignore: {}, 95 | extends: [], 96 | }, 97 | type: 'success', 98 | }; 99 | 100 | expect(result).toEqual(expected); 101 | }); 102 | 103 | it('returns an error if provided path does not exist', () => { 104 | (cosmiconfigSync as jest.Mock).mockImplementationOnce(() => ({ 105 | load: (): unknown => null, 106 | })); 107 | 108 | const result = getConfig('./some-path'); 109 | 110 | expect(result.type).toBe('fail'); 111 | }); 112 | 113 | it('returns an error if loaded config did not pass validation', () => { 114 | (cosmiconfigSync as jest.Mock).mockImplementationOnce(() => ({ 115 | load: ( 116 | filepath: string, 117 | ): {config: object; filepath: string} => ({ 118 | config: {extends: [null, 'foo']}, 119 | filepath, 120 | }), 121 | })); 122 | 123 | const result = getConfig('./some-path'); 124 | 125 | expect(result).toEqual({ 126 | type: 'error', 127 | filepath: './some-path', 128 | error: 'Expected string at ".extends[0]", got `null`', 129 | }); 130 | }); 131 | 132 | it('returns an error if found config did not pass validation', () => { 133 | (cosmiconfigSync as jest.Mock).mockImplementationOnce(() => ({ 134 | search: (): {config: object; filepath: string} => ({ 135 | config: {extends: [null, 'foo']}, 136 | filepath: './some-path', 137 | }), 138 | })); 139 | 140 | const result = getConfig(); 141 | 142 | expect(result).toEqual({ 143 | type: 'error', 144 | filepath: './some-path', 145 | error: 'Expected string at ".extends[0]", got `null`', 146 | }); 147 | }); 148 | 149 | it('returns config by searching for it', () => { 150 | const config = {rules: {userRule: true}}; 151 | const search = jest.fn((): { 152 | config: SwaggerlintConfig; 153 | filepath: string; 154 | } => ({ 155 | config, 156 | filepath: 'foo/bar', 157 | })); 158 | 159 | (cosmiconfigSync as jest.Mock).mockImplementationOnce(() => ({ 160 | search, 161 | })); 162 | 163 | const result = getConfig(); 164 | const expected = { 165 | type: 'success', 166 | filepath: 'foo/bar', 167 | config: { 168 | rules: { 169 | ...config.rules, 170 | defaultRule: true, 171 | }, 172 | ignore: {}, 173 | extends: [], 174 | }, 175 | }; 176 | 177 | expect(result).toEqual(expected); 178 | expect(result).toEqual(expected); 179 | expect(search.mock.calls).toHaveLength(1); 180 | }); 181 | 182 | it('finds and resolves config with extends field', () => { 183 | (cosmiconfigSync as jest.Mock).mockReturnValueOnce({ 184 | search: jest.fn(() => ({ 185 | config: {rules: {a: true}, extends: ['case']}, 186 | filepath: 'some/valid/path/a.config.js', 187 | })), 188 | }); 189 | const result = getConfig(); 190 | 191 | const expected = { 192 | type: 'success', 193 | config: { 194 | extends: [], 195 | ignore: {}, 196 | rules: { 197 | a: true, 198 | case: true, 199 | defaultRule: true, 200 | }, 201 | }, 202 | filepath: 'some/valid/path/a.config.js', 203 | }; 204 | 205 | expect(result).toEqual(expected); 206 | }); 207 | 208 | it('returns defaultConfig if could not find a config', () => { 209 | const search = jest.fn(() => null); 210 | 211 | (cosmiconfigSync as jest.Mock).mockImplementationOnce(() => ({ 212 | search, 213 | })); 214 | 215 | const result = getConfig(); 216 | 217 | expect(search.mock.calls).toHaveLength(1); 218 | expect(result.type).toBe('success'); 219 | }); 220 | }); 221 | }); 222 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type {JSONSchema7} from 'json-schema'; 3 | import {cosmiconfigSync} from 'cosmiconfig'; 4 | import {validate} from './validate-json'; 5 | import {SwaggerlintConfig, LintError} from '../types'; 6 | import defaultConfig from '../defaultConfig'; 7 | const pkg = require('../../package.json'); 8 | 9 | function mergeConfigs( 10 | defConf: SwaggerlintConfig, 11 | userConf: SwaggerlintConfig, 12 | ): SwaggerlintConfig { 13 | return { 14 | extends: [...(defConf.extends ?? []), ...(userConf.extends ?? [])], 15 | rules: { 16 | ...defConf.rules, 17 | ...userConf.rules, 18 | }, 19 | ignore: { 20 | ...userConf.ignore, 21 | }, 22 | }; 23 | } 24 | 25 | // workaround for extends keyword 26 | function omitExtends({ 27 | /* eslint-disable @typescript-eslint/no-unused-vars */ 28 | extends: ext, 29 | ...rest 30 | }: SwaggerlintConfig): Omit { 31 | return rest; 32 | } 33 | 34 | type ConfigNoExtends = Omit; 35 | 36 | export function resolveConfigExtends( 37 | baseConfig: SwaggerlintConfig, 38 | ): SwaggerlintConfig { 39 | const processedConfigs = new Set([]); 40 | const toBeMergedConfigs: ConfigNoExtends[] = [omitExtends(baseConfig)]; 41 | 42 | function resolveExtends(conf: SwaggerlintConfig): void { 43 | [...(conf.extends || [])] 44 | .reverse() 45 | .forEach(function resolveEachName(name) { 46 | let resolvedName; 47 | try { 48 | resolvedName = require.resolve(name); 49 | } catch (e) { 50 | if (e.code && e.code === 'MODULE_NOT_FOUND') { 51 | throw `"${name}" in extends of your config cannot be found. Make sure it exists in your node_modules.`; 52 | } else { 53 | throw e; 54 | } 55 | } 56 | if (processedConfigs.has(resolvedName)) return; 57 | processedConfigs.add(resolvedName); 58 | const nextConf = require(name); 59 | 60 | if (nextConf.extends) resolveExtends(nextConf); 61 | 62 | toBeMergedConfigs.unshift(nextConf); 63 | }); 64 | } 65 | 66 | resolveExtends(baseConfig); 67 | 68 | /** 69 | * default config is always the base of any config 70 | */ 71 | const result = toBeMergedConfigs.reduce( 72 | (acc, conf) => mergeConfigs(acc, conf), 73 | defaultConfig, 74 | ); 75 | 76 | return result; 77 | } 78 | 79 | const arrayOfStrings: JSONSchema7 = {type: 'array', items: {type: 'string'}}; 80 | const configSchema: JSONSchema7 = { 81 | type: 'object', 82 | properties: { 83 | rules: { 84 | type: 'object', 85 | }, 86 | extends: arrayOfStrings, 87 | ignore: { 88 | type: 'object', 89 | properties: { 90 | definitions: arrayOfStrings, 91 | components: { 92 | type: 'object', 93 | properties: { 94 | schemas: arrayOfStrings, 95 | responses: arrayOfStrings, 96 | parameters: arrayOfStrings, 97 | examples: arrayOfStrings, 98 | requestBodies: arrayOfStrings, 99 | headers: arrayOfStrings, 100 | securitySchemes: arrayOfStrings, 101 | links: arrayOfStrings, 102 | callbacks: arrayOfStrings, 103 | }, 104 | additionalProperties: false, 105 | }, 106 | }, 107 | additionalProperties: false, 108 | }, 109 | }, 110 | additionalProperties: false, 111 | }; 112 | 113 | const validateConfig = (config: SwaggerlintConfig) => { 114 | const errors = validate(configSchema, config); 115 | 116 | return errors.map(se => { 117 | const result: LintError = { 118 | name: 'swaggerlint-core', 119 | msg: 'Invalid config', 120 | location: [], 121 | }; 122 | switch (se.keyword) { 123 | case 'additionalProperties': 124 | const key = 125 | 'additionalProperty' in se.params && 126 | se.params.additionalProperty; 127 | result.msg = `Unexpected property ${JSON.stringify( 128 | key || '', 129 | )} in swaggerlint.config.js`; 130 | break; 131 | case 'type': 132 | result.msg = `${(se.message || '').replace( 133 | 'should be', 134 | 'Expected', 135 | )} at "${se.dataPath}", got \`${se.data}\``; 136 | } 137 | 138 | return result; 139 | }); 140 | }; 141 | 142 | type GetConfigSuccess = { 143 | type: 'success'; 144 | config: SwaggerlintConfig; 145 | filepath: string; 146 | }; 147 | type GetConfigFail = { 148 | type: 'fail'; 149 | error: string; 150 | }; 151 | 152 | type GetConfigError = { 153 | type: 'error'; 154 | filepath: string; 155 | error: string; 156 | }; 157 | 158 | type GetConfigResult = GetConfigSuccess | GetConfigFail | GetConfigError; 159 | 160 | const defaultConfigPath = path.join(__dirname, '..', 'defaultConfig.js'); 161 | export function getConfig(configPath: string | void): GetConfigResult { 162 | const cosmiconfig = cosmiconfigSync(pkg.name); 163 | if (configPath) { 164 | const cosmiResult = cosmiconfig.load(path.resolve(configPath)); 165 | 166 | if (cosmiResult !== null) { 167 | const validationErrros = validateConfig(cosmiResult.config); 168 | if (validationErrros.length) { 169 | return { 170 | type: 'error', 171 | filepath: cosmiResult.filepath, 172 | error: validationErrros[0].msg, 173 | }; 174 | } 175 | 176 | const config = cosmiResult.config.extends 177 | ? resolveConfigExtends(cosmiResult.config) 178 | : mergeConfigs(defaultConfig, cosmiResult.config); 179 | 180 | return { 181 | type: 'success', 182 | config, 183 | filepath: cosmiResult.filepath, 184 | }; 185 | } else { 186 | return { 187 | type: 'fail', 188 | error: 189 | 'Swaggerlint config with a provided path does not exits.', 190 | }; 191 | } 192 | } else { 193 | const cosmiResult = cosmiconfig.search(); 194 | if (cosmiResult !== null) { 195 | const validationErrros = validateConfig(cosmiResult.config); 196 | if (validationErrros.length) { 197 | return { 198 | type: 'error', 199 | filepath: cosmiResult.filepath, 200 | error: validationErrros[0].msg, 201 | }; 202 | } 203 | 204 | if (cosmiResult.config.extends) { 205 | try { 206 | return { 207 | type: 'success', 208 | config: resolveConfigExtends(cosmiResult.config), 209 | filepath: cosmiResult.filepath, 210 | }; 211 | } catch (e) { 212 | return { 213 | type: 'error', 214 | error: e, 215 | filepath: cosmiResult.filepath, 216 | }; 217 | } 218 | } else { 219 | return { 220 | type: 'success', 221 | config: mergeConfigs(defaultConfig, cosmiResult.config), 222 | filepath: cosmiResult.filepath, 223 | }; 224 | } 225 | } else { 226 | /** 227 | * if no config is found we use default config 228 | * reasoning: cli should work out of the box 229 | */ 230 | return { 231 | type: 'success', 232 | config: defaultConfig, 233 | filepath: defaultConfigPath, 234 | }; 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/ruleTester.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import {swaggerlint} from './swaggerlint'; 3 | import {getSwaggerObject, getOpenAPIObject} from './utils/tests'; 4 | import {hasKey} from './utils'; 5 | import { 6 | Swagger, 7 | OpenAPI, 8 | SwaggerlintRule, 9 | SwaggerlintConfig, 10 | LintError, 11 | } from './types'; 12 | 13 | type ValidSample = { 14 | it: string; 15 | schema: Partial; 16 | config?: SwaggerlintConfig; 17 | }[]; 18 | 19 | type InvalidSample = { 20 | it: string; 21 | schema: Partial; 22 | config?: SwaggerlintConfig; 23 | errors: Partial[]; 24 | }[]; 25 | 26 | type Item = { 27 | valid: ValidSample; 28 | invalid: InvalidSample; 29 | }; 30 | 31 | export class RuleTester { 32 | rule: SwaggerlintRule; 33 | defaultConfig: SwaggerlintConfig; 34 | 35 | constructor(rule: SwaggerlintRule) { 36 | this.rule = rule; 37 | this.defaultConfig = { 38 | rules: { 39 | [rule.name]: true, 40 | }, 41 | }; 42 | } 43 | 44 | run({ 45 | swagger, 46 | openapi, 47 | }: { 48 | swagger?: Item; 49 | openapi?: Item; 50 | }) { 51 | const {defaultConfig} = this; 52 | 53 | function runTests( 54 | sample: Item, 55 | name: 'Swagger', 56 | ): void; 57 | function runTests( 58 | sample: Item, 59 | name: 'OpenAPI', 60 | ): void; 61 | function runTests( 62 | sample: Item, 63 | name: 'Swagger' | 'OpenAPI', 64 | ): void { 65 | const makeSchema = 66 | name === 'Swagger' ? getSwaggerObject : getOpenAPIObject; 67 | 68 | describe(name, () => { 69 | describe('valid', () => { 70 | sample.valid.forEach(valid => { 71 | it(valid.it, () => { 72 | expect( 73 | swaggerlint( 74 | // @ts-expect-error: mom, typescript is merging arguments again 75 | makeSchema(valid.schema), 76 | valid.config || defaultConfig, 77 | ), 78 | ).toEqual([]); 79 | }); 80 | }); 81 | }); 82 | 83 | describe('invalid', () => { 84 | sample.invalid.forEach(invalid => { 85 | it(invalid.it, () => { 86 | const actual = swaggerlint( 87 | // @ts-expect-error: mom, typescript is merging arguments again 88 | makeSchema(invalid.schema), 89 | invalid.config || defaultConfig, 90 | ); 91 | const expected = invalid.errors; 92 | 93 | if (actual.length !== expected.length) { 94 | assert.fail( 95 | `Expected ${expected.length} error${ 96 | expected.length === 1 ? '' : 's' 97 | } but got ${ 98 | actual.length 99 | }.\n\n${JSON.stringify(actual, null, 4)}`, 100 | ); 101 | } 102 | 103 | actual.forEach((actualError, index) => { 104 | const expectedError = expected[index]; 105 | 106 | checkForMsgOrMessageId( 107 | actualError, 108 | index, 109 | 'Lint error', 110 | ); 111 | checkForMsgOrMessageId( 112 | expectedError, 113 | index, 114 | 'Provided error', 115 | ); 116 | 117 | if (expectedError.name) { 118 | assert.strictEqual( 119 | expectedError.name, 120 | actualError.name, 121 | `Expected error name "${expectedError.name}" but got "${actualError.name}" at index ${index}.`, 122 | ); 123 | } 124 | 125 | if (expectedError.msg) { 126 | assert.strictEqual( 127 | expectedError.msg, 128 | actualError.msg, 129 | `Expected error message "${expectedError.msg}" but got "${actualError.msg}" at index ${index}.`, 130 | ); 131 | } 132 | 133 | if ( 134 | hasKey('messageId', expectedError) && 135 | expectedError.messageId 136 | ) { 137 | const expectedMessageId = 138 | expectedError.messageId; 139 | const actualMessageId = 140 | hasKey('messageId', actualError) && 141 | actualError.messageId; 142 | assert.strictEqual( 143 | expectedMessageId, 144 | actualMessageId, 145 | `Expected error messageId "${expectedMessageId}" but got "${actualMessageId}" at index ${index}.`, 146 | ); 147 | } 148 | 149 | if ( 150 | hasKey('data', expectedError) && 151 | expectedError.data 152 | ) { 153 | assert.deepStrictEqual( 154 | expectedError.data, 155 | // @ts-expect-error: expected since one was provided 156 | actualError.data, 157 | 'Expected data for message template does not match actual data at index ${index}.', 158 | ); 159 | } 160 | 161 | if (!hasKey('location', expectedError)) { 162 | assert.fail( 163 | `Expected error at index ${index} does not have location specified`, 164 | ); 165 | } 166 | 167 | if ( 168 | hasKey('location', expectedError) && 169 | expectedError.location 170 | ) { 171 | assert.deepStrictEqual( 172 | expectedError.location, 173 | actualError.location, 174 | 'Expected location does not match with actual error location at index ${index}.', 175 | ); 176 | } 177 | }); 178 | expect(actual).toMatchObject(expected); 179 | }); 180 | }); 181 | }); 182 | }); 183 | } 184 | 185 | describe(this.rule.name, () => { 186 | if (swagger) runTests(swagger, 'Swagger'); 187 | if (openapi) runTests(openapi, 'OpenAPI'); 188 | 189 | if (!swagger && !openapi) 190 | assert.fail( 191 | 'Neither swagger nor openapi was passed to RuleTester', 192 | ); 193 | }); 194 | } 195 | } 196 | 197 | function checkForMsgOrMessageId( 198 | err: Partial, 199 | index: number, 200 | errLabel: string, 201 | ): void { 202 | const hasMsg = typeof err.msg === 'string'; 203 | const hasMessageId = 204 | hasKey('messageId', err) && typeof err.messageId === 'string'; 205 | if (!hasMsg && !hasMessageId) { 206 | assert.fail( 207 | `${errLabel} with index ${index} expected to have "msg" or "messageId" but has neither`, 208 | ); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/rules/expressive-path-summary/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [], 9 | invalid: [ 10 | { 11 | it: 'should error for missing and poor path summaries', 12 | schema: { 13 | paths: { 14 | '/some/api/path': { 15 | get: { 16 | tags: ['random'], 17 | summary: 'upload-image', 18 | description: '', 19 | consumes: ['multipart/form-data'], 20 | produces: ['application/json'], 21 | responses: { 22 | '200': { 23 | description: 'successful operation', 24 | schema: { 25 | $ref: '#/definitions/ApiResponse', 26 | }, 27 | }, 28 | }, 29 | }, 30 | post: { 31 | tags: ['random'], 32 | description: '', 33 | consumes: ['multipart/form-data'], 34 | produces: ['application/json'], 35 | responses: { 36 | '200': { 37 | description: 'successful operation', 38 | schema: { 39 | $ref: '#/definitions/ApiResponse', 40 | }, 41 | }, 42 | }, 43 | }, 44 | delete: { 45 | tags: ['random'], 46 | summary: '', 47 | description: '', 48 | consumes: ['multipart/form-data'], 49 | produces: ['application/json'], 50 | responses: { 51 | '200': { 52 | description: 'successful operation', 53 | schema: { 54 | $ref: '#/definitions/ApiResponse', 55 | }, 56 | }, 57 | }, 58 | }, 59 | patch: { 60 | tags: ['random'], 61 | summary: 62 | 'wonderful summary, well described the meaning of this endpoint method.', 63 | description: '', 64 | consumes: ['multipart/form-data'], 65 | produces: ['application/json'], 66 | responses: { 67 | '200': { 68 | description: 'successful operation', 69 | schema: { 70 | $ref: '#/definitions/ApiResponse', 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | }, 78 | errors: [ 79 | { 80 | location: ['paths', '/some/api/path', 'get', 'summary'], 81 | msg: 82 | 'Every path summary should contain at least 2 words. This has "upload-image"', 83 | messageId: 'nonExpressive', 84 | data: { 85 | summary: 'upload-image', 86 | }, 87 | }, 88 | { 89 | location: ['paths', '/some/api/path', 'post'], 90 | msg: 'Every path has to have a summary.', 91 | messageId: 'noSummary', 92 | }, 93 | { 94 | location: [ 95 | 'paths', 96 | '/some/api/path', 97 | 'delete', 98 | 'summary', 99 | ], 100 | msg: 101 | 'Every path summary should contain at least 2 words. This has ""', 102 | messageId: 'nonExpressive', 103 | data: { 104 | summary: '', 105 | }, 106 | }, 107 | ], 108 | }, 109 | ], 110 | }, 111 | openapi: { 112 | valid: [], 113 | invalid: [ 114 | { 115 | it: 'should error for missing and poor path summaries', 116 | schema: { 117 | paths: { 118 | '/some/api/path': { 119 | get: { 120 | tags: ['random'], 121 | summary: 'upload-image', 122 | description: '', 123 | consumes: ['multipart/form-data'], 124 | produces: ['application/json'], 125 | responses: { 126 | '200': { 127 | description: 'successful operation', 128 | schema: { 129 | $ref: '#/definitions/ApiResponse', 130 | }, 131 | }, 132 | }, 133 | }, 134 | post: { 135 | tags: ['random'], 136 | description: '', 137 | consumes: ['multipart/form-data'], 138 | produces: ['application/json'], 139 | responses: { 140 | '200': { 141 | description: 'successful operation', 142 | schema: { 143 | $ref: '#/definitions/ApiResponse', 144 | }, 145 | }, 146 | }, 147 | }, 148 | delete: { 149 | tags: ['random'], 150 | summary: '', 151 | description: '', 152 | consumes: ['multipart/form-data'], 153 | produces: ['application/json'], 154 | responses: { 155 | '200': { 156 | description: 'successful operation', 157 | schema: { 158 | $ref: '#/definitions/ApiResponse', 159 | }, 160 | }, 161 | }, 162 | }, 163 | patch: { 164 | tags: ['random'], 165 | summary: 166 | 'wonderful summary, well described the meaning of this endpoint method.', 167 | description: '', 168 | consumes: ['multipart/form-data'], 169 | produces: ['application/json'], 170 | responses: { 171 | '200': { 172 | description: 'successful operation', 173 | schema: { 174 | $ref: '#/definitions/ApiResponse', 175 | }, 176 | }, 177 | }, 178 | }, 179 | }, 180 | }, 181 | }, 182 | errors: [ 183 | { 184 | location: ['paths', '/some/api/path', 'get', 'summary'], 185 | msg: 186 | 'Every path summary should contain at least 2 words. This has "upload-image"', 187 | messageId: 'nonExpressive', 188 | data: { 189 | summary: 'upload-image', 190 | }, 191 | }, 192 | { 193 | location: ['paths', '/some/api/path', 'post'], 194 | msg: 'Every path has to have a summary.', 195 | messageId: 'noSummary', 196 | }, 197 | { 198 | location: [ 199 | 'paths', 200 | '/some/api/path', 201 | 'delete', 202 | 'summary', 203 | ], 204 | msg: 205 | 'Every path summary should contain at least 2 words. This has ""', 206 | messageId: 'nonExpressive', 207 | data: { 208 | summary: '', 209 | }, 210 | }, 211 | ], 212 | }, 213 | ], 214 | }, 215 | }); 216 | -------------------------------------------------------------------------------- /src/rules/latin-definitions-only/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rule from '../'; 2 | import {RuleTester} from '../../../'; 3 | 4 | const ruleTester = new RuleTester(rule); 5 | 6 | ruleTester.run({ 7 | swagger: { 8 | valid: [ 9 | { 10 | it: 'should NOT error for an empty swagger sample', 11 | schema: { 12 | definitions: { 13 | foo: {}, 14 | Foo: {}, 15 | FOO: {}, 16 | }, 17 | }, 18 | }, 19 | { 20 | it: 'should not error for ignored definitions', 21 | schema: { 22 | definitions: { 23 | valid: { 24 | type: 'object', 25 | }, 26 | 'invalid-obj': { 27 | type: 'object', 28 | }, 29 | }, 30 | }, 31 | config: { 32 | rules: { 33 | [rule.name]: true, 34 | }, 35 | ignore: { 36 | definitions: ['invalid-obj'], 37 | }, 38 | }, 39 | }, 40 | ], 41 | invalid: [ 42 | { 43 | it: 'should error for absent ignore option', 44 | schema: { 45 | definitions: { 46 | valid: { 47 | type: 'object', 48 | }, 49 | }, 50 | }, 51 | config: { 52 | rules: { 53 | [rule.name]: ['foo', {}], 54 | 'expressive-path-summary': true, 55 | }, 56 | }, 57 | errors: [ 58 | { 59 | msg: 60 | "Should have required property 'ignore', got object", 61 | name: rule.name, 62 | location: [], 63 | }, 64 | ], 65 | }, 66 | { 67 | it: 'should error for all non single char config options', 68 | schema: { 69 | definitions: { 70 | valid: { 71 | type: 'object', 72 | }, 73 | }, 74 | }, 75 | config: { 76 | rules: { 77 | [rule.name]: ['foo', {ignore: ['', '12']}], 78 | 'expressive-path-summary': true, 79 | }, 80 | }, 81 | errors: [ 82 | { 83 | msg: 'Invalid rule setting.', 84 | name: rule.name, 85 | location: [], 86 | }, 87 | { 88 | msg: 'Invalid rule setting.', 89 | name: rule.name, 90 | location: [], 91 | }, 92 | ], 93 | }, 94 | { 95 | it: 'should error for all non latin named definitions', 96 | schema: { 97 | definitions: { 98 | valid: { 99 | type: 'object', 100 | }, 101 | 'invalid-obj': { 102 | type: 'object', 103 | }, 104 | }, 105 | }, 106 | errors: [ 107 | { 108 | data: { 109 | name: 'invalid-obj', 110 | }, 111 | messageId: 'msg', 112 | msg: 113 | 'Definition name "invalid-obj" contains non latin characters.', 114 | name: rule.name, 115 | location: ['definitions', 'invalid-obj'], 116 | }, 117 | ], 118 | }, 119 | { 120 | it: 'should error for non latin & non ignored definitoins', 121 | schema: { 122 | definitions: { 123 | valid: { 124 | type: 'object', 125 | }, 126 | '^invalid^': { 127 | type: 'object', 128 | }, 129 | '&invalid&': { 130 | type: 'object', 131 | }, 132 | $invalid$: { 133 | type: 'object', 134 | }, 135 | '«invalid»': { 136 | type: 'object', 137 | }, 138 | }, 139 | }, 140 | config: { 141 | rules: { 142 | [rule.name]: ['', {ignore: ['$', '«', '»']}], 143 | }, 144 | }, 145 | errors: [ 146 | { 147 | name: 'latin-definitions-only', 148 | msg: 149 | 'Definition name "^invalid^" contains non latin characters.', 150 | messageId: 'msg', 151 | location: ['definitions', '^invalid^'], 152 | }, 153 | { 154 | name: 'latin-definitions-only', 155 | msg: 156 | 'Definition name "&invalid&" contains non latin characters.', 157 | messageId: 'msg', 158 | location: ['definitions', '&invalid&'], 159 | }, 160 | ], 161 | }, 162 | ], 163 | }, 164 | openapi: { 165 | valid: [ 166 | { 167 | it: 'should NOT error for an empty swagger sample', 168 | schema: {}, 169 | }, 170 | { 171 | it: 'should not error for ignored definitions', 172 | schema: { 173 | components: { 174 | schemas: { 175 | valid: { 176 | type: 'object', 177 | }, 178 | 'invalid-obj': { 179 | type: 'object', 180 | }, 181 | }, 182 | }, 183 | }, 184 | config: { 185 | rules: { 186 | [rule.name]: true, 187 | }, 188 | ignore: { 189 | components: { 190 | schemas: ['invalid-obj'], 191 | }, 192 | }, 193 | }, 194 | }, 195 | { 196 | it: 'should not error for ignored characters', 197 | schema: { 198 | components: { 199 | schemas: { 200 | valid: { 201 | type: 'object', 202 | }, 203 | 'invalid-obj': { 204 | type: 'object', 205 | }, 206 | }, 207 | }, 208 | }, 209 | config: { 210 | rules: { 211 | [rule.name]: ['', {ignore: ['-']}], 212 | }, 213 | }, 214 | }, 215 | ], 216 | invalid: [ 217 | { 218 | it: 'should error for all non latin named definitions', 219 | schema: { 220 | components: { 221 | schemas: { 222 | valid: { 223 | type: 'object', 224 | }, 225 | 'invalid-obj': { 226 | type: 'object', 227 | }, 228 | }, 229 | }, 230 | }, 231 | errors: [ 232 | { 233 | data: { 234 | name: 'invalid-obj', 235 | }, 236 | messageId: 'msg', 237 | msg: 238 | 'Definition name "invalid-obj" contains non latin characters.', 239 | name: rule.name, 240 | location: ['components', 'schemas', 'invalid-obj'], 241 | }, 242 | ], 243 | }, 244 | { 245 | it: 'should error for non non ignored characters', 246 | schema: { 247 | components: { 248 | schemas: { 249 | valid: { 250 | type: 'object', 251 | }, 252 | $ignored$: { 253 | type: 'object', 254 | }, 255 | '^invalid^': { 256 | type: 'object', 257 | }, 258 | }, 259 | }, 260 | }, 261 | config: { 262 | rules: { 263 | [rule.name]: ['', {ignore: ['$']}], 264 | }, 265 | }, 266 | errors: [ 267 | { 268 | data: { 269 | name: '^invalid^', 270 | }, 271 | messageId: 'msg', 272 | msg: 273 | 'Definition name "^invalid^" contains non latin characters.', 274 | name: rule.name, 275 | location: ['components', 'schemas', '^invalid^'], 276 | }, 277 | ], 278 | }, 279 | ], 280 | }, 281 | }); 282 | -------------------------------------------------------------------------------- /src/spec/cli.spec.ts: -------------------------------------------------------------------------------- 1 | import {cli} from '../cli'; 2 | 3 | jest.mock('fs', () => ({ 4 | existsSync: jest.fn(), 5 | })); 6 | jest.mock('js-yaml'); 7 | jest.mock('../swaggerlint', () => ({ 8 | swaggerlint: jest.fn(), 9 | })); 10 | jest.mock('../utils', () => ({ 11 | log: jest.fn(), 12 | })); 13 | jest.mock('../utils/config', () => ({ 14 | getConfig: jest.fn(), 15 | })); 16 | jest.mock('../utils/swaggerfile', () => ({ 17 | getSwaggerByPath: jest.fn(), 18 | getSwaggerByUrl: jest.fn(), 19 | })); 20 | 21 | beforeEach(() => { 22 | jest.resetAllMocks(); 23 | }); 24 | 25 | const name = 'swaggerlint-core'; 26 | 27 | describe('cli function', () => { 28 | it('exits when neither url nor swaggerPath are passed', async () => { 29 | const {getConfig} = require('../utils/config'); 30 | 31 | getConfig.mockReturnValueOnce({ 32 | type: 'success', 33 | config: {}, 34 | filepath: '~/foo/bar/baz.js', 35 | }); 36 | 37 | const result = await cli({_: []}); 38 | 39 | expect(result).toEqual({ 40 | code: 1, 41 | results: [ 42 | { 43 | src: '', 44 | schema: undefined, 45 | errors: [ 46 | { 47 | name, 48 | location: [], 49 | msg: 50 | 'Neither url nor path were provided for your swagger scheme', 51 | }, 52 | ], 53 | }, 54 | ], 55 | }); 56 | }); 57 | 58 | it('exits if config validation did not pass', async () => { 59 | const {swaggerlint} = require('../index'); 60 | const {getConfig} = require('../utils/config'); 61 | 62 | getConfig.mockReturnValueOnce({ 63 | type: 'error', 64 | error: 'Invalid extends field in the config', 65 | filepath: '~/foo/bar/baz.js', 66 | }); 67 | 68 | const result = await cli({_: ['some/path']}); 69 | 70 | expect(swaggerlint.mock.calls.length === 0).toBe(true); 71 | expect(getConfig.mock.calls).toEqual([[undefined]]); 72 | 73 | expect(result).toEqual({ 74 | code: 1, 75 | results: [ 76 | { 77 | src: '~/foo/bar/baz.js', 78 | schema: undefined, 79 | errors: [ 80 | { 81 | name, 82 | location: [], 83 | msg: 'Invalid extends field in the config', 84 | }, 85 | ], 86 | }, 87 | ], 88 | }); 89 | }); 90 | 91 | it('exits when passed config path does not exist', async () => { 92 | const {swaggerlint} = require('../index'); 93 | const {getConfig} = require('../utils/config'); 94 | 95 | getConfig.mockReturnValueOnce({ 96 | error: 'Swaggerlint config with a provided path does not exits.', 97 | type: 'fail', 98 | }); 99 | 100 | const config = 'lol/kek/foo/bar'; 101 | const result = await cli({_: ['some/path'], config}); 102 | 103 | expect(getConfig.mock.calls).toEqual([[config]]); 104 | expect(swaggerlint.mock.calls.length === 0).toBe(true); 105 | expect(result).toEqual({ 106 | code: 1, 107 | results: [ 108 | { 109 | src: '', 110 | schema: undefined, 111 | errors: [ 112 | { 113 | msg: 114 | 'Swaggerlint config with a provided path does not exits.', 115 | location: [], 116 | name, 117 | }, 118 | ], 119 | }, 120 | ], 121 | }); 122 | }); 123 | 124 | it('uses default config when cannot locate config file', async () => { 125 | const {swaggerlint} = require('../index'); 126 | const {getConfig} = require('../utils/config'); 127 | const {getSwaggerByPath} = require('../utils/swaggerfile'); 128 | 129 | const schema = {file: 'swagger'}; 130 | getSwaggerByPath.mockReturnValueOnce({swagger: schema}); 131 | 132 | getConfig.mockReturnValueOnce({ 133 | type: 'success', 134 | config: {rules: {}}, 135 | }); 136 | 137 | swaggerlint.mockReturnValueOnce([]); 138 | 139 | const result = await cli({_: ['some-path']}); 140 | 141 | expect(getConfig.mock.calls.length === 1).toBe(true); 142 | expect(swaggerlint.mock.calls).toEqual([[schema, {rules: {}}]]); 143 | expect(result).toEqual({ 144 | code: 0, 145 | results: [ 146 | { 147 | src: 'some-path', 148 | errors: [], 149 | schema, 150 | }, 151 | ], 152 | }); 153 | }); 154 | 155 | it('exits when passed path to swagger does not exist', async () => { 156 | const {swaggerlint} = require('../index'); 157 | const {getConfig} = require('../utils/config'); 158 | const {getSwaggerByPath} = require('../utils/swaggerfile'); 159 | 160 | getConfig.mockReturnValueOnce({ 161 | type: 'success', 162 | config: {rules: {}}, 163 | }); 164 | 165 | const error = 'File at the provided path does not exist.'; 166 | getSwaggerByPath.mockReturnValueOnce({schema: {}, error}); 167 | 168 | const path = 'lol/kek/foo/bar'; 169 | const result = await cli({_: [path]}); 170 | 171 | expect(swaggerlint.mock.calls.length === 0).toBe(true); 172 | expect(result).toEqual({ 173 | code: 1, 174 | results: [ 175 | { 176 | src: path, 177 | schema: undefined, 178 | errors: [ 179 | { 180 | msg: error, 181 | location: [], 182 | name, 183 | }, 184 | ], 185 | }, 186 | ], 187 | }); 188 | }); 189 | 190 | it('exits when cannot fetch from passed url', async () => { 191 | const {swaggerlint} = require('../index'); 192 | const {getSwaggerByUrl} = require('../utils/swaggerfile'); 193 | const {getConfig} = require('../utils/config'); 194 | 195 | getConfig.mockReturnValueOnce({ 196 | type: 'success', 197 | config: {rules: {}}, 198 | }); 199 | getSwaggerByUrl.mockImplementation(() => Promise.reject(null)); 200 | 201 | const url = 'https://lol.org/openapi'; 202 | const result = await cli({_: [url]}); 203 | 204 | expect(getSwaggerByUrl.mock.calls).toEqual([[url]]); 205 | expect(swaggerlint.mock.calls.length === 0).toBe(true); 206 | expect(result).toEqual({ 207 | code: 1, 208 | results: [ 209 | { 210 | src: url, 211 | schema: undefined, 212 | errors: [ 213 | { 214 | msg: 215 | 'Cannot fetch swagger scheme from the provided url', 216 | location: [], 217 | name, 218 | }, 219 | ], 220 | }, 221 | ], 222 | }); 223 | }); 224 | 225 | it('returns code 0 when no errors are found', async () => { 226 | const {swaggerlint} = require('../index'); 227 | const {getSwaggerByUrl} = require('../utils/swaggerfile'); 228 | const {getConfig} = require('../utils/config'); 229 | 230 | getConfig.mockReturnValueOnce({type: 'success', config: {rules: {}}}); 231 | swaggerlint.mockImplementation(() => []); 232 | getSwaggerByUrl.mockImplementation(() => Promise.resolve({})); 233 | 234 | const url = 'https://lol.org/openapi'; 235 | const result = await cli({_: [url]}); 236 | 237 | expect(getSwaggerByUrl.mock.calls).toEqual([[url]]); 238 | expect(swaggerlint.mock.calls).toEqual([[{}, {rules: {}}]]); 239 | expect(result).toEqual({ 240 | code: 0, 241 | results: [ 242 | { 243 | src: url, 244 | errors: [], 245 | schema: {}, 246 | }, 247 | ], 248 | }); 249 | }); 250 | 251 | it('returns code 1 when errors are found', async () => { 252 | const {swaggerlint} = require('../index'); 253 | const {getConfig} = require('../utils/config'); 254 | const {getSwaggerByUrl} = require('../utils/swaggerfile'); 255 | const errors = [{name: 'foo', location: [], msg: 'bar'}]; 256 | 257 | getConfig.mockReturnValueOnce({type: 'success', config: {rules: {}}}); 258 | swaggerlint.mockImplementation(() => errors); 259 | getSwaggerByUrl.mockImplementation(() => Promise.resolve({})); 260 | 261 | const url = 'https://lol.org/openapi'; 262 | const result = await cli({_: [url]}); 263 | 264 | expect(getSwaggerByUrl.mock.calls).toEqual([[url]]); 265 | expect(swaggerlint.mock.calls).toEqual([[{}, {rules: {}}]]); 266 | expect(result).toEqual({ 267 | code: 1, 268 | results: [ 269 | { 270 | src: url, 271 | errors, 272 | schema: {}, 273 | }, 274 | ], 275 | }); 276 | }); 277 | 278 | it('returns errors for both passed schemas', async () => { 279 | const {swaggerlint} = require('../index'); 280 | const {getConfig} = require('../utils/config'); 281 | const {getSwaggerByUrl} = require('../utils/swaggerfile'); 282 | const errors = [{name: 'foo', location: [], msg: 'bar'}]; 283 | 284 | getConfig.mockReturnValueOnce({type: 'success', config: {rules: {}}}); 285 | swaggerlint.mockImplementation(() => errors); 286 | getSwaggerByUrl.mockImplementation(() => Promise.resolve({})); 287 | 288 | const url = 'https://lol.org/openapi'; 289 | const result = await cli({_: [url, url]}); 290 | 291 | expect(getSwaggerByUrl.mock.calls).toEqual([[url], [url]]); 292 | expect(swaggerlint.mock.calls).toEqual([ 293 | [{}, {rules: {}}], 294 | [{}, {rules: {}}], 295 | ]); 296 | expect(result).toEqual({ 297 | code: 1, 298 | results: [ 299 | { 300 | src: url, 301 | errors, 302 | schema: {}, 303 | }, 304 | { 305 | src: url, 306 | errors, 307 | schema: {}, 308 | }, 309 | ], 310 | }); 311 | }); 312 | }); 313 | -------------------------------------------------------------------------------- /src/swaggerlint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Report, 3 | LintError, 4 | SwaggerlintConfig, 5 | SwaggerlintRule, 6 | SwaggerlintRuleSetting, 7 | SwaggerVisitors, 8 | OpenAPIVisitors, 9 | NodeWithLocation, 10 | Swagger, 11 | OpenAPI, 12 | RuleVisitorFunction, 13 | OpenAPITypes, 14 | } from './types'; 15 | import {isValidSwaggerVisitorName, hasKey, capitalize} from './utils'; 16 | import {validate} from './utils/validate-json'; 17 | 18 | import {isSwaggerObject} from './utils/swagger'; 19 | 20 | import * as oaUtils from './utils/openapi'; 21 | 22 | import defaultConfig from './defaultConfig'; 23 | 24 | import {rules} from './rules'; 25 | import * as walker from './walker'; 26 | 27 | type Validated = 28 | | { 29 | _type: 'swagger'; 30 | schema: Swagger.SwaggerObject; 31 | } 32 | | { 33 | _type: 'openAPI'; 34 | schema: OpenAPI.OpenAPIObject; 35 | } 36 | | null; 37 | 38 | type Info = 39 | | { 40 | _type: 'swagger'; 41 | schema: Swagger.SwaggerObject; 42 | visitors: SwaggerVisitors; 43 | } 44 | | { 45 | _type: 'openAPI'; 46 | schema: OpenAPI.OpenAPIObject; 47 | visitors: OpenAPIVisitors; 48 | }; 49 | 50 | function validateInput(schema: unknown): Validated { 51 | if (isSwaggerObject(schema)) { 52 | return { 53 | _type: 'swagger', 54 | schema, 55 | }; 56 | } 57 | 58 | if (oaUtils.isValidOpenAPIObject(schema)) { 59 | return { 60 | _type: 'openAPI', 61 | schema, 62 | }; 63 | } 64 | 65 | return null; 66 | } 67 | 68 | export function swaggerlint( 69 | input: unknown, 70 | config: SwaggerlintConfig, 71 | ): LintError[] { 72 | const errors: LintError[] = []; 73 | const validated = validateInput(input); 74 | 75 | if (validated === null) { 76 | return [ 77 | { 78 | msg: 'You have supplier neither OpenAPI nor Swagger schema', 79 | name: 'swaggerlint-core', 80 | location: [], 81 | }, 82 | ]; 83 | } 84 | 85 | const walkerResult = 86 | validated._type === 'swagger' 87 | ? walker.walkSwagger(validated.schema, config.ignore) 88 | : walker.walkOpenAPI(validated.schema, config.ignore || {}); 89 | 90 | if ('errors' in walkerResult) { 91 | return walkerResult.errors; 92 | } 93 | 94 | const info = { 95 | ...validated, 96 | visitors: walkerResult.visitors, 97 | } as Info; 98 | 99 | type ValidatedRule = { 100 | rule: SwaggerlintRule; 101 | setting: SwaggerlintRuleSetting; 102 | }; 103 | 104 | const validatedRules: ValidatedRule[] = Object.keys(config.rules).reduce( 105 | (acc, ruleName) => { 106 | const rule = rules[ruleName]; 107 | if (!rule) { 108 | errors.push({ 109 | msg: `swaggerlint.config.js contains unknown rule "${ruleName}"`, 110 | name: 'swaggerlint-core', 111 | location: [], 112 | }); 113 | 114 | return acc; 115 | } 116 | 117 | let setting = config.rules[ruleName]; 118 | 119 | if (setting === false) { 120 | return acc; 121 | } 122 | if (setting === true) { 123 | const defaultConfigSetting = defaultConfig.rules[ruleName]; 124 | if (typeof defaultConfigSetting !== 'undefined') { 125 | setting = defaultConfigSetting; 126 | } else if ('defaultSetting' in rule) { 127 | setting = rule.defaultSetting; 128 | } 129 | } 130 | 131 | if (rule.meta && 'schema' in rule.meta && rule.meta?.schema) { 132 | const ruleSettingErrors = validate(rule.meta.schema, setting); 133 | 134 | if (ruleSettingErrors.length) { 135 | ruleSettingErrors.forEach(se => { 136 | const err: LintError = { 137 | name: rule.name, 138 | msg: 'Invalid rule setting.', 139 | location: [], 140 | }; 141 | 142 | switch (se.keyword) { 143 | case 'enum': 144 | if (typeof se.schema[0] === 'string') { 145 | const gotValue = JSON.stringify(se.data); 146 | const expectedValues = se.schema 147 | .map((x: string) => `"${x}"`) 148 | .join(', '); 149 | err.msg = `Invalid rule setting. Got ${gotValue}, expected: ${expectedValues}`; 150 | } 151 | break; 152 | case 'additionalProperties': 153 | if (hasKey('additionalProperty', se.params)) { 154 | err.msg = `Unexpected property "${se.params.additionalProperty}" in rule settings`; 155 | } 156 | break; 157 | case 'maxItems': 158 | err.msg = `Rule setting ${se.message}`; 159 | break; 160 | case 'minItems': 161 | err.msg = `Rule setting ${se.message}`; 162 | break; 163 | case 'required': 164 | err.msg = `${capitalize( 165 | se.message || 'Missing required field', 166 | )}, got ${typeof se.data}`; 167 | break; 168 | case 'type': 169 | err.msg = `Value ${ 170 | se.message 171 | }, got ${typeof se.data}`; 172 | break; 173 | } 174 | 175 | errors.push(err); 176 | }); 177 | 178 | return acc; 179 | } 180 | } 181 | 182 | acc.push({ 183 | rule, 184 | setting, 185 | }); 186 | 187 | return acc; 188 | }, 189 | [] as ValidatedRule[], 190 | ); 191 | 192 | if (validatedRules.length === 0) { 193 | errors.push({ 194 | msg: 195 | 'Found 0 enabled rules. Swaggerlint requires at least one rule enabled.', 196 | name: 'swaggerlint-core', 197 | location: [], 198 | }); 199 | 200 | return errors; 201 | } 202 | 203 | if (info._type === 'swagger') { 204 | validatedRules.forEach(({rule, setting}) => { 205 | const {swaggerVisitor} = rule; 206 | if (!swaggerVisitor) return; 207 | Object.keys(swaggerVisitor).forEach(visitorName => { 208 | // TODO swagger & openapi visitor names checks 209 | if (!isValidSwaggerVisitorName(visitorName)) return; 210 | 211 | const check = swaggerVisitor[visitorName]; 212 | 213 | const specificVisitor = info.visitors[visitorName]; 214 | specificVisitor.forEach( 215 | /** 216 | * TODO: note the type for `node` 217 | * ts infers example object yet it can be any of the objects 218 | */ 219 | ({node, location}) => { 220 | const report = makeReportFunc(errors, rule, location); 221 | if (typeof check === 'function') { 222 | /** 223 | * ts manages to only infer example object here, 224 | * due to the checks above function call is supposed to be safe 225 | * 226 | */ 227 | // @ts-expect-error: @see https://bit.ly/2MNEii7 228 | check({node, location, setting, report, config}); 229 | } 230 | }, 231 | ); 232 | }); 233 | }); 234 | } else { 235 | validatedRules.forEach(({rule, setting}) => { 236 | const {openapiVisitor} = rule; 237 | if (!openapiVisitor) return; 238 | Object.keys(openapiVisitor).forEach(visitorName => { 239 | if (!oaUtils.isValidVisitorName(visitorName)) return; 240 | 241 | type CurrentObject = OpenAPITypes[typeof visitorName]; 242 | const check = openapiVisitor[ 243 | visitorName 244 | ] as RuleVisitorFunction | void; 245 | 246 | if (check === undefined) return; 247 | 248 | const specificVisitor = info.visitors[visitorName]; 249 | specificVisitor.forEach( 250 | ({node, location}: NodeWithLocation) => { 251 | const report = makeReportFunc(errors, rule, location); 252 | 253 | check({node, location, setting, report, config}); 254 | }, 255 | ); 256 | }); 257 | }); 258 | } 259 | 260 | return errors; 261 | } 262 | 263 | function makeReportFunc( 264 | errors: LintError[], 265 | rule: SwaggerlintRule, 266 | location: string[], 267 | ): Report { 268 | return function (arg): void { 269 | if (hasKey('messageId', arg) && arg.messageId) { 270 | const msgTemplate = rule.meta?.messages?.[arg.messageId] || ''; 271 | // TODO: return an error for the rule if `messageId` is unknown 272 | /* eslint-disable indent */ 273 | const message: string = arg.data 274 | ? Object.keys(arg.data).reduce((acc, key) => { 275 | return acc.replace( 276 | new RegExp(`{{\s*${key}\s*}}`, 'g'), 277 | // @ts-expect-error: this code will not run if data is undefined. 278 | arg.data[key], 279 | ); 280 | }, msgTemplate) 281 | : msgTemplate; 282 | /* eslint-enable indent */ 283 | 284 | const err: LintError = { 285 | name: rule.name, 286 | msg: message, 287 | messageId: arg.messageId, 288 | location: arg.location || location, 289 | }; 290 | if (arg.data) err.data = arg.data; 291 | errors.push(err); 292 | return; 293 | } 294 | 295 | if (hasKey('message', arg) && arg.message) { 296 | const err: LintError = { 297 | name: rule.name, 298 | msg: arg.message, 299 | location: arg.location || location, 300 | }; 301 | errors.push(err); 302 | return; 303 | } 304 | 305 | throw new Error( 306 | `Invalid data passed to report function:\n\n${JSON.stringify( 307 | arg, 308 | null, 309 | 4, 310 | )}`, 311 | ); 312 | }; 313 | } 314 | --------------------------------------------------------------------------------