diff --git a/CONTEXT.md b/CONTEXT.md index 6ef1499..a1008d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,12 @@ A document flaw that contradicts a rule of the applicable OpenAPI dialect: a REQ genoc enforces exactly what the spec prose mandates — presence and type, never value quality the spec leaves unconstrained: `info.title: ""` is a valid title. A document that is valid but beyond genoc (3.2, external `$ref`s) is an unsupported feature, not a spec violation. +## Shared rule + +A validation rule stated identically by more than one dialect, so its check lives once in a shared module under `src/parser/version/` (`validate-security.ts`, `validate-info.ts`) rather than in each dialect's validator file. The module takes the dialect as a parameter — `(spec, version, errors)` — and renders its own spec links, so the same message cites each dialect's own spec page. + +Rules that are identical except for a small delta parameterize the delta per version inside the shared module (security scheme types, for instance); genuinely dialect-specific rules stay in the per-dialect validator files. The published dialects are frozen documents, so a rule identical across two of them stays identical — sharing costs nothing and keeps genoc-side changes out of lockstep edits. + ## Security Scheme One named authentication mechanism, declared in `components.securitySchemes` — the only declaration site in both 3.0 and 3.1 (external-document references are outside the boundary). Typed by `type`: `apiKey`, `http`, `oauth2`, `openIdConnect`, plus `mutualTLS` in 3.1 only. diff --git a/src/parser/version/v3.0/validator.ts b/src/parser/version/v3.0/validator.ts index 1f4754a..1213abc 100644 --- a/src/parser/version/v3.0/validator.ts +++ b/src/parser/version/v3.0/validator.ts @@ -1,4 +1,5 @@ import { specViolation, type SpecLinkKey } from '../spec-links.js'; +import { validateInfo } from '../validate-info.js'; import { validateSecurity } from '../validate-security.js'; import type { ValidationResult } from '../validate.js'; @@ -14,7 +15,7 @@ const violation = (key: SpecLinkKey, message: string): string => specViolation(' * 3.0-specific validation rules: * - `openapi` must start with "3.0" * - `paths` is required (unlike 3.1 where paths/components/webhooks is flexible) - * - `info` with `title` and `version` required + * - `info` with `title` and `version` required (shared rule) * - Warns about 3.1-only features: `webhooks`, `$schema` in schemas, `type` as array * - Accepts `nullable` and `example` (valid in 3.0, not in 3.1) * - Errors on `items` as array (3.1-only tuple syntax) @@ -44,23 +45,7 @@ export function validateSpec30(spec: unknown): ValidationResult { ); } - if (!s.info || typeof s.info !== 'object' || Array.isArray(s.info)) { - errors.push( - violation('info-object', "OpenAPI specification must have an 'info' field with object value") - ); - } else { - const info = s.info as Record; - if (typeof info.title !== 'string') { - errors.push( - violation('info-title', "Info object must have a 'title' field with string value") - ); - } - if (typeof info.version !== 'string') { - errors.push( - violation('info-version', "Info object must have a 'version' field with string value") - ); - } - } + validateInfo(s, '3.0', errors); if (!s.paths || typeof s.paths !== 'object' || Array.isArray(s.paths)) { errors.push( diff --git a/src/parser/version/v3.1/validator.ts b/src/parser/version/v3.1/validator.ts index badff40..8413e61 100644 --- a/src/parser/version/v3.1/validator.ts +++ b/src/parser/version/v3.1/validator.ts @@ -1,4 +1,5 @@ import { specViolation, type SpecLinkKey } from '../spec-links.js'; +import { validateInfo } from '../validate-info.js'; import { validateSecurity } from '../validate-security.js'; import type { ValidationResult } from '../validate.js'; @@ -14,7 +15,7 @@ const violation = (key: SpecLinkKey, message: string): string => specViolation(' * 3.1-specific validation rules: * - `openapi` must start with "3.1" * - at least one of `paths`, `components`, or `webhooks` (unlike 3.0 where `paths` is required) - * - `info` with `title` and `version` required + * - `info` with `title` and `version` required (shared rule) * - `paths` must be an object when present * - `components.schemas` entries must be objects, validated recursively for 3.0-only features * - Warns about 3.0-only features in schemas: `nullable`, boolean `exclusiveMinimum`/`exclusiveMaximum`, @@ -48,23 +49,7 @@ export function validateSpec31(spec: unknown): ValidationResult { ); } - if (!s.info || typeof s.info !== 'object' || Array.isArray(s.info)) { - errors.push( - violation('info-object', "OpenAPI specification must have an 'info' field with object value") - ); - } else { - const info = s.info as Record; - if (typeof info.title !== 'string') { - errors.push( - violation('info-title', "Info object must have a 'title' field with string value") - ); - } - if (typeof info.version !== 'string') { - errors.push( - violation('info-version', "Info object must have a 'version' field with string value") - ); - } - } + validateInfo(s, '3.1', errors); if (!s.paths && !s.components && !s.webhooks) { // Not a spec violation: all three fields are optional in 3.1; the check diff --git a/src/parser/version/validate-info.ts b/src/parser/version/validate-info.ts new file mode 100644 index 0000000..3fe8c5f --- /dev/null +++ b/src/parser/version/validate-info.ts @@ -0,0 +1,46 @@ +/** + * Shared Info Object validation for the 3.0 and 3.1 validators. + * + * Rules are identical across dialects — every published dialect (3.0, 3.1, + * 3.2 alike) requires `info` to be an object carrying `title` and `version` + * strings. Presence and type only: the spec puts no constraint on the + * values, so `title: ""` is a valid title. + */ + +import { specViolation } from './spec-links.js'; + +export function validateInfo( + spec: Record, + version: '3.0' | '3.1', + errors: string[] +): void { + if (!spec.info || typeof spec.info !== 'object' || Array.isArray(spec.info)) { + errors.push( + specViolation( + version, + 'info-object', + "OpenAPI specification must have an 'info' field with object value" + ) + ); + return; + } + const info = spec.info as Record; + if (typeof info.title !== 'string') { + errors.push( + specViolation( + version, + 'info-title', + "Info object must have a 'title' field with string value" + ) + ); + } + if (typeof info.version !== 'string') { + errors.push( + specViolation( + version, + 'info-version', + "Info object must have a 'version' field with string value" + ) + ); + } +} diff --git a/tests/unit/version/validate-info.test.ts b/tests/unit/version/validate-info.test.ts new file mode 100644 index 0000000..b3786db --- /dev/null +++ b/tests/unit/version/validate-info.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { validateInfo } from '../../../src/parser/version/validate-info.js'; + +function run(spec: Record, version: '3.0' | '3.1'): string[] { + const errors: string[] = []; + validateInfo(spec, version, errors); + return errors; +} + +// Spec pages are hardcoded instead of importing SPEC_PAGE: these tests must +// fail loudly if link rendering ever changes. When a spec patch release bumps +// SPEC_PAGE, update these expectations in the same commit. +const DIALECTS = [ + { version: '3.0', page: 'https://spec.openapis.org/oas/v3.0.4.html' }, + { version: '3.1', page: 'https://spec.openapis.org/oas/v3.1.1.html' }, +] as const; + +describe('validateInfo', () => { + it.each(DIALECTS)( + 'rejects a missing or non-object info with the exact %s link', + ({ version, page }) => { + for (const info of [undefined, null, 'nope', [], 42]) { + const spec: Record = info === undefined ? {} : { info }; + expect(run(spec, version)).toEqual([ + `OpenAPI specification must have an 'info' field with object value\n ${page}#info-object`, + ]); + } + } + ); + + it.each(DIALECTS)( + 'rejects a non-string info.title with the exact %s link', + ({ version, page }) => { + for (const title of [undefined, null, 42, ['T']]) { + expect(run({ info: { title, version: '1.0.0' } }, version)).toEqual([ + `Info object must have a 'title' field with string value\n ${page}#info-title`, + ]); + } + } + ); + + it.each(DIALECTS)( + 'rejects a non-string info.version with the exact %s link', + ({ version, page }) => { + for (const badVersion of [undefined, null, 42, ['1']]) { + expect(run({ info: { title: 'Test', version: badVersion } }, version)).toEqual([ + `Info object must have a 'version' field with string value\n ${page}#info-version`, + ]); + } + } + ); + + it.each(DIALECTS)( + 'accepts empty-string title and version in %s (spec constrains presence only)', + ({ version }) => { + expect(run({ info: { title: '', version: '' } }, version)).toEqual([]); + } + ); + + it.each(DIALECTS)('accepts a fully valid info object in %s', ({ version }) => { + expect(run({ info: { title: 'Test', version: '1.0.0' } }, version)).toEqual([]); + }); +});