├── .editorconfig ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── README.md ├── __tests__ ├── __helpers__ │ └── helper.ts ├── api-health-format.test.ts ├── api-health.test.ts ├── api-home-get.test.ts ├── api-home.test.ts ├── hosts-https-only-oas2.test.ts ├── hosts-https-only-oas3.test.ts ├── no-file-extensions-in-paths.test.ts ├── no-global-versioning.test.ts ├── no-http-basic.test.ts ├── no-numeric-ids.test.ts ├── no-security-schemes-defined.test.ts ├── no-unknown-error-format.test.ts ├── no-x-headers.test.ts ├── no-x-response-headers.test.ts ├── paths-kebab-case.test.ts ├── request-GET-no-body-oas2.test.ts ├── request-GET-no-body-oas3.test.ts └── request-support-json-oas3.test.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src └── ruleset.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.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: 18 17 | cache: npm 18 | - run: npm ci 19 | - run: npm test 20 | - run: npm run type-check 21 | - run: npm run build 22 | - run: npx semantic-release --branches main 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | -------------------------------------------------------------------------------- /.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: 18 18 | cache: npm 19 | - run: npm ci 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APIs You Won't Hate: API Style Guide 2 | 3 | Make your APIs "better" according to APIs You Won't Hate, with this slick command line tool that'll run on your [OpenAPI](https://spec.openapis.org/oas/v3.1.0). 4 | 5 | ## Installation 6 | 7 | ``` bash 8 | npm install --save -D @apisyouwonthate/style-guide 9 | npm install --save -D @stoplight/spectral-cli 10 | ``` 11 | 12 | ## Usage 13 | 14 | 15 | Create a local ruleset that extends the style guide. 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. 16 | 17 | ``` 18 | cd ~/src/ 19 | 20 | echo 'extends: ["@apisyouwonthate/style-guide"]' > .spectral.yaml 21 | ``` 22 | 23 | _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:_ 24 | 25 | ``` 26 | echo 'extends: ["https://unpkg.com/@apisyouwonthate/style-guide@1.4.0/dist/ruleset.js"]' > .spectral.yaml 27 | ``` 28 | 29 | _**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)._ 30 | 31 | 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. 32 | 33 | ``` 34 | spectral lint api/openapi.yaml 35 | ``` 36 | 37 | You should see some output like this, letting you know there are a few more standards you could be using (shout-out to [Standards.REST](https://standards.rest/)): 38 | 39 | ``` 40 | /Users/phil/src/protect-earth-api/api/openapi.yaml 41 | 18:7 warning api-health Creating a `/health` endpoint is a simple solution for pull-based monitoring and manually checking the status of an API. paths 42 | 18:7 warning api-home Stop forcing all API consumers to visit documentation for basic interactions when the API could do that itself. paths 43 | 36:30 warning no-unknown-error-format Every error response SHOULD support either RFC 7807 (https://tools.ietf.org/html/rfc7807) or the JSON:API Error format. paths./v1/orders.post.responses[401].content.application/json 44 | 96:30 warning no-unknown-error-format Every error response SHOULD support either RFC 7807 (https://tools.ietf.org/html/rfc7807) or the JSON:API Error format. paths./v1/orders/{order}.get.responses[401].content.application/json 45 | 112:30 warning no-unknown-error-format Every error response SHOULD support either RFC 7807 (https://tools.ietf.org/html/rfc7807) or the JSON:API Error format. paths./v1/orders/{order}.get.responses[404].content.application/json 46 | ``` 47 | 48 | Now you have some things to work on for your API. Thankfully these are only warnings, which are 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)). 49 | 50 | ## Backstory 51 | 52 | You could write your API Style Guide as a giant manifesto and hope people see it, or you could [automate your API style guide](https://apisyouwonthate.com/blog/automated-style-guides-for-rest-graphql-grpc/) using a tool like Spectral so that your API Style Guide is enforced at the pull request level. This is an integral part of any successful API Governance program, otherwise you're all just wasting time covering the basics far too late in the game. 53 | 54 | Spectral runs on top of OpenAPI and AsyncAPI, powering linting in editors, as a CLI tool, in continuous integration, etc., and comes with its [own set of baked 55 | in OpenAPI v2/v3 rules](https://meta.stoplight.io/docs/spectral/docs/reference/openapi-rules.md). Making rules about how to write OpenAPI can help beginners write better OpenAPI, but its real power is using rules to make the actual APIs better and more consistent, before there is any programming involved. 56 | 57 | This NPM package brings together all sorts of advice found in the books and blog posts from [APIs You Won't Hate](https://apisyouwonthate.com) books. If you apply this to APIs in production, this is basically [Phil Sturgeon](https://philsturgeon.com/) judging your API for free instead of [doing paid consulting](http://phil.tech/consulting). But if you can get this API Style Guide involved in the API Design-First workflow, you're getting free advice on how to design a better API before you waste any time coding, which then means fewer backwards-compatibility breaks as you fix things. 58 | 59 | There are [a bunch of other rulesets](https://github.com/stoplightio/spectral-rulesets) you can check out if you feel like making your own API Style Guides, or feel like contributing some new rules here via a pull request. 60 | 61 | ## 🎉 Thanks 62 | 63 | - [Andrzej](https://github.com/jerzyn) - Great rules contributed to the Adidas style guide. 64 | - [Nauman Ali](https://github.com/naumanali-stoplight) - Creating the `no-global-versioning` rule as part of his excellent [style guide blog post series](https://blog.stoplight.io/consistent-api-urls-with-openapi-and-style-guides). 65 | 66 | ## 📜 License 67 | 68 | This repository is licensed under the MIT license. 69 | 70 | ## 🌲 Sponsor 71 | 72 | If you'd like to say thanks for this style guide, throw some money at [Protect Earth](https://protect.earth/donate), a charity that plants trees all over the United Kingdom, which has [secured a 64 acre ancient woodland to restore](https://www.protect.earth/blog/high-wood). Phil spends all his time on this, both planting trees _and_ writing APIs believe it or not! 73 | -------------------------------------------------------------------------------- /__tests__/__helpers__/helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IRuleResult, 3 | Spectral, 4 | Document, 5 | Ruleset, 6 | RulesetDefinition, 7 | } from "@stoplight/spectral-core"; 8 | import { httpAndFileResolver } from "@stoplight/spectral-ref-resolver"; 9 | import apisYouWontHateRuleset from "../../src/ruleset"; 10 | 11 | export type RuleName = keyof Ruleset["rules"]; 12 | 13 | type Scenario = ReadonlyArray< 14 | Readonly<{ 15 | name: string; 16 | document: Record | Document; 17 | errors: ReadonlyArray>; 18 | mocks?: Record>; 19 | }> 20 | >; 21 | 22 | export default (ruleName: RuleName, tests: Scenario): void => { 23 | describe(`Rule ${ruleName}`, () => { 24 | const concurrent = tests.every( 25 | (test) => test.mocks === void 0 || Object.keys(test.mocks).length === 0 26 | ); 27 | for (const testCase of tests) { 28 | (concurrent ? it.concurrent : it)(testCase.name, async () => { 29 | const s = createWithRules([ruleName]); 30 | const doc = 31 | testCase.document instanceof Document 32 | ? testCase.document 33 | : JSON.stringify(testCase.document); 34 | const errors = await s.run(doc); 35 | expect(errors.filter(({ code }) => code === ruleName)).toEqual( 36 | testCase.errors.map( 37 | (error) => expect.objectContaining(error) as unknown 38 | ) 39 | ); 40 | }); 41 | } 42 | }); 43 | }; 44 | 45 | export function createWithRules(rules: (keyof Ruleset["rules"])[]): Spectral { 46 | const s = new Spectral({ resolver: httpAndFileResolver }); 47 | 48 | s.setRuleset({ 49 | extends: [[apisYouWontHateRuleset as RulesetDefinition, "off"]], 50 | rules: rules.reduce((obj: Record, name) => { 51 | obj[name] = true; 52 | return obj; 53 | }, {}), 54 | }); 55 | 56 | return s; 57 | } 58 | -------------------------------------------------------------------------------- /__tests__/api-health-format.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | const template = (contentType: string) => { 5 | return { 6 | openapi: "3.1.0", 7 | info: { version: "1.0", contact: {} }, 8 | paths: { 9 | "/health": { 10 | get: { 11 | summary: "Your health endpoint", 12 | responses: { 13 | "200": { 14 | description: "Error", 15 | content: { 16 | [contentType]: {}, 17 | }, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }; 24 | }; 25 | 26 | testRule("api-health-format", [ 27 | { 28 | name: "valid case", 29 | document: template("application/health+json"), 30 | errors: [], 31 | }, 32 | 33 | { 34 | name: "invalid case if plain json", 35 | document: template("application/json"), 36 | errors: [ 37 | { 38 | message: 39 | "Health path (`/health`) SHOULD support Health Check Response Format", 40 | path: [ 41 | "paths", 42 | "/health", 43 | "get", 44 | "responses", 45 | "200", 46 | "content", 47 | "application/json", 48 | ], 49 | severity: DiagnosticSeverity.Warning, 50 | }, 51 | ], 52 | }, 53 | 54 | { 55 | name: "invalid case if any other mime type", 56 | document: template("text/png"), 57 | errors: [ 58 | { 59 | message: 60 | "Health path (`/health`) SHOULD support Health Check Response Format", 61 | path: [ 62 | "paths", 63 | "/health", 64 | "get", 65 | "responses", 66 | "200", 67 | "content", 68 | "text/png", 69 | ], 70 | severity: DiagnosticSeverity.Warning, 71 | }, 72 | ], 73 | }, 74 | ]); 75 | -------------------------------------------------------------------------------- /__tests__/api-health.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("api-health", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0", contact: {} }, 10 | paths: { "/health": {} }, 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "invalid case", 17 | document: { 18 | openapi: "3.1.0", 19 | info: { version: "1.0", contact: {} }, 20 | paths: {}, 21 | }, 22 | errors: [ 23 | { 24 | message: "APIs MUST have a health path (`/health`) defined.", 25 | path: ["paths"], 26 | severity: DiagnosticSeverity.Warning, 27 | }, 28 | ], 29 | }, 30 | ]); 31 | -------------------------------------------------------------------------------- /__tests__/api-home-get.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("api-home-get", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: {}, 13 | }, 14 | }, 15 | }, 16 | errors: [], 17 | }, 18 | 19 | { 20 | name: "invalid case", 21 | document: { 22 | openapi: "3.1.0", 23 | info: { version: "1.0" }, 24 | paths: { 25 | "/": {}, 26 | }, 27 | }, 28 | errors: [ 29 | { 30 | message: "APIs root path (`/`) MUST have a GET operation.", 31 | path: ["paths", "/"], 32 | severity: DiagnosticSeverity.Warning, 33 | }, 34 | ], 35 | }, 36 | ]); 37 | -------------------------------------------------------------------------------- /__tests__/api-home.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("api-home", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "invalid case", 17 | document: { 18 | openapi: "3.1.0", 19 | info: { version: "1.0" }, 20 | paths: {}, 21 | }, 22 | errors: [ 23 | { 24 | message: "APIs MUST have a root path (`/`) defined.", 25 | path: ["paths"], 26 | severity: DiagnosticSeverity.Warning, 27 | }, 28 | ], 29 | }, 30 | ]); 31 | -------------------------------------------------------------------------------- /__tests__/hosts-https-only-oas2.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("hosts-https-only-oas2", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | host: "example.com", 12 | schemes: ["https"], 13 | }, 14 | errors: [], 15 | }, 16 | 17 | { 18 | name: "an invalid server.url using http", 19 | document: { 20 | swagger: "2.0", 21 | info: { version: "1.0" }, 22 | paths: { "/": {} }, 23 | host: "example.com", 24 | schemes: ["http"], 25 | }, 26 | errors: [ 27 | { 28 | message: "Schemes MUST be https and no other protocol is allowed.", 29 | path: ["schemes", "0"], 30 | severity: DiagnosticSeverity.Error, 31 | }, 32 | ], 33 | }, 34 | 35 | { 36 | name: "an invalid server.url using http and https", 37 | document: { 38 | swagger: "2.0", 39 | info: { version: "1.0" }, 40 | paths: { "/": {} }, 41 | host: "example.com", 42 | schemes: ["https", "http"], 43 | }, 44 | errors: [ 45 | { 46 | message: "Schemes MUST be https and no other protocol is allowed.", 47 | path: ["schemes", "1"], 48 | severity: DiagnosticSeverity.Error, 49 | }, 50 | ], 51 | }, 52 | 53 | { 54 | name: "an invalid server using ftp", 55 | document: { 56 | swagger: "2.0", 57 | info: { version: "1.0" }, 58 | paths: { "/": {} }, 59 | host: "example.com", 60 | schemes: ["ftp"], 61 | }, 62 | errors: [ 63 | { 64 | message: "Schemes MUST be https and no other protocol is allowed.", 65 | path: ["schemes", "0"], 66 | severity: DiagnosticSeverity.Error, 67 | }, 68 | ], 69 | }, 70 | ]); 71 | -------------------------------------------------------------------------------- /__tests__/hosts-https-only-oas3.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("hosts-https-only-oas3", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | servers: [{ url: "https://api.example.com/" }], 12 | }, 13 | errors: [], 14 | }, 15 | 16 | { 17 | name: "an invalid server.url using http", 18 | document: { 19 | openapi: "3.1.0", 20 | info: { version: "1.0" }, 21 | paths: { "/": {} }, 22 | servers: [{ url: "http://api.example.com/" }], 23 | }, 24 | errors: [ 25 | { 26 | message: "Servers MUST be https and no other protocol is allowed.", 27 | path: ["servers", "0", "url"], 28 | severity: DiagnosticSeverity.Error, 29 | }, 30 | ], 31 | }, 32 | 33 | { 34 | name: "an invalid server using ftp", 35 | document: { 36 | openapi: "3.1.0", 37 | info: { version: "1.0" }, 38 | paths: { "/": {} }, 39 | servers: [{ url: "ftp://api.example.com/" }], 40 | }, 41 | errors: [ 42 | { 43 | message: "Servers MUST be https and no other protocol is allowed.", 44 | path: ["servers", "0", "url"], 45 | severity: DiagnosticSeverity.Error, 46 | }, 47 | ], 48 | }, 49 | ]); 50 | -------------------------------------------------------------------------------- /__tests__/no-file-extensions-in-paths.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("no-file-extensions-in-paths", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | paths: { resources: {} }, 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "an API definition that is returning a json file", 17 | document: { 18 | swagger: "2.0", 19 | info: { version: "1.0" }, 20 | paths: { "resources.json": {} }, 21 | }, 22 | errors: [ 23 | { 24 | message: 25 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 26 | path: ["paths", "resources.json"], 27 | severity: DiagnosticSeverity.Error, 28 | }, 29 | ], 30 | }, 31 | { 32 | name: "an API definition that is returning a xml file", 33 | document: { 34 | swagger: "2.0", 35 | info: { version: "1.0" }, 36 | paths: { "resources.xml": {} }, 37 | }, 38 | errors: [ 39 | { 40 | message: 41 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 42 | path: ["paths", "resources.xml"], 43 | severity: DiagnosticSeverity.Error, 44 | }, 45 | ], 46 | }, 47 | { 48 | name: "an API definition that is returning a html file", 49 | document: { 50 | swagger: "2.0", 51 | info: { version: "1.0" }, 52 | paths: { "resources.html": {} }, 53 | }, 54 | errors: [ 55 | { 56 | message: 57 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 58 | path: ["paths", "resources.html"], 59 | severity: DiagnosticSeverity.Error, 60 | }, 61 | ], 62 | }, 63 | { 64 | name: "an API definition that is returning a txt file", 65 | document: { 66 | swagger: "2.0", 67 | info: { version: "1.0" }, 68 | paths: { "resources.txt": {} }, 69 | }, 70 | errors: [ 71 | { 72 | message: 73 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 74 | path: ["paths", "resources.txt"], 75 | severity: DiagnosticSeverity.Error, 76 | }, 77 | ], 78 | }, 79 | 80 | { 81 | name: "valid case", 82 | document: { 83 | openapi: "3.1.0", 84 | info: { version: "1.0" }, 85 | paths: { resources: {} }, 86 | }, 87 | errors: [], 88 | }, 89 | 90 | { 91 | name: "an API definition that is returning a json file", 92 | document: { 93 | openapi: "3.1.0", 94 | info: { version: "1.0" }, 95 | paths: { "resources.json": {} }, 96 | }, 97 | errors: [ 98 | { 99 | message: 100 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 101 | path: ["paths", "resources.json"], 102 | severity: DiagnosticSeverity.Error, 103 | }, 104 | ], 105 | }, 106 | { 107 | name: "an API definition that is returning a xml file", 108 | document: { 109 | openapi: "3.1.0", 110 | info: { version: "1.0" }, 111 | paths: { "resources.xml": {} }, 112 | }, 113 | errors: [ 114 | { 115 | message: 116 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 117 | path: ["paths", "resources.xml"], 118 | severity: DiagnosticSeverity.Error, 119 | }, 120 | ], 121 | }, 122 | { 123 | name: "an API definition that is returning a html file", 124 | document: { 125 | openapi: "3.1.0", 126 | info: { version: "1.0" }, 127 | paths: { "resources.html": {} }, 128 | }, 129 | errors: [ 130 | { 131 | message: 132 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 133 | path: ["paths", "resources.html"], 134 | severity: DiagnosticSeverity.Error, 135 | }, 136 | ], 137 | }, 138 | { 139 | name: "an API definition that is returning a txt file", 140 | document: { 141 | openapi: "3.1.0", 142 | info: { version: "1.0" }, 143 | paths: { "resources.txt": {} }, 144 | }, 145 | errors: [ 146 | { 147 | message: 148 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 149 | path: ["paths", "resources.txt"], 150 | severity: DiagnosticSeverity.Error, 151 | }, 152 | ], 153 | }, 154 | ]); 155 | -------------------------------------------------------------------------------- /__tests__/no-global-versioning.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("no-global-versioning", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { "/": {} }, 11 | servers: [{ url: "https://api.example.com/" }], 12 | }, 13 | errors: [], 14 | }, 15 | 16 | { 17 | name: "an API that is getting ready to give its consumers a really bad time", 18 | document: { 19 | openapi: "3.1.0", 20 | info: { version: "1.0" }, 21 | paths: { "/": {} }, 22 | servers: [{ url: "https://api.example.com/v1" }], 23 | }, 24 | errors: [ 25 | { 26 | message: "Server URL should not contain global versions.", 27 | path: ["servers", "0", "url"], 28 | severity: DiagnosticSeverity.Warning, 29 | }, 30 | ], 31 | }, 32 | 33 | { 34 | name: "an API that got massively out of control as usual", 35 | document: { 36 | openapi: "3.1.0", 37 | info: { version: "1.0" }, 38 | paths: { "/": {} }, 39 | servers: [{ url: "https://api.example.com/v13" }], 40 | }, 41 | errors: [ 42 | { 43 | message: "Server URL should not contain global versions.", 44 | path: ["servers", "0", "url"], 45 | severity: DiagnosticSeverity.Warning, 46 | }, 47 | ], 48 | }, 49 | ]); 50 | -------------------------------------------------------------------------------- /__tests__/no-http-basic.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("no-http-basic", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | securitySchemes: { 12 | "anything-else": { 13 | type: "http", 14 | scheme: "bearer", 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "invalid case", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: { 28 | securitySchemes: { 29 | "please-hack-me": { 30 | type: "http", 31 | scheme: "basic", 32 | }, 33 | }, 34 | }, 35 | }, 36 | errors: [ 37 | { 38 | message: "Please consider a more secure alternative to HTTP Basic.", 39 | path: ["components", "securitySchemes", "please-hack-me", "scheme"], 40 | severity: DiagnosticSeverity.Error, 41 | }, 42 | ], 43 | }, 44 | ]); 45 | -------------------------------------------------------------------------------- /__tests__/no-numeric-ids.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("no-numeric-ids", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/foo/{id}": { 12 | get: { 13 | description: "get", 14 | parameters: [ 15 | { 16 | name: "id", 17 | in: "path", 18 | required: true, 19 | schema: { 20 | type: "string", 21 | format: "uuid", 22 | }, 23 | }, 24 | ], 25 | }, 26 | }, 27 | }, 28 | }, 29 | errors: [], 30 | }, 31 | 32 | { 33 | name: "invalid if its an integer", 34 | document: { 35 | openapi: "3.1.0", 36 | info: { version: "1.0" }, 37 | paths: { 38 | "/foo/{id}": { 39 | get: { 40 | description: "get", 41 | parameters: [ 42 | { 43 | name: "id", 44 | in: "path", 45 | required: true, 46 | schema: { 47 | type: "integer", 48 | format: "int32", 49 | }, 50 | }, 51 | ], 52 | }, 53 | }, 54 | }, 55 | }, 56 | errors: [ 57 | { 58 | message: 59 | "Please avoid exposing IDs as an integer, UUIDs are preferred.", 60 | path: ["paths", "/foo/{id}", "get", "parameters", "0", "schema"], 61 | severity: DiagnosticSeverity.Error, 62 | }, 63 | ], 64 | }, 65 | ]); 66 | -------------------------------------------------------------------------------- /__tests__/no-security-schemes-defined.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("no-security-schemes-defined", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | components: { 11 | securitySchemes: { 12 | oAuth2: { 13 | type: "oauth2", 14 | flow: {}, 15 | }, 16 | }, 17 | }, 18 | }, 19 | errors: [], 20 | }, 21 | 22 | { 23 | name: "invalid case", 24 | document: { 25 | openapi: "3.1.0", 26 | info: { version: "1.0" }, 27 | components: {}, 28 | }, 29 | errors: [ 30 | { 31 | message: "All APIs MUST have a security scheme defined.", 32 | path: ["components"], 33 | severity: DiagnosticSeverity.Error, 34 | }, 35 | ], 36 | }, 37 | ]); 38 | -------------------------------------------------------------------------------- /__tests__/no-unknown-error-format.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | const template = (contentType: string) => { 5 | return { 6 | openapi: "3.1.0", 7 | info: { version: "1.0", contact: {} }, 8 | paths: { 9 | "/unknown-error": { 10 | get: { 11 | summary: "Your GET endpoint", 12 | responses: { 13 | "400": { 14 | description: "Error", 15 | content: { 16 | [contentType]: {}, 17 | }, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }; 24 | }; 25 | 26 | testRule("no-unknown-error-format", [ 27 | { 28 | name: "valid error format (JSON:API)", 29 | document: template("application/vnd.api+json"), 30 | errors: [], 31 | }, 32 | 33 | { 34 | name: "valid error format (RFC 7807, XML)", 35 | document: template("application/problem+xml"), 36 | errors: [], 37 | }, 38 | 39 | { 40 | name: "valid error format (RFC 7807, JSON)", 41 | document: template("application/problem+json"), 42 | errors: [], 43 | }, 44 | 45 | { 46 | name: "invalid error format (plain JSON)", 47 | document: template("application/json"), 48 | errors: [ 49 | { 50 | message: "Error response should use a standard error format.", 51 | path: [ 52 | "paths", 53 | "/unknown-error", 54 | "get", 55 | "responses", 56 | "400", 57 | "content", 58 | "application/json", 59 | ], 60 | severity: DiagnosticSeverity.Warning, 61 | }, 62 | ], 63 | }, 64 | ]); 65 | -------------------------------------------------------------------------------- /__tests__/no-x-headers.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("no-x-headers", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/foo": { 12 | get: { 13 | parameters: [ 14 | { 15 | name: "RateLimit-Limit", 16 | in: "header", 17 | description: 18 | "standards are cool: https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#name-ratelimit-limit", 19 | required: true, 20 | schema: { 21 | type: "string", 22 | examples: ["100, 100;w=10"], 23 | }, 24 | }, 25 | ], 26 | responses: { 27 | "200": { 28 | description: "ok", 29 | headers: { 30 | "X-Doesnt-Matter": { 31 | description: 32 | "Because OAS has two totally different ways of doing headers for request or response, this will be picked up by another rule.", 33 | schema: { 34 | type: "string", 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | errors: [], 45 | }, 46 | 47 | { 48 | name: "invalid case", 49 | document: { 50 | openapi: "3.1.0", 51 | info: { version: "1.0" }, 52 | paths: { 53 | "/foo": { 54 | get: { 55 | description: "get", 56 | parameters: [ 57 | { 58 | name: "X-Rate-Limit", 59 | in: "header", 60 | description: "calls per hour allowed by the user", 61 | required: true, 62 | schema: { 63 | type: "integer", 64 | format: "int32", 65 | }, 66 | }, 67 | ], 68 | responses: { 69 | "200": { 70 | description: "ok", 71 | headers: { 72 | "X-Doesnt-Matter": { 73 | description: 74 | "Because OAS has two totally different ways of doing headers for request or response, this will be picked up by another rule.", 75 | schema: { 76 | type: "string", 77 | }, 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | errors: [ 87 | { 88 | message: 'Header `name` should not start with "X-".', 89 | path: ["paths", "/foo", "get", "parameters", "0", "name"], 90 | severity: DiagnosticSeverity.Error, 91 | }, 92 | ], 93 | }, 94 | ]); 95 | -------------------------------------------------------------------------------- /__tests__/no-x-response-headers.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("no-x-response-headers", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0", contact: {} }, 10 | paths: { 11 | "/foo": { 12 | get: { 13 | parameters: [ 14 | { 15 | name: "X-Doesnt-Matter", 16 | in: "header", 17 | description: 18 | "Because OAS has two totally different ways of doing headers for request or response, this will be picked up by another rule.", 19 | required: true, 20 | schema: { 21 | type: "string", 22 | }, 23 | }, 24 | ], 25 | responses: { 26 | "200": { 27 | description: "ok", 28 | headers: { 29 | "Retry-After": { 30 | description: 31 | "How long the user agent should wait before making a follow-up request.", 32 | schema: { 33 | oneOf: [ 34 | { 35 | type: "string", 36 | format: "date-time", 37 | examples: ["Wed, 21 Oct 2015 07:28:00 GMT"], 38 | }, 39 | { 40 | type: "integer", 41 | examples: [60], 42 | }, 43 | ], 44 | }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | errors: [], 54 | }, 55 | 56 | { 57 | name: "invalid case", 58 | document: { 59 | openapi: "3.1.0", 60 | info: { version: "1.0", contact: {} }, 61 | paths: { 62 | "/foo": { 63 | get: { 64 | parameters: [ 65 | { 66 | name: "X-Doesnt-Matter", 67 | in: "header", 68 | description: 69 | "Because OAS has two totally different ways of doing headers for request or response, this will be picked up by another rule.", 70 | required: true, 71 | schema: { 72 | type: "string", 73 | }, 74 | }, 75 | ], 76 | responses: { 77 | "200": { 78 | description: "ok", 79 | headers: { 80 | "X-Expires-After": { 81 | description: 82 | "Some custom made header that could will confuse everyone and probably has a standard HTTP header already.", 83 | schema: { 84 | type: "string", 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | errors: [ 95 | { 96 | message: 'Header `X-Expires-After` should not start with "X-".', 97 | path: [ 98 | "paths", 99 | "/foo", 100 | "get", 101 | "responses", 102 | "200", 103 | "headers", 104 | "X-Expires-After", 105 | ], 106 | severity: DiagnosticSeverity.Error, 107 | }, 108 | ], 109 | }, 110 | ]); 111 | -------------------------------------------------------------------------------- /__tests__/paths-kebab-case.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("paths-kebab-case", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { "/this-is-kebab-case": {} }, 11 | }, 12 | errors: [], 13 | }, 14 | 15 | { 16 | name: "invalid case", 17 | document: { 18 | openapi: "3.1.0", 19 | info: { version: "1.0" }, 20 | paths: { "/this_is_snake_case": {} }, 21 | }, 22 | errors: [ 23 | { 24 | message: 25 | "/this_is_snake_case should be kebab-case (lower case and separated with hyphens).", 26 | path: ["paths", "/this_is_snake_case"], 27 | severity: DiagnosticSeverity.Warning, 28 | }, 29 | ], 30 | }, 31 | ]); 32 | -------------------------------------------------------------------------------- /__tests__/request-GET-no-body-oas2.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("request-GET-no-body-oas2", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | swagger: "2.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: {}, 13 | }, 14 | }, 15 | }, 16 | errors: [], 17 | }, 18 | 19 | { 20 | name: "invalid case", 21 | document: { 22 | swagger: "2.0.0", 23 | info: { version: "1.0" }, 24 | paths: { 25 | "/": { 26 | get: { 27 | summary: "Get is a question but this looks like an answer", 28 | consumes: ["application/json"], 29 | parameters: [ 30 | { 31 | in: "body", 32 | name: "user", 33 | schema: { 34 | type: "object", 35 | }, 36 | }, 37 | ], 38 | }, 39 | }, 40 | }, 41 | }, 42 | errors: [ 43 | { 44 | message: "A `GET` request MUST NOT accept a request body.", 45 | path: ["paths", "/", "get", "parameters", "0", "in"], 46 | severity: DiagnosticSeverity.Error, 47 | }, 48 | ], 49 | }, 50 | ]); 51 | -------------------------------------------------------------------------------- /__tests__/request-GET-no-body-oas3.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("request-GET-no-body-oas3", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: {}, 13 | }, 14 | }, 15 | }, 16 | errors: [], 17 | }, 18 | 19 | { 20 | name: "invalid case", 21 | document: { 22 | openapi: "3.1.0", 23 | info: { version: "1.0" }, 24 | paths: { 25 | "/": { 26 | get: { 27 | requestBody: { 28 | description: "Get is a question but this looks like an answer", 29 | content: { 30 | "application/json": { 31 | schema: { 32 | type: "object", 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | errors: [ 42 | { 43 | message: "A `GET` request MUST NOT accept a request body.", 44 | path: ["paths", "/", "get", "requestBody"], 45 | severity: DiagnosticSeverity.Error, 46 | }, 47 | ], 48 | }, 49 | ]); 50 | -------------------------------------------------------------------------------- /__tests__/request-support-json-oas3.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSeverity } from "@stoplight/types"; 2 | import testRule from "./__helpers__/helper"; 3 | 4 | testRule("request-support-json-oas3", [ 5 | { 6 | name: "valid case", 7 | document: { 8 | openapi: "3.1.0", 9 | info: { version: "1.0" }, 10 | paths: { 11 | "/": { 12 | get: { 13 | requestBody: { 14 | description: "JSON and CSV? How courteous of you!", 15 | content: { 16 | "application/json": { 17 | schema: { 18 | type: "object", 19 | }, 20 | }, 21 | "text/csv": { 22 | schema: { 23 | type: "string", 24 | }, 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | errors: [], 33 | }, 34 | 35 | { 36 | name: "invalid case", 37 | document: { 38 | openapi: "3.1.0", 39 | info: { version: "1.0" }, 40 | paths: { 41 | "/": { 42 | get: { 43 | requestBody: { 44 | description: 45 | "only csv is going to annoy folks who want to use JSON so this is invalid", 46 | content: { 47 | "text/csv": { 48 | schema: { 49 | type: "string", 50 | }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | errors: [ 59 | { 60 | message: 61 | "Every request SHOULD support at least one `application/json` content type.", 62 | path: ["paths", "/", "get", "requestBody", "content"], 63 | severity: DiagnosticSeverity.Warning, 64 | }, 65 | ], 66 | }, 67 | ]); 68 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apisyouwonthate/style-guide", 3 | "version": "0.0.0", 4 | "description": "Make your HTTP APIs better, faster, stronger, whether they are still being designed (API Design-First) or your organization has flopped various mismatched APIs into production and now you're thinking some consistency would be nice. Using Spectral and OpenAPI.", 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/openapi-contrib/style-guides.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/openapi-contrib/style-guides/issues" 37 | }, 38 | "homepage": "https://github.com/openapi-contrib/style-guides#readme", 39 | "dependencies": { 40 | "@stoplight/spectral-formats": "^1.2.0", 41 | "@stoplight/spectral-functions": "^1.6.1" 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 | }, 51 | "tsup": { 52 | "entry": ["src/ruleset.ts"], 53 | "clean": true, 54 | "dts": true, 55 | "format": ["cjs", "esm"], 56 | "sourcemap": true, 57 | "noExternal": ["@stoplight/types"], 58 | "footer": { 59 | "js": "module.exports = module.exports.default;" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ruleset.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * These rules dictate actual content of the API: headers, URL conventions, and general 3 | * Good Ideas™ for HTTP APIs, mainly from the books/blogs on apisyouwonthate.com 4 | */ 5 | 6 | import { 7 | enumeration, 8 | truthy, 9 | undefined as undefinedFunc, 10 | pattern, 11 | schema, 12 | } from "@stoplight/spectral-functions"; 13 | import { oas2, oas3 } from "@stoplight/spectral-formats"; 14 | import { DiagnosticSeverity } from "@stoplight/types"; 15 | 16 | export default { 17 | rules: { 18 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 19 | "api-home": { 20 | message: "APIs MUST have a root path (`/`) defined.", 21 | description: 22 | "Good documentation is always welcome, but API consumers should be able to get a pretty long way through interaction with the API alone. They should at least know they're looking at the right place instead of getting a 404 or random 500 error as is common in some APIs.\n\nThere are various efforts around to standardize the home document, but the best is probably this one: https://webconcepts.info/specs/IETF/I-D/nottingham-json-home", 23 | given: "$.paths", 24 | then: { 25 | field: "/", 26 | function: truthy, 27 | }, 28 | severity: DiagnosticSeverity.Warning, 29 | }, 30 | 31 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 32 | "api-home-get": { 33 | message: "APIs root path (`/`) MUST have a GET operation.", 34 | description: 35 | "Good documentation is always welcome, but API consumers should be able to get a pretty long way through interaction with the API alone. They should at least know they're looking at the right place instead of getting a 404 or random 500 error as is common in some APIs.\n\nThere are various efforts around to standardize the home document, but the best is probably this one: https://webconcepts.info/specs/IETF/I-D/nottingham-json-home", 36 | given: "$.paths[/]", 37 | then: { 38 | field: "get", 39 | function: truthy, 40 | }, 41 | severity: DiagnosticSeverity.Warning, 42 | }, 43 | 44 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 45 | "api-health": { 46 | message: "APIs MUST have a health path (`/health`) defined.", 47 | description: 48 | "Creating a `/health` endpoint is a simple solution for pull-based monitoring and manually checking the status of an API. To learn more about health check endpoints see https://apisyouwonthate.com/blog/health-checks-with-kubernetes.", 49 | given: "$.paths", 50 | then: { 51 | field: "/health", 52 | function: truthy, 53 | }, 54 | severity: DiagnosticSeverity.Warning, 55 | }, 56 | 57 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 58 | "api-health-format": { 59 | message: 60 | "Health path (`/health`) SHOULD support Health Check Response Format", 61 | description: 62 | "Use existing standards (and draft standards) wherever possible, like the draft standard for health checks: https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check. To learn more about health check endpoints see https://apisyouwonthate.com/blog/health-checks-with-kubernetes.", 63 | formats: [oas3], 64 | given: "$.paths[/health]..responses[*].content.*~", 65 | then: { 66 | function: enumeration, 67 | functionOptions: { 68 | values: ["application/health+json"], 69 | }, 70 | }, 71 | severity: DiagnosticSeverity.Warning, 72 | }, 73 | 74 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 75 | "paths-kebab-case": { 76 | message: 77 | "{{property}} should be kebab-case (lower case and separated with hyphens).", 78 | description: 79 | "Naming conventions don't particular matter, and picking something consistent is the most important thing. So let's pick kebab-case for paths, because... well it's nice and why not.", 80 | given: "$.paths[*]~", 81 | then: { 82 | function: pattern, 83 | functionOptions: { 84 | match: "^(/|[a-z0-9-.]+|{[a-zA-Z0-9_]+})+$", 85 | }, 86 | }, 87 | severity: DiagnosticSeverity.Warning, 88 | }, 89 | 90 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 91 | "no-numeric-ids": { 92 | message: "Please avoid exposing IDs as an integer, UUIDs are preferred.", 93 | description: 94 | "Using auto-incrementing IDs in your API means people can download your entire database with a for() loop, whether its public or protected. Using UUID, ULID, snowflake, etc. can help to avoid this, or at least slow them down, depending on how you have your API set up.\n\nThis is recommended by the OWASP API Security Project. https://github.com/OWASP/API-Security/blob/master/2019/en/src/0xa1-broken-object-level-authorization.md.\n\nLearn more about this over here. https://phil.tech/2015/auto-incrementing-to-destruction/", 95 | given: 96 | '$.paths..parameters[*][?(@property === "name" && (@ === "id" || @.match(/(_id|Id|-id)$/)))]^.schema', 97 | then: { 98 | function: schema, 99 | functionOptions: { 100 | schema: { 101 | type: "object", 102 | not: { 103 | properties: { 104 | type: { 105 | const: "integer", 106 | }, 107 | }, 108 | }, 109 | properties: { 110 | format: { 111 | const: "uuid", 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | severity: DiagnosticSeverity.Error, 118 | }, 119 | 120 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 121 | "no-http-basic": { 122 | message: "Please consider a more secure alternative to HTTP Basic.", 123 | description: 124 | "HTTP Basic is an inherently insecure way to pass credentials to the API. They're placed in the URL in base64 which can be decrypted easily. Even if you're using a token, there are far better ways to handle passing tokens to an API which are less likely to leak.\n\nSee OWASP advice. https://github.com/OWASP/API-Security/blob/master/2019/en/src/0xa2-broken-user-authentication.md", 125 | given: "$.components.securitySchemes[*]", 126 | then: { 127 | field: "scheme", 128 | function: pattern, 129 | functionOptions: { 130 | notMatch: "basic", 131 | }, 132 | }, 133 | severity: DiagnosticSeverity.Error, 134 | }, 135 | 136 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 137 | "no-x-headers": { 138 | message: 'Header `{{property}}` should not start with "X-".', 139 | description: 140 | "Headers starting with X- is an awkward convention which is entirely unnecessary. There is probably a standard for what you're trying to do, so it would be better to use that. If there is not a standard already perhaps there's a draft that you could help mature through use and feedback.\n\nSee what you can find on https://standards.rest.\n\nMore about X- headers here: https://tools.ietf.org/html/rfc6648.", 141 | given: "$..parameters[?(@.in === 'header')].name", 142 | then: { 143 | function: pattern, 144 | functionOptions: { 145 | notMatch: "^(x|X)-", 146 | }, 147 | }, 148 | severity: DiagnosticSeverity.Error, 149 | }, 150 | 151 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 152 | "no-x-response-headers": { 153 | message: 'Header `{{property}}` should not start with "X-".', 154 | description: 155 | "Headers starting with X- is an awkward convention which is entirely unnecessary. There is probably a standard for what you're trying to do, so it would be better to use that. If there is not a standard already perhaps there's a draft that you could help mature through use and feedback.\n\nSee what you can find on https://standards.rest.\n\nMore about X- headers here: https://tools.ietf.org/html/rfc6648.", 156 | given: "$..headers.*~", 157 | then: { 158 | function: pattern, 159 | functionOptions: { 160 | notMatch: "^(x|X)-", 161 | }, 162 | }, 163 | severity: DiagnosticSeverity.Error, 164 | }, 165 | 166 | // Author: Andrzej (https://github.com/jerzyn) 167 | "request-GET-no-body-oas2": { 168 | message: "A `GET` request MUST NOT accept a request body.", 169 | description: 170 | "Defining a request body on a HTTP GET is technically possible in some implementations, but is increasingly frowned upon due to the confusion that comes from unspecified behavior in the HTTP specification.", 171 | given: "$.paths..get.parameters..in", 172 | then: { 173 | function: pattern, 174 | functionOptions: { 175 | notMatch: "/^body$/", 176 | }, 177 | }, 178 | severity: DiagnosticSeverity.Error, 179 | formats: [oas2], 180 | }, 181 | 182 | // Author: Andrzej (https://github.com/jerzyn) 183 | "request-GET-no-body-oas3": { 184 | message: "A `GET` request MUST NOT accept a request body.", 185 | description: 186 | "Defining a request body on a HTTP GET is in some implementations, but is increasingly frowned upon due to the confusion that comes from unspecified behavior in the HTTP specification.", 187 | given: "$.paths..get.requestBody", 188 | then: { 189 | function: undefinedFunc, 190 | }, 191 | formats: [oas3], 192 | severity: DiagnosticSeverity.Error, 193 | }, 194 | 195 | // Author: Andrzej (https://github.com/jerzyn) 196 | "hosts-https-only-oas2": { 197 | message: "Schemes MUST be https and no other protocol is allowed.", 198 | description: 199 | "Using http in production is reckless, advised against by OWASP API Security, and generally unnecessary thanks to free SSL on loads of hosts, gateways like Cloudflare, and OSS tools like Lets Encrypt.", 200 | given: "$.schemes", 201 | then: { 202 | function: schema, 203 | functionOptions: { 204 | schema: { 205 | type: "array", 206 | items: { 207 | type: "string", 208 | const: "https", 209 | }, 210 | }, 211 | }, 212 | }, 213 | severity: DiagnosticSeverity.Error, 214 | formats: [oas2], 215 | }, 216 | 217 | // Author: Andrzej (https://github.com/jerzyn) 218 | "hosts-https-only-oas3": { 219 | message: "Servers MUST be https and no other protocol is allowed.", 220 | description: 221 | "Using http in production is reckless, advised against by OWASP API Security, and generally unnecessary thanks to free SSL on loads of hosts, gateways like Cloudflare, and OSS tools like Lets Encrypt.", 222 | given: "$.servers..url", 223 | then: { 224 | function: pattern, 225 | functionOptions: { 226 | match: "/^https:/", 227 | }, 228 | }, 229 | formats: [oas3], 230 | severity: DiagnosticSeverity.Error, 231 | }, 232 | 233 | // Author: Andrzej (https://github.com/jerzyn) 234 | "request-support-json-oas3": { 235 | message: 236 | "Every request SHOULD support at least one `application/json` content type.", 237 | description: 238 | "Maybe you've got an XML heavy API or you're using a special binary format like BSON or CSON. That's lovely, but supporting JSON too is going to help a lot of people avoid a lot of confusion, and probably make you more money than you spend on supporting it.", 239 | given: "$.paths[*][*].requestBody.content", 240 | then: { 241 | function: schema, 242 | functionOptions: { 243 | schema: { 244 | type: "object", 245 | properties: { 246 | "application/json": true, 247 | }, 248 | required: ["application/json"], 249 | }, 250 | }, 251 | }, 252 | formats: [oas3], 253 | severity: DiagnosticSeverity.Warning, 254 | }, 255 | 256 | // Author: Phil Sturgeon (https://github.com/philsturgeon) 257 | "no-unknown-error-format": { 258 | message: "Error response should use a standard error format.", 259 | description: 260 | "Error responses can be unique snowflakes, different to every API, but standards exist to make them consistent, which reduces surprises and increase interoperability. Please use either RFC 7807 (https://tools.ietf.org/html/rfc7807) or the JSON:API Error format (https://jsonapi.org/format/#error-objects).", 261 | given: "$.paths[*]..responses[?(@property.match(/^(4|5)/))].content.*~", 262 | then: { 263 | function: enumeration, 264 | functionOptions: { 265 | values: [ 266 | "application/vnd.api+json", 267 | "application/problem+json", 268 | "application/problem+xml", 269 | ], 270 | }, 271 | }, 272 | formats: [oas3], 273 | severity: DiagnosticSeverity.Warning, 274 | }, 275 | 276 | // Author: Nauman Ali (https://github.com/naumanali-stoplight) 277 | "no-global-versioning": { 278 | message: "Server URL should not contain global versions.", 279 | description: 280 | "Using global versions just forces all your clients to do a lot more work for each upgrade. Please consider using API Evolution instead.\n\nMore: https://apisyouwonthate.com/blog/api-evolution-for-rest-http-apis.", 281 | given: "$.servers[*].url", 282 | then: { 283 | function: pattern, 284 | functionOptions: { 285 | notMatch: "/v[1-9]+", 286 | }, 287 | }, 288 | formats: [oas3], 289 | severity: DiagnosticSeverity.Warning, 290 | }, 291 | 292 | // Author: Advanced API & Integrations Team (https://www.oneadvanced.com/) 293 | "no-file-extensions-in-paths": { 294 | message: 295 | "Paths must not include file extensions such as .json, .xml, .html and .txt.", 296 | description: 297 | "Paths must not include file extensions such as `.json`, `.xml`, `.html` and `.txt`. Use the OpenAPI `content` keyword to tell consumers which Media Types are available.", 298 | given: "$.paths[*]~", 299 | then: { 300 | function: pattern, 301 | functionOptions: { 302 | notMatch: ".(json|xml|html|txt)$", 303 | }, 304 | }, 305 | severity: DiagnosticSeverity.Error, 306 | }, 307 | 308 | // Author: Advanced API & Integrations Team (https://www.oneadvanced.com/) 309 | "no-security-schemes-defined": { 310 | message: "All APIs MUST have a security scheme defined.", 311 | description: 312 | "This API definition does not have any security scheme defined, which means the entire API is open to the public. That's probably not what you want, even if all the data is read-only. Setting lower rate limits for the public and letting known consumers use more resources is a handy path to monetization, and helps know who your power users are when changes need feedback or migration, even if not just good practice.", 313 | given: "$..components", 314 | then: { 315 | field: "securitySchemes", 316 | function: truthy, 317 | }, 318 | formats: [oas3], 319 | severity: DiagnosticSeverity.Error, 320 | }, 321 | }, 322 | }; 323 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist" 6 | } 7 | } 8 | --------------------------------------------------------------------------------