├── .gitignore ├── tsconfig.json ├── .editorconfig ├── jest.config.js ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── tsup.config.ts ├── __tests__ ├── docs-api-host.test.ts ├── docs-info-contact.test.ts ├── docs-api-host-not-example.test.ts ├── docs-tags-alphabetical.test.ts ├── docs-api-server-not-example.test.ts ├── docs-tags.test.ts ├── docs-operationId-valid-in-url.test.ts ├── docs-api-schemes.test.ts ├── docs-api-servers.test.ts ├── docs-operation-tags.test.ts ├── __helpers__ │ └── helper.ts ├── docs-parameters-example-or-schema.test.ts ├── docs-media-type-examples-or-schema.test.ts └── docs-description.test.ts ├── package.json ├── README.md └── src └── ruleset.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | # Unix-style newlines with a newline ending every file 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [typescript] 10 | quote_type = single 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = async () => { 3 | return { 4 | preset: 'ts-jest', 5 | testPathIgnorePatterns: ['__helpers__'], 6 | testEnvironment: 'node', 7 | globals: { 8 | 'ts-jest': { 9 | useIsolatedModules: true, 10 | }, 11 | }, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | # Only run tests when relevant files are changing 6 | paths-ignore: 7 | - '**.md' 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 20 18 | cache: npm 19 | - run: npm ci 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'tsup'; 2 | 3 | export default { 4 | entry: ["src/ruleset.ts"], 5 | clean: true, 6 | dts: true, 7 | target: "es2018", 8 | format: ["cjs", "esm"], 9 | sourcemap: true, 10 | noExternal: ["@stoplight/types"], 11 | external: ["@stoplight/spectral-core"], 12 | footer({ format }) { 13 | if (format === "cjs") { 14 | return { 15 | js: "module.exports = module.exports.default;", 16 | }; 17 | } 18 | 19 | return {}; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release npm package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 20 17 | - run: npm ci 18 | - run: npm test 19 | - run: npm run type-check 20 | - run: npm run build 21 | - run: npx semantic-release --branches main 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | -------------------------------------------------------------------------------- /__tests__/docs-api-host.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-api-host", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | paths: {}, 10 | host: "stoplight.io", 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "missing host", 17 | document: { 18 | swagger: "2.0", 19 | paths: {}, 20 | }, 21 | errors: [ 22 | { 23 | message: "API host must be present and non-empty string.", 24 | path: [], 25 | severity: DiagnosticSeverity.Warning, 26 | }, 27 | ], 28 | }, 29 | ]); 30 | -------------------------------------------------------------------------------- /__tests__/docs-info-contact.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-info-contact", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { 10 | contact: { 11 | } 12 | }, 13 | paths: {}, 14 | }, 15 | errors: [], 16 | }, 17 | 18 | { 19 | name: "invalid case", 20 | document: { 21 | openapi: "3.1.0", 22 | info: { 23 | }, 24 | paths: {}, 25 | }, 26 | errors: [ 27 | { 28 | message: '"info.contact" property must be truthy.', 29 | path: ["info"], 30 | severity: DiagnosticSeverity.Warning, 31 | }, 32 | ], 33 | }, 34 | 35 | ]); 36 | -------------------------------------------------------------------------------- /__tests__/docs-api-host-not-example.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-api-host-not-example", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | paths: {}, 10 | host: "stoplight.io", 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "server is example.com", 17 | document: { 18 | swagger: "2.0", 19 | paths: {}, 20 | host: "https://example.com", 21 | }, 22 | errors: [ 23 | { 24 | message: "Host URL should not point at example.com.", 25 | path: ["host"], 26 | severity: DiagnosticSeverity.Warning, 27 | }, 28 | ], 29 | }, 30 | ]); 31 | -------------------------------------------------------------------------------- /__tests__/docs-tags-alphabetical.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-tags-alphabetical", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | paths: {}, 10 | tags: [{ name: "a-tag" }, { name: "b-tag" }], 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "tags is not in alphabetical order", 17 | document: { 18 | swagger: "2.0", 19 | paths: {}, 20 | tags: [{ name: "b-tag" }, { name: "a-tag" }], 21 | }, 22 | errors: [ 23 | { 24 | message: "Tags should be defined in alphabetical order.", 25 | path: ["tags"], 26 | severity: DiagnosticSeverity.Warning, 27 | }, 28 | ], 29 | }, 30 | ]); 31 | -------------------------------------------------------------------------------- /__tests__/docs-api-server-not-example.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-api-server-not-example.com", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.0.0", 9 | paths: {}, 10 | servers: [ 11 | { 12 | url: "https://stoplight.io", 13 | }, 14 | ], 15 | }, 16 | errors: [], 17 | }, 18 | 19 | { 20 | name: "server is example.com", 21 | document: { 22 | openapi: "3.0.0", 23 | paths: {}, 24 | servers: [ 25 | { 26 | url: "https://example.com", 27 | }, 28 | ], 29 | }, 30 | errors: [ 31 | { 32 | message: "Server URL should not point at example.com.", 33 | path: ["servers", "0", "url"], 34 | severity: DiagnosticSeverity.Warning, 35 | }, 36 | ], 37 | }, 38 | ]); 39 | -------------------------------------------------------------------------------- /__tests__/docs-tags.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-tags", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | paths: {}, 10 | tags: [{ name: "todos" }], 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "missing tags", 17 | document: { 18 | swagger: "2.0", 19 | paths: {}, 20 | }, 21 | errors: [ 22 | { 23 | message: '"tags" property must exist.', 24 | path: [], 25 | severity: DiagnosticSeverity.Warning, 26 | }, 27 | ], 28 | }, 29 | 30 | { 31 | name: "empty tags", 32 | document: { 33 | swagger: "2.0", 34 | paths: {}, 35 | tags: [], 36 | }, 37 | errors: [ 38 | { 39 | message: '"tags" property must not have fewer than 1 items.', 40 | path: ["tags"], 41 | severity: DiagnosticSeverity.Warning, 42 | }, 43 | ], 44 | }, 45 | ]); 46 | -------------------------------------------------------------------------------- /__tests__/docs-operationId-valid-in-url.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-operationId-valid-in-url", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | paths: { 10 | "/todos": { 11 | get: { 12 | operationId: "A-Za-z0-9-._~:/?#[]@!$&'()*+,;=", 13 | }, 14 | }, 15 | }, 16 | }, 17 | errors: [], 18 | }, 19 | 20 | { 21 | name: "operationId contains invalid characters", 22 | document: { 23 | swagger: "2.0", 24 | paths: { 25 | "/todos": { 26 | get: { 27 | operationId: "foo-^^", 28 | }, 29 | }, 30 | }, 31 | }, 32 | errors: [ 33 | { 34 | message: "operationId must only contain URL friendly characters.", 35 | path: ["paths", "/todos", "get", "operationId"], 36 | severity: DiagnosticSeverity.Error, 37 | }, 38 | ], 39 | }, 40 | ]); 41 | -------------------------------------------------------------------------------- /__tests__/docs-api-schemes.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-api-schemes", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | paths: {}, 10 | schemes: ["http"], 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "schemes is missing", 17 | document: { 18 | swagger: "2.0", 19 | paths: {}, 20 | }, 21 | errors: [ 22 | { 23 | message: "API schemes should be present and non-empty array.", 24 | path: [], 25 | severity: DiagnosticSeverity.Warning, 26 | }, 27 | ], 28 | }, 29 | 30 | { 31 | name: "schemes is an empty array", 32 | document: { 33 | swagger: "2.0", 34 | paths: {}, 35 | schemes: [], 36 | }, 37 | errors: [ 38 | { 39 | message: "API schemes should be present and non-empty array.", 40 | path: ["schemes"], 41 | severity: DiagnosticSeverity.Warning, 42 | }, 43 | ], 44 | }, 45 | ]); 46 | -------------------------------------------------------------------------------- /__tests__/docs-api-servers.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-api-servers", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.0.0", 9 | paths: {}, 10 | servers: [{ url: "https://stoplight.io" }], 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "servers is missing", 17 | document: { 18 | openapi: "3.0.0", 19 | paths: {}, 20 | }, 21 | errors: [ 22 | { 23 | message: "API servers should be present and non-empty array.", 24 | path: [], 25 | severity: DiagnosticSeverity.Warning, 26 | }, 27 | ], 28 | }, 29 | 30 | { 31 | name: "servers is an empty array", 32 | document: { 33 | openapi: "3.0.0", 34 | paths: {}, 35 | servers: [], 36 | }, 37 | errors: [ 38 | { 39 | message: "API servers should be present and non-empty array.", 40 | path: ["servers"], 41 | severity: DiagnosticSeverity.Warning, 42 | }, 43 | ], 44 | }, 45 | ]); 46 | -------------------------------------------------------------------------------- /__tests__/docs-operation-tags.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-operation-tags", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | paths: { 10 | "/todos": { 11 | get: { 12 | tags: [{ name: "todos" }], 13 | }, 14 | }, 15 | }, 16 | }, 17 | errors: [], 18 | }, 19 | 20 | { 21 | name: "tags is missing", 22 | 23 | document: { 24 | swagger: "2.0", 25 | paths: { 26 | "/todos": { 27 | get: {}, 28 | }, 29 | }, 30 | }, 31 | errors: [ 32 | { 33 | message: "Operation should have non-empty `tags` array.", 34 | path: ["paths", "/todos", "get"], 35 | severity: DiagnosticSeverity.Warning, 36 | }, 37 | ], 38 | }, 39 | 40 | { 41 | name: "tags is empty", 42 | document: { 43 | swagger: "2.0", 44 | paths: { 45 | "/todos": { 46 | get: { 47 | tags: [], 48 | }, 49 | }, 50 | }, 51 | }, 52 | errors: [ 53 | { 54 | message: "Operation should have non-empty `tags` array.", 55 | path: ["paths", "/todos", "get", "tags"], 56 | severity: DiagnosticSeverity.Warning, 57 | }, 58 | ], 59 | }, 60 | ]); 61 | -------------------------------------------------------------------------------- /__tests__/__helpers__/helper.ts: -------------------------------------------------------------------------------- 1 | import { IRuleResult, Spectral, Document, Ruleset } from '@stoplight/spectral-core'; 2 | import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver'; 3 | import sourceRuleset from '../../src/ruleset'; 4 | 5 | export type RuleName = keyof Ruleset['rules']; 6 | 7 | type Scenario = ReadonlyArray< 8 | Readonly<{ 9 | name: string; 10 | document: Record | Document; 11 | errors: ReadonlyArray>; 12 | }> 13 | >; 14 | 15 | export default (ruleName: RuleName, tests: Scenario): void => { 16 | describe(`Rule ${ruleName}`, () => { 17 | for (const testCase of tests) { 18 | it.concurrent(testCase.name, async () => { 19 | const s = createWithRules([ruleName]); 20 | const doc = testCase.document instanceof Document ? testCase.document : JSON.stringify(testCase.document); 21 | const errors = await s.run(doc); 22 | 23 | expect(errors.filter(({ code }) => code === ruleName)).toEqual( 24 | testCase.errors.map(error => expect.objectContaining(error) as unknown), 25 | ); 26 | }); 27 | } 28 | }); 29 | }; 30 | 31 | export function createWithRules(rules: (keyof Ruleset['rules'])[]): Spectral { 32 | const s = new Spectral({ resolver: httpAndFileResolver }); 33 | 34 | s.setRuleset({ 35 | extends: [ 36 | [sourceRuleset, 'off'], 37 | ], 38 | rules: rules.reduce((obj: any, name) => { 39 | obj[name] = true; 40 | return obj; 41 | }, {}), 42 | }); 43 | 44 | return s; 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stoplight/spectral-documentation", 3 | "version": "0.0.0", 4 | "description": "Your documentation is only as useful as the quality of the information you've provided, so make sure you're taking full advantage of the features OpenAPI has to offer.", 5 | "main": "dist/ruleset.js", 6 | "module": "dist/ruleset.mjs", 7 | "type": "commonjs", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/ruleset.d.ts", 11 | "import": "./dist/ruleset.mjs", 12 | "require": "./dist/ruleset.js" 13 | } 14 | }, 15 | "directories": { 16 | "test": "test" 17 | }, 18 | "scripts": { 19 | "build": "tsup", 20 | "type-check": "tsc --noEmit --noErrorTruncation --pretty false --incremental false", 21 | "test": "jest" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/stoplightio/spectral-documentation.git" 26 | }, 27 | "keywords": [ 28 | "openapi", 29 | "openapi3", 30 | "openapi31", 31 | "api-design" 32 | ], 33 | "author": "Phil Sturgeon ", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/stoplightio/spectral-documentation/issues" 37 | }, 38 | "homepage": "https://github.com/stoplightio/spectral-documentation#readme", 39 | "dependencies": { 40 | "@stoplight/spectral-formats": "^1.2.0", 41 | "@stoplight/spectral-functions": "^1.7.2" 42 | }, 43 | "devDependencies": { 44 | "@sindresorhus/tsconfig": "^3.0.1", 45 | "@stoplight/types": "^13.6.0", 46 | "@types/jest": "^28.1.6", 47 | "jest": "^28.0", 48 | "ts-jest": "^28.0", 49 | "tsup": "^6.2.3", 50 | "typescript": "^4.8.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /__tests__/docs-parameters-example-or-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-parameter-examples-or-schema", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.0.0", 9 | info: {}, 10 | paths: { 11 | "/user_busy_times": { 12 | get: { 13 | summary: "List User Busy Times", 14 | parameters: [ 15 | { 16 | name: "user", 17 | in: "query", 18 | schema: { 19 | type: "string", 20 | format: "uri" 21 | }, 22 | description: "The uri associated with the user", 23 | example: "https://api.calendly.com/users/AAAAAAAAAAAAAAAA", 24 | required: true 25 | } 26 | ], 27 | responses: { 28 | "200": { 29 | description: "OK", 30 | } 31 | } 32 | } 33 | } 34 | }, 35 | }, 36 | errors: [], 37 | }, 38 | 39 | { 40 | name: "invalid case", 41 | document: { 42 | openapi: "3.0.0", 43 | info: {}, 44 | paths: { 45 | "/user_busy_times": { 46 | get: { 47 | summary: "List User Busy Times", 48 | parameters: [ 49 | { 50 | name: "user", 51 | in: "query", 52 | description: "neither a schema or an example", 53 | required: true 54 | }, 55 | ], 56 | responses: { 57 | "200": { 58 | description: "OK", 59 | } 60 | } 61 | } 62 | } 63 | }, 64 | }, 65 | errors: [ 66 | { 67 | message: 'No example or schema provided for 0.', 68 | path: ["paths", "/user_busy_times", "get", "parameters", "0"], 69 | severity: DiagnosticSeverity.Error, 70 | }, 71 | ], 72 | }, 73 | ]); 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spectral Documentation Ruleset 2 | 3 | [![NPM Downloads](https://img.shields.io/npm/dw/@stoplight/spectral-documentation?color=blue)](https://www.npmjs.com/package/@stoplight/spectral-documentation) [![Stoplight Forest](https://img.shields.io/ecologi/trees/stoplightinc)][stoplight_forest] 4 | 5 | Scan an [OpenAPI](https://spec.openapis.org/oas/v3.1.0) description to make sure you're leveraging enough of its features to help documentation tools like Stoplight Elements, ReDoc, and Swagger UI build the best quality API Reference Documentation possible. 6 | 7 | ## Installation 8 | 9 | ``` bash 10 | npm install --save -D @stoplight/spectral-documentation 11 | npm install --save -D @stoplight/spectral-cli 12 | ``` 13 | 14 | ## Usage 15 | 16 | 17 | Create a local ruleset that extends the ruleset. In its most basic form this just tells Spectral what ruleset you want to use, but it will allow you to customise things, add your own rules, turn bits off if its causing trouble. 18 | 19 | ``` 20 | cd ~/src/ 21 | 22 | echo 'extends: ["@stoplight/spectral-documentation"]' > .spectral.yaml 23 | ``` 24 | 25 | _If you're using VS Code or Stoplight Studio then the NPM modules will not be available. Instead you can use the CDN hosted version:_ 26 | 27 | ``` 28 | echo 'extends: ["https://unpkg.com/@stoplight/spectral-documentation/dist/ruleset.mjs"]' > .spectral.yaml 29 | ``` 30 | 31 | _**Note:** You need to use the full URL with CDN hosted rulesets because Spectral [cannot follow redirects through extends](https://github.com/stoplightio/spectral/issues/2266)._ 32 | 33 | Next, use Spectral CLI to lint against your OpenAPI description. Don't have any OpenAPI? [Record some HTTP traffic to make OpenAPI](https://apisyouwonthate.com/blog/creating-openapi-from-http-traffic) and then you can switch to API Design-First going forwards. 34 | 35 | ``` 36 | spectral lint api/openapi.yaml 37 | ``` 38 | 39 | You should see some output like this: 40 | 41 | ``` 42 | /Users/phil/src/protect-earth-api/api/openapi.yaml 43 | 44:17 warning no-path-versioning #/paths/~1v1 contains a version number. API paths SHOULD NOT have versioning in the path. It SHOULD be in the server URL instead. paths./v1 44 | ``` 45 | 46 | Now you have some things to work on for your API. Thankfully these are only at the `warning` severity, and that is not going to [fail continuous integration](https://meta.stoplight.io/docs/spectral/ZG9jOjExNTMyOTAx-continuous-integration) (unless [you want them to](https://meta.stoplight.io/docs/spectral/ZG9jOjI1MTg1-spectral-cli#error-results)). 47 | 48 | There are [a bunch of other rulesets](https://github.com/stoplightio/spectral-rulesets) you can use, or use for inspiration for your own rulesets and API Style Guides. 49 | 50 | ## 🎉 Thanks 51 | 52 | - [Phil Sturgeon](https://github.com/philsturgeon) - Made some of these fairly opinionated but probably reasonable rules. 53 | 54 | ## 📜 License 55 | 56 | This repository is licensed under the MIT license. 57 | 58 | ## 🌲 Sponsor 59 | 60 | If you would like to thank us for creating Spectral, we ask that you [**buy the world a tree**][stoplight_forest]. 61 | 62 | [stoplight_forest]: https://ecologi.com/stoplightinc 63 | -------------------------------------------------------------------------------- /__tests__/docs-media-type-examples-or-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-media-type-examples-or-schema", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.0.0", 9 | info: { 10 | title: "Calendars API", 11 | version: "2.0.0", 12 | }, 13 | paths: { 14 | "/organization/invitees": { 15 | post: { 16 | summary: "Invite User to Organization", 17 | responses: { 18 | "201": { 19 | description: "Created", 20 | content: { 21 | "application/json": { 22 | schema: { 23 | type: "object", 24 | }, 25 | examples: { 26 | Invitation: { 27 | value: {}, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | requestBody: { 35 | required: true, 36 | content: { 37 | "application/json": { 38 | schema: { 39 | type: "object", 40 | }, 41 | examples: { 42 | Example: { 43 | value: {}, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | errors: [], 54 | }, 55 | 56 | { 57 | name: "invalid case", 58 | document: { 59 | openapi: "3.0.0", 60 | info: { 61 | title: "Calendars API", 62 | version: "2.0.0", 63 | }, 64 | paths: { 65 | "/organization/invitees": { 66 | post: { 67 | summary: "Invite User to Organization", 68 | responses: { 69 | "201": { 70 | description: "Created", 71 | content: { 72 | "application/json": {}, 73 | }, 74 | }, 75 | }, 76 | requestBody: { 77 | required: true, 78 | content: { 79 | "application/json": { 80 | description: "no schema or examples!", 81 | }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | errors: [ 89 | { 90 | message: "No example or schema provided for application/json.", 91 | path: [ 92 | "paths", 93 | "/organization/invitees", 94 | "post", 95 | "responses", 96 | "201", 97 | "content", 98 | "application/json", 99 | ], 100 | severity: DiagnosticSeverity.Error, 101 | }, 102 | { 103 | message: "No example or schema provided for application/json.", 104 | path: [ 105 | "paths", 106 | "/organization/invitees", 107 | "post", 108 | "requestBody", 109 | "content", 110 | "application/json", 111 | ], 112 | severity: DiagnosticSeverity.Error, 113 | }, 114 | ], 115 | }, 116 | ]); 117 | -------------------------------------------------------------------------------- /__tests__/docs-description.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("docs-description", [ 5 | // -- info.description --- 6 | 7 | { 8 | name: "valid case: over 20 chars, upper first, and full stop at the end.", 9 | document: { 10 | openapi: "3.1.0", 11 | info: { description: "Bla bla bla very interesting mmm yes." }, 12 | paths: {}, 13 | }, 14 | errors: [], 15 | }, 16 | 17 | { 18 | name: "invalid case: no info.description", 19 | document: { 20 | openapi: "3.1.0", 21 | info: {}, 22 | paths: {}, 23 | }, 24 | errors: [ 25 | { 26 | message: '"info.description" property must be truthy.', 27 | path: ["info"], 28 | severity: DiagnosticSeverity.Warning, 29 | }, 30 | ], 31 | }, 32 | 33 | { 34 | name: "invalid: shorter than 20 characters", 35 | document: { 36 | openapi: "3.1.0", 37 | info: { description: "Bit short." }, 38 | paths: { 39 | "/foo/{id}": {}, 40 | }, 41 | }, 42 | errors: [ 43 | { 44 | message: '"description" property must be longer than 20.', 45 | path: ["info", "description"], 46 | severity: DiagnosticSeverity.Warning, 47 | }, 48 | ], 49 | }, 50 | 51 | { 52 | name: "valid: longer than 20 characters", 53 | document: { 54 | openapi: "3.1.0", 55 | info: { 56 | description: "Bit short? Not anymore! Bahahah lots of wooooords.", 57 | }, 58 | paths: { 59 | "/foo/{id}": {}, 60 | }, 61 | }, 62 | errors: [], 63 | }, 64 | 65 | { 66 | name: "invalid: description does not start capital letter", 67 | document: { 68 | openapi: "3.1.0", 69 | info: { 70 | description: 71 | "lower case looks funny for most documentation tools and they dont wanna mess with your strings.", 72 | }, 73 | paths: { 74 | "/foo/{id}": {}, 75 | }, 76 | }, 77 | errors: [ 78 | { 79 | message: 80 | '"lower case looks funny for most documentation tools and they dont wanna mess with your strings." must match the pattern "/^[A-Z]/".', 81 | path: ["info", "description"], 82 | severity: DiagnosticSeverity.Warning, 83 | }, 84 | ], 85 | }, 86 | 87 | { 88 | name: "valid: description must start capital letter", 89 | document: { 90 | openapi: "3.1.0", 91 | info: { 92 | description: 93 | "Upper case looks more human for most documentation tools as they dont wanna mess with your strings.", 94 | }, 95 | paths: { 96 | "/foo/{id}": {}, 97 | }, 98 | }, 99 | errors: [], 100 | }, 101 | 102 | { 103 | name: "invalid: description needs a full stop at the end", 104 | document: { 105 | openapi: "3.1.0", 106 | info: { 107 | description: 108 | "Descriptions are strings for humans, and they are sentences or paragaphs, so should end with a", 109 | }, 110 | paths: { 111 | "/foo/{id}": {}, 112 | }, 113 | }, 114 | errors: [ 115 | { 116 | message: 117 | '"Descriptions are strings for humans, and they are sentences or paragaphs, so should end with a" must match the pattern "\\\\.$".', 118 | path: ["info", "description"], 119 | severity: DiagnosticSeverity.Warning, 120 | }, 121 | ], 122 | }, 123 | 124 | { 125 | name: "valid: description has a full stop at the end", 126 | document: { 127 | openapi: "3.1.0", 128 | info: { 129 | description: 130 | "Descriptions are strings for humans, and they are sentences or paragaphs, so should end with a '.'.", 131 | }, 132 | paths: { 133 | "/foo/{id}": {}, 134 | }, 135 | }, 136 | errors: [], 137 | }, 138 | 139 | // -- schema description -- 140 | 141 | { 142 | name: "invalid case: no description", 143 | document: { 144 | openapi: "3.1.0", 145 | info: { description: "Bla bla bla very interesting mmm yes." }, 146 | components: { 147 | schemas: { 148 | Tree: {}, 149 | }, 150 | }, 151 | }, 152 | errors: [ 153 | { 154 | message: '"Tree.description" property must be truthy.', 155 | path: ["components", "schemas", "Tree"], 156 | severity: DiagnosticSeverity.Warning, 157 | }, 158 | ], 159 | }, 160 | 161 | { 162 | name: "invalid: shorter than 20 characters", 163 | document: { 164 | openapi: "3.1.0", 165 | info: { description: "Bla bla bla very interesting mmm yes." }, 166 | components: { 167 | schemas: { 168 | Tree: { 169 | description: "Its a tree genius.", 170 | }, 171 | }, 172 | }, 173 | }, 174 | errors: [ 175 | { 176 | message: '"description" property must be longer than 20.', 177 | path: ["components", "schemas", "Tree", "description"], 178 | severity: DiagnosticSeverity.Warning, 179 | }, 180 | ], 181 | }, 182 | 183 | { 184 | name: "valid: longer than 20 characters", 185 | document: { 186 | openapi: "3.1.0", 187 | info: { description: "Bla bla bla very interesting mmm yes." }, 188 | components: { 189 | schemas: { 190 | Tree: { 191 | description: 192 | "A sapling, whether its tree, or a shrub, woodland or hedgerow, its all a Tree.", 193 | }, 194 | }, 195 | }, 196 | }, 197 | errors: [], 198 | }, 199 | 200 | { 201 | name: "invalid: description should start with capital letter", 202 | document: { 203 | openapi: "3.1.0", 204 | info: { description: "Bla bla bla very interesting mmm yes." }, 205 | components: { 206 | schemas: { 207 | Tree: { 208 | description: 209 | "a sapling, whether its tree, or a shrub, woodland or hedgerow, its all a Tree.", 210 | }, 211 | }, 212 | }, 213 | }, 214 | errors: [ 215 | { 216 | message: 217 | '"a sapling, whether its tree, or a shrub, woodland or hedgerow, its all a Tree." must match the pattern "/^[A-Z]/".', 218 | path: ["components", "schemas", "Tree", "description"], 219 | severity: DiagnosticSeverity.Warning, 220 | }, 221 | ], 222 | }, 223 | 224 | { 225 | name: "valid: description must start capital letter", 226 | document: { 227 | openapi: "3.1.0", 228 | info: { description: "Bla bla bla very interesting mmm yes." }, 229 | components: { 230 | schemas: { 231 | Tree: { 232 | description: 233 | "A sapling, whether its tree, or a shrub, woodland or hedgerow, its all a Tree.", 234 | }, 235 | }, 236 | }, 237 | }, 238 | errors: [], 239 | }, 240 | 241 | { 242 | name: "invalid: description needs a full stop at the end", 243 | document: { 244 | openapi: "3.1.0", 245 | info: { description: "Bla bla bla very interesting mmm yes." }, 246 | components: { 247 | schemas: { 248 | Tree: { 249 | description: 250 | "A sapling, whether its tree, or a shrub, woodland or hedgerow, its all a Tree", 251 | }, 252 | }, 253 | }, 254 | }, 255 | errors: [ 256 | { 257 | message: 258 | '"A sapling, whether its tree, or a shrub, woodland or hedgerow, its all a Tree" must match the pattern "\\\\.$".', 259 | path: ["components", "schemas", "Tree", "description"], 260 | severity: DiagnosticSeverity.Warning, 261 | }, 262 | ], 263 | }, 264 | 265 | { 266 | name: "valid: description has a full stop at the end", 267 | document: { 268 | openapi: "3.1.0", 269 | info: { description: "Bla bla bla very interesting mmm yes." }, 270 | components: { 271 | schemas: { 272 | Tree: { 273 | description: 274 | "A sapling, whether its tree, or a shrub, woodland or hedgerow, its all a Tree.", 275 | }, 276 | }, 277 | }, 278 | }, 279 | errors: [], 280 | }, 281 | 282 | // --- parameter examples --- 283 | 284 | { 285 | name: "valid top level path parameters", 286 | document: { 287 | swagger: "2.0", 288 | paths: { 289 | "/todos": { 290 | parameters: [ 291 | { 292 | name: "limit", 293 | in: "query", 294 | description: "This is how it works.", 295 | type: "integer", 296 | }, 297 | ], 298 | }, 299 | }, 300 | }, 301 | errors: [], 302 | }, 303 | 304 | { 305 | name: "valid operation level parameters", 306 | document: { 307 | swagger: "2.0", 308 | paths: { 309 | "/todos": { 310 | get: { 311 | description: "Should be present here too since we look for it.", 312 | parameters: [ 313 | { 314 | name: "limit", 315 | in: "query", 316 | description: "This is how it works.", 317 | type: "integer", 318 | }, 319 | ], 320 | }, 321 | }, 322 | }, 323 | }, 324 | errors: [], 325 | }, 326 | 327 | { 328 | name: "invalid case: top level path parameter description is missing", 329 | document: { 330 | swagger: "2.0", 331 | paths: { 332 | "/todos": { 333 | parameters: [ 334 | { 335 | name: "limit", 336 | in: "query", 337 | type: "integer", 338 | }, 339 | ], 340 | }, 341 | }, 342 | }, 343 | errors: [ 344 | { 345 | message: '"[0].description" property must be truthy.', 346 | path: ["paths", "/todos", "parameters", "0"], 347 | severity: DiagnosticSeverity.Warning, 348 | }, 349 | ], 350 | }, 351 | 352 | { 353 | name: "invalid case: operation level parameter description is missing", 354 | document: { 355 | swagger: "2.0", 356 | paths: { 357 | "/todos": { 358 | get: { 359 | description: "Should be present here too since we look for it.", 360 | parameters: [ 361 | { 362 | name: "limit", 363 | in: "query", 364 | type: "integer", 365 | }, 366 | ], 367 | }, 368 | }, 369 | }, 370 | }, 371 | errors: [ 372 | { 373 | message: '"[0].description" property must be truthy.', 374 | path: ["paths", "/todos", "get", "parameters", "0"], 375 | severity: DiagnosticSeverity.Warning, 376 | }, 377 | ], 378 | }, 379 | 380 | { 381 | name: "does not throw on refs", 382 | document: { 383 | swagger: "2.0", 384 | paths: { 385 | "/todos": { 386 | parameters: [ 387 | { 388 | $ref: "#/parameters/limit", 389 | }, 390 | ], 391 | }, 392 | }, 393 | }, 394 | errors: [], 395 | }, 396 | ]); 397 | -------------------------------------------------------------------------------- /src/ruleset.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alphabetical, 3 | pattern, 4 | schema, 5 | truthy, 6 | length, 7 | } from "@stoplight/spectral-functions"; 8 | import { oas2, oas3 } from "@stoplight/spectral-formats"; 9 | import { DiagnosticSeverity } from "@stoplight/types"; 10 | 11 | export default { 12 | // extends: [ 13 | // 'spectral:oas' 14 | // ], 15 | 16 | aliases: { 17 | // --- OAS Aliases --- 18 | // TODO Remove these once they're available in spectral:oas 19 | PathItem: ["$.paths[*]"], 20 | OperationObject: [ 21 | "#PathItem[get,put,post,delete,options,head,patch,trace]", 22 | ], 23 | 24 | // --- Custom Aliases --- 25 | 26 | DescribableObjects: { 27 | description: "All objects that should be described.", 28 | targets: [ 29 | { 30 | formats: [oas2], 31 | given: [ 32 | "$.info", 33 | "$.tags[*]", 34 | "#OperationObject", 35 | "#OperationObject.responses[*]", 36 | "#PathItem.parameters[?(@ && @.in)]", 37 | "#OperationObject.parameters[?(@ && @.in)]", 38 | "$.definitions[*]", 39 | ], 40 | }, 41 | { 42 | formats: [oas3], 43 | given: [ 44 | "$.info", 45 | "$.tags[*]", 46 | "#OperationObject", 47 | "#OperationObject.responses[*]", 48 | "#PathItem.parameters[?(@ && @.in)]", 49 | "#OperationObject.parameters[?(@ && @.in)]", 50 | "$.components.schemas[*]", 51 | "$.servers[*]", 52 | ], 53 | }, 54 | ], 55 | }, 56 | 57 | MediaTypeObjects: { 58 | description: 59 | "Media Type objects are what OpenAPI calls the object that describes requests and responses, or in OAS2 it was parameters with in=body.", 60 | targets: [ 61 | { 62 | formats: [oas2], 63 | given: [ 64 | '#OperationObject.parameters[?(@ && @.in === "body")]', 65 | "#OperationObject.responses[*]", 66 | ], 67 | }, 68 | { 69 | formats: [oas3], 70 | given: [ 71 | "#OperationObject.requestBody.content[*]", 72 | "#OperationObject.responses[*].content[*]", 73 | ], 74 | }, 75 | ], 76 | }, 77 | }, 78 | 79 | rules: { 80 | "docs-api-host": { 81 | message: "API host must be present and non-empty string.", 82 | description: 83 | "People will want to know where your amazing API is hosted, and this property can show them.", 84 | severity: DiagnosticSeverity.Warning, 85 | formats: [oas2], 86 | given: "$", 87 | then: { 88 | field: "host", 89 | function: truthy, 90 | }, 91 | }, 92 | "docs-api-schemes": { 93 | message: "API schemes should be present and non-empty array.", 94 | description: 95 | "Knowing if the API is available on https-only, http-only, or both, is useful information for API consumers.", 96 | severity: DiagnosticSeverity.Warning, 97 | formats: [oas2], 98 | given: "$", 99 | then: { 100 | field: "schemes", 101 | function: schema, 102 | functionOptions: { 103 | dialect: "draft7", 104 | schema: { 105 | items: { 106 | type: "string", 107 | }, 108 | minItems: 1, 109 | type: "array", 110 | }, 111 | }, 112 | }, 113 | }, 114 | "docs-api-servers": { 115 | message: "API servers should be present and non-empty array.", 116 | description: 117 | "People will want to know where your amazing API is hosted, and this property can show them.", 118 | severity: DiagnosticSeverity.Warning, 119 | formats: [oas3], 120 | given: "$", 121 | then: { 122 | field: "servers", 123 | function: schema, 124 | functionOptions: { 125 | dialect: "draft7", 126 | schema: { 127 | items: { 128 | type: "object", 129 | }, 130 | minItems: 1, 131 | type: "array", 132 | }, 133 | }, 134 | }, 135 | }, 136 | 137 | "docs-api-host-not-example": { 138 | message: "Host URL should not point at example.com.", 139 | description: 140 | "People will want to know where your amazing API is hosted, and it's probably not on example.com.", 141 | severity: DiagnosticSeverity.Warning, 142 | recommended: false, 143 | formats: [oas2], 144 | given: "$", 145 | then: { 146 | field: "host", 147 | function: pattern, 148 | functionOptions: { 149 | notMatch: "example\\.com", 150 | }, 151 | }, 152 | }, 153 | 154 | "docs-api-server-not-example.com": { 155 | message: "Server URL should not point at example.com.", 156 | description: 157 | "People will want to know where your amazing API is hosted, and it's probably not on example.com.", 158 | severity: DiagnosticSeverity.Warning, 159 | recommended: false, 160 | formats: [oas3], 161 | given: "$.servers[*].url", 162 | then: { 163 | function: pattern, 164 | functionOptions: { 165 | notMatch: "example\\.com", 166 | }, 167 | }, 168 | }, 169 | 170 | /** 171 | * @author: Phil Sturgeon 172 | */ 173 | "docs-description": { 174 | message: "{{error}}.", 175 | description: 176 | "Documentation tools use description to provide more context to users of the API who are not as familiar with the concepts as the API designers are.", 177 | severity: DiagnosticSeverity.Warning, 178 | given: "#DescribableObjects", 179 | then: [ 180 | { 181 | field: "description", 182 | function: truthy, 183 | }, 184 | { 185 | field: "description", 186 | function: length, 187 | functionOptions: { 188 | min: 20, 189 | }, 190 | }, 191 | { 192 | field: "description", 193 | function: pattern, 194 | functionOptions: { 195 | match: "/^[A-Z]/", 196 | }, 197 | }, 198 | { 199 | field: "description", 200 | function: pattern, 201 | functionOptions: { 202 | match: "\\.$", 203 | }, 204 | }, 205 | ], 206 | }, 207 | 208 | "docs-info-contact": { 209 | message: "{{error}}.", 210 | description: 211 | "Providing contact means that API consumers can get in touch with you, which can be confusing even if you all work at the same company. This could be a specific developer or a team, depending on the organization.", 212 | severity: DiagnosticSeverity.Warning, 213 | given: "$", 214 | then: { 215 | field: "info.contact", 216 | function: truthy, 217 | }, 218 | }, 219 | 220 | "docs-parameters-anything-useful": { 221 | message: "No example or schema provided for {{property}}", 222 | description: 223 | "In order to make a good sample request doc tools will need an x-example, default, enum, or maybe even a format. The more information you can provide the more useful the sample request will be.", 224 | severity: DiagnosticSeverity.Error, 225 | formats: [oas2], 226 | given: [ 227 | '#PathItem.parameters[?(@ && @.in !== "body")]', 228 | '#OperationObject.parameters[?(@ && @.in !== "body")]' 229 | ], 230 | then: { 231 | function: schema, 232 | functionOptions: { 233 | schema: { 234 | anyOf: [ 235 | { required: ["x-example"] }, 236 | { required: ["example"] }, 237 | { required: ["default"] }, 238 | { required: ["enum"] }, 239 | { required: ["format"] }, 240 | ], 241 | }, 242 | }, 243 | }, 244 | }, 245 | 246 | "docs-parameter-examples-or-schema": { 247 | message: "No example or schema provided for {{property}}.", 248 | description: 249 | "Without providing a well defined schema or example(s) an API consumer will have a hard time knowing how to interact with this API.", 250 | severity: DiagnosticSeverity.Error, 251 | formats: [oas3], 252 | given: "$.paths[*]..parameters[*]", 253 | then: { 254 | function: schema, 255 | functionOptions: { 256 | schema: { 257 | anyOf: [ 258 | { required: ["example"] }, 259 | { required: ["examples"] }, 260 | { required: ["schema"] }, 261 | ], 262 | }, 263 | }, 264 | }, 265 | }, 266 | 267 | "docs-media-type-examples-or-schema": { 268 | message: "No example or schema provided for {{property}}.", 269 | description: 270 | 'To generate useful API reference documentation a sample request and response should be provided, which can either be provided statically as an "example", or tooling can infer a sample from the schema provided (and any examples, defaults, enums, etc. provided for each property). Please provide one or the other. Both would be fantastic.', 271 | severity: DiagnosticSeverity.Error, 272 | formats: [oas3], 273 | given: "#MediaTypeObjects", 274 | then: { 275 | function: schema, 276 | functionOptions: { 277 | schema: { 278 | anyOf: [ 279 | { required: ["example"] }, 280 | { required: ["examples"] }, 281 | { required: ["schema"] }, 282 | ], 283 | }, 284 | }, 285 | }, 286 | }, 287 | 288 | "docs-tags-alphabetical": { 289 | message: "Tags should be defined in alphabetical order.", 290 | description: 291 | "Many documentation tools show tags in the order they are defined, so defining them not in alphabetical order can look funny to API consumers.", 292 | severity: DiagnosticSeverity.Warning, 293 | given: "$", 294 | then: { 295 | field: "tags", 296 | function: alphabetical, 297 | functionOptions: { 298 | keyedBy: "name", 299 | }, 300 | }, 301 | }, 302 | 303 | "docs-operationId-valid-in-url": { 304 | message: "operationId must only contain URL friendly characters.", 305 | description: 306 | "Most documentation tools use the operationId to produce URLs, so the characters used must be safe and legal when used in the URL.", 307 | severity: DiagnosticSeverity.Error, 308 | given: "#OperationObject", 309 | then: { 310 | field: "operationId", 311 | function: pattern, 312 | functionOptions: { 313 | match: "^[A-Za-z0-9-._~:/?#\\[\\]@!\\$&'()*+,;=]*$", 314 | }, 315 | }, 316 | }, 317 | 318 | "docs-tags": { 319 | message: "{{error}}.", 320 | description: 321 | "Tags help group logic into conceptual groups instead of making end-users dig through URLs or lists of operation names.", 322 | severity: DiagnosticSeverity.Warning, 323 | given: "$", 324 | then: { 325 | field: "tags", 326 | function: schema, 327 | functionOptions: { 328 | schema: { 329 | type: "array", 330 | minItems: 1, 331 | }, 332 | }, 333 | }, 334 | }, 335 | 336 | "docs-operation-tags": { 337 | message: "Operation should have non-empty `tags` array.", 338 | description: 339 | "Once tags are defined they should be references in the operation, otherwise they will not be doing anything.", 340 | severity: DiagnosticSeverity.Warning, 341 | given: "#OperationObject", 342 | then: { 343 | field: "tags", 344 | function: schema, 345 | functionOptions: { 346 | schema: { 347 | type: "array", 348 | minItems: 1, 349 | }, 350 | }, 351 | }, 352 | }, 353 | }, 354 | }; 355 | --------------------------------------------------------------------------------