diff --git a/src/__tests__/__helpers__/helper.mts b/src/__tests__/__helpers__/helper.mts index 0752baa..eb8ba0e 100644 --- a/src/__tests__/__helpers__/helper.mts +++ b/src/__tests__/__helpers__/helper.mts @@ -13,7 +13,8 @@ import type { IRuleResult, Ruleset, RulesetDefinition } from '@stoplight/spectra const { fetch } = spectralRuntime; const { Spectral, Document } = SpectralCore; const { Yaml } = SpectralParsers; -const RULESET_PATH = path.resolve(process.cwd(), 'ukhsa.oas.rules.yml'); +const DEFAULT_RULESET_PATH = path.resolve(process.cwd(), 'ukhsa.oas.rules.yml'); +const rulesetCache = new Map>(); /** * Loads the Spectral ruleset definition from a local YAML file. @@ -21,17 +22,25 @@ const RULESET_PATH = path.resolve(process.cwd(), 'ukhsa.oas.rules.yml'); * @throws {Error} If the ruleset file does not exist at the expected path. * @returns The parsed ruleset definition object. */ -async function loadRulesetFromYaml(): Promise { - if (!fs.existsSync(RULESET_PATH)) { - throw new Error(`Ruleset file not found at ${RULESET_PATH}`); +async function loadRulesetFromYaml(rulesetPath: string = DEFAULT_RULESET_PATH): Promise { + if (!fs.existsSync(rulesetPath)) { + throw new Error(`Ruleset file not found at ${rulesetPath}`); } - return await bundleAndLoadRuleset(RULESET_PATH, { fs, fetch }, [commonjs()]); + if (!rulesetCache.has(rulesetPath)) { + rulesetCache.set(rulesetPath, bundleAndLoadRuleset(rulesetPath, { fs, fetch }, [commonjs()])); + } + + return await rulesetCache.get(rulesetPath)!; } export type RuleName = keyof Ruleset['rules']; +type TestRuleOptions = Readonly<{ + rulesetPath?: string; +}>; + type Scenario = ReadonlyArray< Readonly<{ name: string; @@ -49,10 +58,13 @@ type Scenario = ReadonlyArray< * @throws {Error} If any requested rule is not found in the loaded ruleset. * @returns A Spectral instance with the filtered ruleset. */ -export async function createWithRules(rules: RuleName[]): Promise { +export async function createWithRules( + rules: RuleName[], + rulesetPath: string = DEFAULT_RULESET_PATH, +): Promise { const s = new Spectral({ resolver: httpAndFileResolver }); // Load a fresh ruleset per Spectral instance to guarantee isolation - const freshRuleset = await loadRulesetFromYaml(); + const freshRuleset = await loadRulesetFromYaml(rulesetPath); s.setRuleset(freshRuleset as RulesetDefinition); return s; @@ -64,13 +76,22 @@ export async function createWithRules(rules: RuleName[]): Promise { + let spectral!: SpectralCore.Spectral; + + beforeAll(async () => { + spectral = await createWithRules(rulesToInclude, rulesetPath); + }); + for (const t of tests) { it(t.name, async () => { - const spectral = await createWithRules(rulesToInclude); - const doc = t.document instanceof Document ? t.document @@ -91,8 +112,8 @@ export default function testRule(rules: RuleName | RuleName[], tests: Scenario): * * @throws {Error} If the ruleset file is missing. */ -export function expectRulesetFileExists(): void { - if (!fs.existsSync(RULESET_PATH)) { - throw new Error(`Ruleset file not found at ${RULESET_PATH}`); +export function expectRulesetFileExists(rulesetPath: string = DEFAULT_RULESET_PATH): void { + if (!fs.existsSync(rulesetPath)) { + throw new Error(`Ruleset file not found at ${rulesetPath}`); } } diff --git a/src/__tests__/rules/must-always-return-json-objects-as-top-level-data-structures.test.ts b/src/__tests__/rules/must-always-return-json-objects-as-top-level-data-structures.test.ts new file mode 100644 index 0000000..1836908 --- /dev/null +++ b/src/__tests__/rules/must-always-return-json-objects-as-top-level-data-structures.test.ts @@ -0,0 +1,60 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-always-return-json-objects-as-top-level-data-structures', [ + { + name: 'valid: response schema uses object as top-level', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + value: + type: string +`, + errors: [], + }, + { + name: 'invalid: response schema uses array top-level', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: array + items: + type: string +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + path: ['paths', '/items', 'get', 'responses', '200', 'content', 'application/json', 'schema'], + message: 'Top-level data structure must be an object', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-define-critical-problem-responses.test.ts b/src/__tests__/rules/must-define-critical-problem-responses.test.ts new file mode 100644 index 0000000..a5743b7 --- /dev/null +++ b/src/__tests__/rules/must-define-critical-problem-responses.test.ts @@ -0,0 +1,88 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-define-critical-problem-responses', [ + { + name: 'valid: 400, 404, and 500 error responses include Problem Details examples', + document: ` +openapi: 3.0.0 +info: + title: Example API + version: 1.0.0 +paths: + /pets: + get: + responses: + '400': + description: bad request + content: + application/problem+json: + examples: + sample: + value: + title: bad request + '404': + description: not found + content: + application/problem+json: + examples: + sample: + value: + title: not found + '500': + description: server error + content: + application/problem+json: + examples: + sample: + value: + title: server error +`, + errors: [], + }, + { + name: 'invalid: missing example for 404 response', + document: ` +openapi: 3.0.0 +info: + title: Example API + version: 1.0.0 +paths: + /pets: + get: + responses: + '400': + description: bad request + content: + application/problem+json: + examples: + sample: + value: + title: bad request + '404': + description: not found + content: + application/problem+json: {} + '500': + description: server error + content: + application/problem+json: + examples: + sample: + value: + title: server error +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + path: ['paths', '/pets', 'get', 'responses'], + message: + 'Each operation SHOULD define Problem Details for: 400, 404, 500. Issues: 404 (missing example).', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-define-security-problem-responses.test.ts b/src/__tests__/rules/must-define-security-problem-responses.test.ts new file mode 100644 index 0000000..31c52ef --- /dev/null +++ b/src/__tests__/rules/must-define-security-problem-responses.test.ts @@ -0,0 +1,72 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-define-security-problem-responses', [ + { + name: 'valid: secured operation defines 401 and 403 Problem Details responses', + document: ` +openapi: 3.0.0 +info: + title: Secure API + version: 1.0.0 +paths: + /accounts: + get: + security: + - ApiKeyAuth: [] + responses: + '401': + description: unauthorized + content: + application/problem+json: + examples: + unauthorized: + value: + title: unauthorized + '403': + description: forbidden + content: + application/problem+json: + examples: + forbidden: + value: + title: forbidden +`, + errors: [], + }, + { + name: 'invalid: secured operation missing 401 response', + document: ` +openapi: 3.0.0 +info: + title: Secure API + version: 1.0.0 +paths: + /accounts: + get: + security: + - ApiKeyAuth: [] + responses: + '403': + description: forbidden + content: + application/problem+json: + examples: + forbidden: + value: + title: forbidden +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + path: ['paths', '/accounts', 'get', 'responses'], + message: + 'Each operation SHOULD define Problem Details for: 401, 403. Issues: 401 (missing response).', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-define-security-schemes.test.ts b/src/__tests__/rules/must-define-security-schemes.test.ts new file mode 100644 index 0000000..46c8a62 --- /dev/null +++ b/src/__tests__/rules/must-define-security-schemes.test.ts @@ -0,0 +1,44 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-define-security-schemes', [ + { + name: 'valid: components contains securitySchemes', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-Api-Key +paths: {} +`, + errors: [], + }, + { + name: 'invalid: components missing securitySchemes', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +components: {} +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + path: ['components'], + message: 'All APIs MUST have a security scheme defined.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-have-info-api-audience.test.ts b/src/__tests__/rules/must-have-info-api-audience.test.ts new file mode 100644 index 0000000..b362f36 --- /dev/null +++ b/src/__tests__/rules/must-have-info-api-audience.test.ts @@ -0,0 +1,55 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-have-info-api-audience', [ + { + name: 'valid: x-audience uses allowed value', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 + x-audience: public-external +paths: {} +`, + errors: [], + }, + { + name: 'invalid: x-audience missing', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Missing or wrong `info.x-audience`, "info.x-audience" property must be defined.', + }, + ], + }, + { + name: 'invalid: x-audience has unexpected value', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 + x-audience: partners-only +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: + 'Missing or wrong `info.x-audience`, "partners-only" must be equal to one of the allowed values: "company-internal", "partner-external", "premium-external", "public-external".', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-have-info-value-chain.test.ts b/src/__tests__/rules/must-have-info-value-chain.test.ts new file mode 100644 index 0000000..56db3a3 --- /dev/null +++ b/src/__tests__/rules/must-have-info-value-chain.test.ts @@ -0,0 +1,55 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-have-info-value-chain', [ + { + name: 'valid: x-value-chain uses allowed value', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 + x-value-chain: detect +paths: {} +`, + errors: [], + }, + { + name: 'invalid: x-value-chain missing', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Missing or wrong `info.x-value-chain`, "info.x-value-chain" property must be defined.', + }, + ], + }, + { + name: 'invalid: x-value-chain uses unsupported value', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 + x-value-chain: sunset +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: + 'Missing or wrong `info.x-value-chain`, "sunset" must be equal to one of the allowed values: "prevent", "detect", "analyse", "respond", "cross-cutting", "enabling".', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-have-info-x-api-id.test.ts b/src/__tests__/rules/must-have-info-x-api-id.test.ts new file mode 100644 index 0000000..7d0513c --- /dev/null +++ b/src/__tests__/rules/must-have-info-x-api-id.test.ts @@ -0,0 +1,19 @@ +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-have-info-x-api-id', [ + { + name: 'rule disabled: missing info.x-api-id is ignored', + document: ` +openapi: 3.0.0 +info: + title: Payments API + version: 1.0.0 +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/must-not-define-request-body-for-get-requests.test.ts b/src/__tests__/rules/must-not-define-request-body-for-get-requests.test.ts new file mode 100644 index 0000000..6b1e75e --- /dev/null +++ b/src/__tests__/rules/must-not-define-request-body-for-get-requests.test.ts @@ -0,0 +1,53 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-not-define-request-body-for-get-requests', [ + { + name: 'valid: GET request without requestBody', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok +`, + errors: [], + }, + { + name: 'invalid: GET request defines requestBody', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + path: ['paths', '/items', 'get', 'requestBody'], + message: 'A GET request MUST NOT accept a request body.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-not-use-http-basic-authentication.test.ts b/src/__tests__/rules/must-not-use-http-basic-authentication.test.ts new file mode 100644 index 0000000..7f8aa40 --- /dev/null +++ b/src/__tests__/rules/must-not-use-http-basic-authentication.test.ts @@ -0,0 +1,48 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-not-use-http-basic-authentication', [ + { + name: 'valid: non-basic security scheme', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-Api-Key +paths: {} +`, + errors: [], + }, + { + name: 'invalid: HTTP basic scheme configured', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + path: ['components', 'securitySchemes', 'BasicAuth', 'scheme'], + message: 'APIs MUST NOT use `HTTP` Basic Authentication.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-provide-api-audience.test.ts b/src/__tests__/rules/must-provide-api-audience.test.ts new file mode 100644 index 0000000..835b28f --- /dev/null +++ b/src/__tests__/rules/must-provide-api-audience.test.ts @@ -0,0 +1,19 @@ +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-provide-api-audience', [ + { + name: 'rule disabled: missing info.x-audience does not fail legacy guidance', + document: ` +openapi: 3.0.0 +info: + title: Example API + version: 1.0.0 +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/must-return-200-for-api-root.test.ts b/src/__tests__/rules/must-return-200-for-api-root.test.ts new file mode 100644 index 0000000..15642fe --- /dev/null +++ b/src/__tests__/rules/must-return-200-for-api-root.test.ts @@ -0,0 +1,47 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-return-200-for-api-root', [ + { + name: 'valid: root path defines 200 response', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /: + get: + responses: + '200': + description: ok +`, + errors: [], + }, + { + name: 'invalid: root path missing 200 response', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /: + get: + responses: + '404': + description: missing +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + path: ['paths', '/', 'get', 'responses'], + message: 'Root path must define a 200 response.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-use-camel-case-for-property-names.test.ts b/src/__tests__/rules/must-use-camel-case-for-property-names.test.ts new file mode 100644 index 0000000..4c92b99 --- /dev/null +++ b/src/__tests__/rules/must-use-camel-case-for-property-names.test.ts @@ -0,0 +1,60 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-use-camel-case-for-property-names', [ + { + name: 'valid: response schema property uses camelCase', + document: ` +openapi: 3.0.0 +info: + title: Sample + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + inventoryCount: + type: integer +`, + errors: [], + }, + { + name: 'invalid: response schema property uses snake_case', + document: ` +openapi: 3.0.0 +info: + title: Sample + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + inventory_count: + type: integer +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Properties must use lower camel case.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-use-camel-case-for-query-parameters.test.ts b/src/__tests__/rules/must-use-camel-case-for-query-parameters.test.ts new file mode 100644 index 0000000..adad148 --- /dev/null +++ b/src/__tests__/rules/must-use-camel-case-for-query-parameters.test.ts @@ -0,0 +1,56 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-use-camel-case-for-query-parameters', [ + { + name: 'valid: query parameter uses camelCase', + document: ` +openapi: 3.0.0 +info: + title: Sample + version: 1.0.0 +paths: + /items: + get: + parameters: + - in: query + name: pageSize + schema: + type: integer + responses: + '200': + description: ok +`, + errors: [], + }, + { + name: 'invalid: query parameter uses snake_case', + document: ` +openapi: 3.0.0 +info: + title: Sample + version: 1.0.0 +paths: + /items: + get: + parameters: + - in: query + name: page_size + schema: + type: integer + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Query parameters must use lower camel case.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-use-https-protocol-only.test.ts b/src/__tests__/rules/must-use-https-protocol-only.test.ts new file mode 100644 index 0000000..4300892 --- /dev/null +++ b/src/__tests__/rules/must-use-https-protocol-only.test.ts @@ -0,0 +1,40 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-use-https-protocol-only', [ + { + name: 'valid: only https servers are declared', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +servers: + - url: https://api.example.com +paths: {} +`, + errors: [], + }, + { + name: 'invalid: http server triggers error', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +servers: + - url: http://api.example.com +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Servers MUST be `https` and no other protocol is allowed.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-use-normalized-paths-without-trailing-slash.test.ts b/src/__tests__/rules/must-use-normalized-paths-without-trailing-slash.test.ts new file mode 100644 index 0000000..63bc647 --- /dev/null +++ b/src/__tests__/rules/must-use-normalized-paths-without-trailing-slash.test.ts @@ -0,0 +1,24 @@ +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-use-normalized-paths-without-trailing-slash', [ + { + name: 'rule disabled: trailing slash paths allowed', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items/: + get: + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/must-use-normalized-paths.test.ts b/src/__tests__/rules/must-use-normalized-paths.test.ts new file mode 100644 index 0000000..e4c869a --- /dev/null +++ b/src/__tests__/rules/must-use-normalized-paths.test.ts @@ -0,0 +1,60 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-use-normalized-paths', [ + { + name: 'valid: paths start with slash and avoid trailing slash', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /pets: + get: + responses: + '200': + description: ok + /pets/{id}: + get: + responses: + '200': + description: ok +`, + errors: [], + }, + { + name: 'invalid: missing leading slash and trailing slash detected', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + pets: + get: + responses: + '200': + description: ok + /widgets/: + get: + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: "Path must start with a slash and must not end with a slash (except root path '/')", + }, + { + severity: DiagnosticSeverity.Error, + message: "Path must start with a slash and must not end with a slash (except root path '/')", + }, + ], + }, +]); diff --git a/src/__tests__/rules/must-use-snake-case-for-property-names.test.ts b/src/__tests__/rules/must-use-snake-case-for-property-names.test.ts new file mode 100644 index 0000000..cce950e --- /dev/null +++ b/src/__tests__/rules/must-use-snake-case-for-property-names.test.ts @@ -0,0 +1,31 @@ +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-use-snake-case-for-property-names', [ + { + name: 'rule disabled: camelCase property names permitted', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + camelCaseName: + type: string +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/must-use-snake-case-for-query-parameters.test.ts b/src/__tests__/rules/must-use-snake-case-for-query-parameters.test.ts new file mode 100644 index 0000000..405d19d --- /dev/null +++ b/src/__tests__/rules/must-use-snake-case-for-query-parameters.test.ts @@ -0,0 +1,29 @@ +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('must-use-snake-case-for-query-parameters', [ + { + name: 'rule disabled: camelCase query names permitted', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + parameters: + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/must-use-valid-version-info-schema.test.ts b/src/__tests__/rules/must-use-valid-version-info-schema.test.ts new file mode 100644 index 0000000..f48e102 --- /dev/null +++ b/src/__tests__/rules/must-use-valid-version-info-schema.test.ts @@ -0,0 +1,108 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +const validSchema = ` +type: object +properties: + name: + type: string + description: The name of the API. + version: + type: string + pattern: '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$' + description: The version of the API. + status: + type: string + x-extensible-enum: + - ALPHA + - BETA + - LIVE + - DEPRECATED + description: The status of the API version. + releaseDate: + type: string + format: date + description: The release date. + documentation: + type: string + format: uri + description: Documentation URL. + releaseNotes: + type: string + format: uri + description: Release notes URL. +`; + +testRule('must-use-valid-version-info-schema', [ + { + name: 'valid: root path provides ApiInfo schema with all required fields', + document: ` +openapi: 3.0.0 +info: + title: Version Info API + version: 1.0.0 +paths: + /: + get: + responses: + '200': + description: ApiInfo response + content: + application/json: + schema: +${validSchema.split('\n').map((line) => (line ? ` ${line}` : ' ')).join('\n')} +`, + errors: [], + }, + { + name: 'invalid: releaseNotes format is not uri', + document: ` +openapi: 3.0.0 +info: + title: Version Info API + version: 1.0.0 +paths: + /: + get: + responses: + '200': + description: ApiInfo response + content: + application/json: + schema: + type: object + properties: + name: + type: string + version: + type: string + pattern: '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$' + status: + type: string + x-extensible-enum: + - ALPHA + - BETA + - LIVE + - DEPRECATED + releaseDate: + type: string + format: date + documentation: + type: string + format: uri + releaseNotes: + type: string + format: url +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: "ApiInfo json must have property 'releaseNotes' with type 'string' and format 'uri'", + }, + ], + }, +]); diff --git a/src/__tests__/rules/oas3-api-servers.test.ts b/src/__tests__/rules/oas3-api-servers.test.ts new file mode 100644 index 0000000..be628a6 --- /dev/null +++ b/src/__tests__/rules/oas3-api-servers.test.ts @@ -0,0 +1,19 @@ +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('oas3-api-servers', [ + { + name: 'rule disabled: documents without servers do not raise diagnostics', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/should-define-api-root.test.ts b/src/__tests__/rules/should-define-api-root.test.ts new file mode 100644 index 0000000..45e3180 --- /dev/null +++ b/src/__tests__/rules/should-define-api-root.test.ts @@ -0,0 +1,52 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('should-define-api-root', [ + { + name: 'valid: root path is present', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /: + get: + responses: + '200': + description: ok + /status: + get: + responses: + '200': + description: ok +`, + errors: [], + }, + { + name: 'invalid: missing root path', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /status: + get: + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + path: ['paths'], + message: 'APIs SHOULD have a root path (`/`) defined.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/should-have-location-header-in-201-response.test.ts b/src/__tests__/rules/should-have-location-header-in-201-response.test.ts new file mode 100644 index 0000000..a04c045 --- /dev/null +++ b/src/__tests__/rules/should-have-location-header-in-201-response.test.ts @@ -0,0 +1,53 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('should-have-location-header-in-201-response', [ + { + name: 'valid: 201 response defines Location header schema', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + post: + responses: + '201': + description: created + headers: + Location: + description: resource url + schema: + type: string + format: uri + example: https://api.example.com/items/123 +`, + errors: [], + }, + { + name: 'invalid: 201 response missing Location header', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + post: + responses: + '201': + description: created +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + path: ['paths', '/items', 'post', 'responses', '201'], + }, + ], + }, +]); diff --git a/src/__tests__/rules/should-not-use-api-as-base-path.test.ts b/src/__tests__/rules/should-not-use-api-as-base-path.test.ts new file mode 100644 index 0000000..d2d5dd9 --- /dev/null +++ b/src/__tests__/rules/should-not-use-api-as-base-path.test.ts @@ -0,0 +1,24 @@ +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('should-not-use-api-as-base-path', [ + { + name: 'rule disabled: base path may start with /api without error', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /api/users: + get: + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/should-support-application-json-content-request-body.test.ts b/src/__tests__/rules/should-support-application-json-content-request-body.test.ts new file mode 100644 index 0000000..b8f8541 --- /dev/null +++ b/src/__tests__/rules/should-support-application-json-content-request-body.test.ts @@ -0,0 +1,57 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs'; + +describe('ruleset file', () => { + it('exists', () => expectRulesetFileExists()); +}); + +testRule('should-support-application-json-content-request-body', [ + { + name: 'valid: requestBody includes application/json content type', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + post: + requestBody: + content: + application/json: + schema: + type: object + responses: + '201': + description: created +`, + errors: [], + }, + { + name: 'invalid: requestBody missing application/json', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + post: + requestBody: + content: + application/xml: + schema: + type: object + responses: + '201': + description: created +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + path: ['paths', '/items', 'post', 'requestBody', 'content'], + message: 'Every request SHOULD support at least one `application/json` content type.', + }, + ], + }, +]); diff --git a/src/__tests__/rules/zalando/must-define-a-format-for-integer-types.test.ts b/src/__tests__/rules/zalando/must-define-a-format-for-integer-types.test.ts new file mode 100644 index 0000000..7733b95 --- /dev/null +++ b/src/__tests__/rules/zalando/must-define-a-format-for-integer-types.test.ts @@ -0,0 +1,68 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-define-a-format-for-integer-types', [ + { + name: 'invalid: integer property missing format', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + quantity: + type: integer +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Numeric properties must have valid format specified', + }, + ], + }, + { + name: 'valid: integer property uses allowed format', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + quantity: + type: integer + format: int64 +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-define-a-format-for-number-types.test.ts b/src/__tests__/rules/zalando/must-define-a-format-for-number-types.test.ts new file mode 100644 index 0000000..e80f0e0 --- /dev/null +++ b/src/__tests__/rules/zalando/must-define-a-format-for-number-types.test.ts @@ -0,0 +1,68 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-define-a-format-for-number-types', [ + { + name: 'invalid: number property missing format', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + weight: + type: number +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Numeric properties must have valid format specified', + }, + ], + }, + { + name: 'valid: number property uses allowed format', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + weight: + type: number + format: double +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-have-info-contact-email.test.ts b/src/__tests__/rules/zalando/must-have-info-contact-email.test.ts new file mode 100644 index 0000000..c5223ac --- /dev/null +++ b/src/__tests__/rules/zalando/must-have-info-contact-email.test.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-have-info-contact-email', [ + { + name: 'invalid: contact email missing', + document: ` +openapi: 3.0.0 +info: + title: Users API + version: 1.0.0 + contact: + name: Users Team +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Must have email defined in `info.contact.email`', + }, + ], + }, + { + name: 'valid: contact email provided', + document: ` +openapi: 3.0.0 +info: + title: Users API + version: 1.0.0 + contact: + name: Users Team + email: team@example.com +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-have-info-contact-name.test.ts b/src/__tests__/rules/zalando/must-have-info-contact-name.test.ts new file mode 100644 index 0000000..0fd79b2 --- /dev/null +++ b/src/__tests__/rules/zalando/must-have-info-contact-name.test.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-have-info-contact-name', [ + { + name: 'invalid: contact name missing', + document: ` +openapi: 3.0.0 +info: + title: Orders API + version: 1.0.0 + contact: + email: team@example.com +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Must have name defined in `info.contact.name`', + }, + ], + }, + { + name: 'valid: contact name supplied', + document: ` +openapi: 3.0.0 +info: + title: Orders API + version: 1.0.0 + contact: + name: API Team + email: team@example.com +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-have-info-contact-url.test.ts b/src/__tests__/rules/zalando/must-have-info-contact-url.test.ts new file mode 100644 index 0000000..45d7314 --- /dev/null +++ b/src/__tests__/rules/zalando/must-have-info-contact-url.test.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-have-info-contact-url', [ + { + name: 'invalid: contact url missing', + document: ` +openapi: 3.0.0 +info: + title: Accounts API + version: 1.0.0 + contact: + name: Accounts Team + email: team@example.com +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Must have email defined in `info.contact.url`', + }, + ], + }, + { + name: 'valid: contact url provided', + document: ` +openapi: 3.0.0 +info: + title: Accounts API + version: 1.0.0 + contact: + name: Accounts Team + email: team@example.com + url: https://example.com/support +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-have-info-description.test.ts b/src/__tests__/rules/zalando/must-have-info-description.test.ts new file mode 100644 index 0000000..58d3d61 --- /dev/null +++ b/src/__tests__/rules/zalando/must-have-info-description.test.ts @@ -0,0 +1,44 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-have-info-description', [ + { + name: 'invalid: missing description', + document: ` +openapi: 3.0.0 +info: + title: Payments API + version: 1.0.0 +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Must have API description defined in `info.description`', + }, + ], + }, + { + name: 'valid: description provided', + document: ` +openapi: 3.0.0 +info: + title: Payments API + version: 1.0.0 + description: Manage payments +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-have-info-title.test.ts b/src/__tests__/rules/zalando/must-have-info-title.test.ts new file mode 100644 index 0000000..417543a --- /dev/null +++ b/src/__tests__/rules/zalando/must-have-info-title.test.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-have-info-title', [ + { + name: 'invalid: missing title', + document: ` +openapi: 3.0.0 +info: + version: 1.2.3 +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Must have API title defined in `info.title`', + }, + ], + }, + { + name: 'valid: title present', + document: ` +openapi: 3.0.0 +info: + title: Catalog API + version: 1.2.3 +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-have-info-version.test.ts b/src/__tests__/rules/zalando/must-have-info-version.test.ts new file mode 100644 index 0000000..1cb0b0d --- /dev/null +++ b/src/__tests__/rules/zalando/must-have-info-version.test.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-have-info-version', [ + { + name: 'invalid: missing version', + document: ` +openapi: 3.0.0 +info: + title: Catalog API +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Must have API version defined in `info.version`', + }, + ], + }, + { + name: 'invalid: non-semver version string', + document: ` +openapi: 3.0.0 +info: + title: Catalog API + version: v1 +paths: {} +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Must have API version defined in `info.version`', + }, + ], + }, + { + name: 'valid: semver version provided', + document: ` +openapi: 3.0.0 +info: + title: Catalog API + version: 1.2.3 +paths: {} +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-not-use-uri-versioning.test.ts b/src/__tests__/rules/zalando/must-not-use-uri-versioning.test.ts new file mode 100644 index 0000000..d108c7e --- /dev/null +++ b/src/__tests__/rules/zalando/must-not-use-uri-versioning.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-not-use-uri-versioning', [ + { + name: 'invalid: path includes version segment', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /v1/items: + get: + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Path must not contain versioning', + }, + ], + }, + { + name: 'valid: path versioning avoided', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-specify-default-response.test.ts b/src/__tests__/rules/zalando/must-specify-default-response.test.ts new file mode 100644 index 0000000..894855d --- /dev/null +++ b/src/__tests__/rules/zalando/must-specify-default-response.test.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-specify-default-response', [ + { + name: 'invalid: default response missing', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Operation does not contain a default response', + }, + ], + }, + { + name: 'valid: default response defined', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + default: + description: problem response + content: + application/problem+json: + schema: + type: object +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-use-lowercase-with-hyphens-for-path-segments.test.ts b/src/__tests__/rules/zalando/must-use-lowercase-with-hyphens-for-path-segments.test.ts new file mode 100644 index 0000000..2a4bb4b --- /dev/null +++ b/src/__tests__/rules/zalando/must-use-lowercase-with-hyphens-for-path-segments.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-use-lowercase-with-hyphens-for-path-segments', [ + { + name: 'invalid: uppercase and underscores in path', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /Orders/{orderId}/line_items: + get: + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Path segments have to be lowercase separate words with hyphens.', + }, + ], + }, + { + name: 'valid: lowercase path segments with hyphens', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /orders/{orderId}/line-items: + get: + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-use-normalized-paths-without-empty-path-segments.test.ts b/src/__tests__/rules/zalando/must-use-normalized-paths-without-empty-path-segments.test.ts new file mode 100644 index 0000000..823376b --- /dev/null +++ b/src/__tests__/rules/zalando/must-use-normalized-paths-without-empty-path-segments.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-use-normalized-paths-without-empty-path-segments', [ + { + name: 'invalid: duplicate slash in path', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /pets//owners: + get: + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Empty path segments are not allowed', + }, + ], + }, + { + name: 'valid: no empty path segments', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /pets/{petId}/owners: + get: + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-use-problem-json-as-default-response.test.ts b/src/__tests__/rules/zalando/must-use-problem-json-as-default-response.test.ts new file mode 100644 index 0000000..5a4eacf --- /dev/null +++ b/src/__tests__/rules/zalando/must-use-problem-json-as-default-response.test.ts @@ -0,0 +1,74 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-use-problem-json-as-default-response', [ + { + name: 'invalid: default response missing problem+json content', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + default: + description: generic error + content: + application/json: + schema: + type: object +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Operation must use problem json as default response', + }, + ], + }, + { + name: 'valid: default response uses problem+json', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + default: + description: generic error + content: + application/problem+json: + schema: + type: object + properties: + title: + type: string + status: + type: integer + format: int32 + type: + type: string + format: uri-reference + detail: + type: string + instance: + type: string +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-use-problem-json-for-errors.test.ts b/src/__tests__/rules/zalando/must-use-problem-json-for-errors.test.ts new file mode 100644 index 0000000..be944c9 --- /dev/null +++ b/src/__tests__/rules/zalando/must-use-problem-json-for-errors.test.ts @@ -0,0 +1,74 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-use-problem-json-for-errors', [ + { + name: 'invalid: error response not problem+json', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '400': + description: bad request + content: + application/json: + schema: + type: object +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: 'Error response must be application/problem+json', + }, + ], + }, + { + name: 'valid: error response uses application/problem+json', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '400': + description: bad request + content: + application/problem+json: + schema: + type: object + properties: + title: + type: string + status: + type: integer + format: int32 + type: + type: string + format: uri-reference + detail: + type: string + instance: + type: string +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/must-use-valid-problem-json-schema.test.ts b/src/__tests__/rules/zalando/must-use-valid-problem-json-schema.test.ts new file mode 100644 index 0000000..04d84cc --- /dev/null +++ b/src/__tests__/rules/zalando/must-use-valid-problem-json-schema.test.ts @@ -0,0 +1,89 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('must-use-valid-problem-json-schema', [ + { + name: 'invalid: problem schema missing required fields', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + default: + description: error response + content: + application/problem+json: + schema: + type: object + properties: + title: + type: string + status: + type: integer + detail: + type: string +`, + errors: [ + { + severity: DiagnosticSeverity.Error, + message: "Problem json must have property 'type' with type 'string' and format 'uri-reference'", + }, + { + severity: DiagnosticSeverity.Error, + message: "Problem json must have property 'status' with type 'integer' and format 'in32'", + }, + { + severity: DiagnosticSeverity.Error, + message: "Problem json must have property 'instance' with type 'string'", + }, + ], + }, + { + name: 'valid: full problem json schema present', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + default: + description: error response + content: + application/problem+json: + schema: + type: object + properties: + type: + type: string + format: uri-reference + title: + type: string + status: + type: integer + format: int32 + detail: + type: string + instance: + type: string +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/should-declare-enum-values-using-upper-snake-case-format.test.ts b/src/__tests__/rules/zalando/should-declare-enum-values-using-upper-snake-case-format.test.ts new file mode 100644 index 0000000..0b543d7 --- /dev/null +++ b/src/__tests__/rules/zalando/should-declare-enum-values-using-upper-snake-case-format.test.ts @@ -0,0 +1,105 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('should-declare-enum-values-using-upper-snake-case-format', [ + { + name: 'invalid: enum values not UPPER_SNAKE_CASE', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + state: + type: string + x-extensible-enum: + - PendingApproval + - Approved +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + message: 'Enum values should be in UPPER_SNAKE_CASE format', + path: [ + 'paths', + '/items', + 'get', + 'responses', + '200', + 'content', + 'application/json', + 'schema', + 'properties', + 'state', + 'x-extensible-enum', + '0', + ], + }, + { + severity: DiagnosticSeverity.Warning, + message: 'Enum values should be in UPPER_SNAKE_CASE format', + path: [ + 'paths', + '/items', + 'get', + 'responses', + '200', + 'content', + 'application/json', + 'schema', + 'properties', + 'state', + 'x-extensible-enum', + '1', + ], + } + ], + }, + { + name: 'valid: enum values follow UPPER_SNAKE_CASE', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + state: + type: string + x-extensible-enum: + - PENDING_APPROVAL + - APPROVED +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/should-limit-number-of-resource-types.test.ts b/src/__tests__/rules/zalando/should-limit-number-of-resource-types.test.ts new file mode 100644 index 0000000..2855400 --- /dev/null +++ b/src/__tests__/rules/zalando/should-limit-number-of-resource-types.test.ts @@ -0,0 +1,94 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('should-limit-number-of-resource-types', [ + { + name: 'invalid: more than eight resource types', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /a: + get: + responses: { '200': { description: ok } } + /b: + get: + responses: { '200': { description: ok } } + /c: + get: + responses: { '200': { description: ok } } + /d: + get: + responses: { '200': { description: ok } } + /e: + get: + responses: { '200': { description: ok } } + /f: + get: + responses: { '200': { description: ok } } + /g: + get: + responses: { '200': { description: ok } } + /h: + get: + responses: { '200': { description: ok } } + /i: + get: + responses: { '200': { description: ok } } +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + message: 'More than 8 resource types found', + }, + ], + }, + { + name: 'valid: at most eight root resource types', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /a: + get: + responses: { '200': { description: ok } } + /b: + get: + responses: { '200': { description: ok } } + /c: + get: + responses: { '200': { description: ok } } + /d: + get: + responses: { '200': { description: ok } } + /e: + get: + responses: { '200': { description: ok } } + /f: + get: + responses: { '200': { description: ok } } + /g: + get: + responses: { '200': { description: ok } } + /h: + get: + responses: { '200': { description: ok } } +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/should-limit-number-of-sub-resource-levels.test.ts b/src/__tests__/rules/zalando/should-limit-number-of-sub-resource-levels.test.ts new file mode 100644 index 0000000..ef99bf7 --- /dev/null +++ b/src/__tests__/rules/zalando/should-limit-number-of-sub-resource-levels.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('should-limit-number-of-sub-resource-levels', [ + { + name: 'invalid: too many nested sub-resources', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /customers/{customerId}/orders/{orderId}/items/{itemId}/options/{optionId}/details: + get: + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + message: 'Sub-resource levels should by <= 3', + }, + ], + }, + { + name: 'valid: three sub-resources allowed', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /customers/{customerId}/orders/{orderId}/items/{itemId}/options: + get: + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/should-prefer-standard-media-type-names.test.ts b/src/__tests__/rules/zalando/should-prefer-standard-media-type-names.test.ts new file mode 100644 index 0000000..999c273 --- /dev/null +++ b/src/__tests__/rules/zalando/should-prefer-standard-media-type-names.test.ts @@ -0,0 +1,61 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('should-prefer-standard-media-type-names', [ + { + name: 'invalid: non-standard media type used', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + text/plain: + schema: + type: string +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + message: 'Response content should use a standard media type `application/json` or `application/problem+json` (required for problem schemas).', + }, + ], + }, + { + name: 'valid: uses application/json', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/should-use-hyphenated-pascal-case-for-header-parameters.test.ts b/src/__tests__/rules/zalando/should-use-hyphenated-pascal-case-for-header-parameters.test.ts new file mode 100644 index 0000000..62af4d8 --- /dev/null +++ b/src/__tests__/rules/zalando/should-use-hyphenated-pascal-case-for-header-parameters.test.ts @@ -0,0 +1,63 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('should-use-hyphenated-pascal-case-for-header-parameters', [ + { + name: 'invalid: header not Hyphenated-Pascal-Case', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + parameters: + - in: header + name: x-custom-header + schema: + type: string + responses: + '200': + description: ok +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + message: 'Header parameters should be Hyphenated-Pascal-Case', + }, + ], + }, + { + name: 'valid: header follows Hyphenated-Pascal-Case', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + parameters: + - in: header + name: X-Custom-Header + schema: + type: string + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/should-use-standard-http-status-codes.test.ts b/src/__tests__/rules/zalando/should-use-standard-http-status-codes.test.ts new file mode 100644 index 0000000..9343d2a --- /dev/null +++ b/src/__tests__/rules/zalando/should-use-standard-http-status-codes.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('should-use-standard-http-status-codes', [ + { + name: 'invalid: non-standard status code used', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '599': + description: not standard +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + message: expect.stringContaining('standardized response code'), + }, + ], + }, + { + name: 'valid: standard status code used', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok +`, + errors: [], + }, +]); diff --git a/src/__tests__/rules/zalando/should-use-x-extensible-enum.test.ts b/src/__tests__/rules/zalando/should-use-x-extensible-enum.test.ts new file mode 100644 index 0000000..355cf6f --- /dev/null +++ b/src/__tests__/rules/zalando/should-use-x-extensible-enum.test.ts @@ -0,0 +1,73 @@ +import path from 'node:path'; + +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule, { expectRulesetFileExists, type RuleName } from '../../__helpers__/helper.mjs'; + +const ZALANDO_RULESET_PATH = path.resolve(process.cwd(), 'zalando.oas.rules.yml'); +const testZalandoRule = (rule: RuleName | RuleName[], tests: Parameters[1]) => + testRule(rule, tests, { rulesetPath: ZALANDO_RULESET_PATH }); + +describe('zalando ruleset file', () => { + it('exists', () => expectRulesetFileExists(ZALANDO_RULESET_PATH)); +}); + +testZalandoRule('should-use-x-extensible-enum', [ + { + name: 'invalid: enum used instead of x-extensible-enum', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: + - pending + - approved +`, + errors: [ + { + severity: DiagnosticSeverity.Warning, + message: 'Should use `x-extensible-enum` instead of `enum`', + }, + ], + }, + { + name: 'valid: x-extensible-enum used', + document: ` +openapi: 3.0.0 +info: + title: Example + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + status: + type: string + x-extensible-enum: + - PENDING + - APPROVED +`, + errors: [], + }, +]);