Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 3 additions & 18 deletions src/parser/version/v3.0/validator.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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)
Expand Down Expand Up @@ -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<string, unknown>;
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(
Expand Down
21 changes: 3 additions & 18 deletions src/parser/version/v3.1/validator.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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`,
Expand Down Expand Up @@ -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<string, unknown>;
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
Expand Down
46 changes: 46 additions & 0 deletions src/parser/version/validate-info.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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<string, unknown>;
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"
)
);
}
}
64 changes: 64 additions & 0 deletions tests/unit/version/validate-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';

import { validateInfo } from '../../../src/parser/version/validate-info.js';

function run(spec: Record<string, unknown>, 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<string, unknown> = 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([]);
});
});
Loading