Skip to content

Commit 7a9172e

Browse files
kevinccbsgclaude
andauthored
feat: content-type support for validateResponse/validateRequest (#1)
* feat(types): add optional contentType to ValidatorOptions Narrow stored options type to { strict: boolean } since contentType is resolved per-call, not at construction time. * feat(schemas): add isBinaryContentType helper * feat(schemas): add resolveMediaType helper for content-type lookup * feat(schemas): content-type aware response schema extraction Extend extractResponseSchema with an optional contentType parameter (defaulting to application/json) that uses isBinaryContentType and resolveMediaType to support exact, family-wildcard, and */* media-type matching; silently bypasses unmatched binary types and emits MISSING_SCHEMA for unmatched non-binary types. * feat(schemas): content-type aware request schema extraction Extend extractRequestSchema with an optional contentType parameter (defaulting to application/json), using resolveMediaType for wildcard matching and isBinaryContentType for silent bypass of binary payloads. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(validator): forward contentType option through validateResponse Plumbs options.contentType (defaulting to 'application/json') from validateResponse into extractResponseSchema, enabling content-type-aware schema lookup for binary and wildcard media types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(validator): forward contentType option through validateRequest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(validator): normalize schemas under all content-type entries Update normalizeAllSchemas to iterate over every media-type object in both response content and requestBody content, rather than only handling application/json, so non-JSON schemas (multipart/form-data, text/plain, etc.) are also converted to OpenAPI 3.1 shape during init. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(validator): use discriminating exclusiveMinimum transform Replace nullable-based assertions (which Ajv silently accepts even without normalization due to strict:false) with exclusiveMinimum boolean-form tests that only pass when normalizeAllSchemas actually rewrites 3.0 schemas under non-JSON content types. * chore: bump version to 0.2.0 for content-type support * docs: changelog entry for 0.2.0 content-type support --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4b56326 commit 7a9172e

10 files changed

Lines changed: 658 additions & 173 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.2.0 (2026-04-20)
4+
5+
### Features
6+
7+
- **content-type:** Add optional `contentType` to `ValidatorOptions` for `validateResponse` and `validateRequest`. Defaults to `application/json` (backwards compatible). Media-type resolution order is exact match → family wildcard (`image/*`) → `*/*`. Unmatched binary content types (`image/*`, `video/*`, `audio/*`, `application/octet-stream`, `application/pdf`, `application/zip`) are silently bypassed — no more false-positive `MISSING_SCHEMA` warnings when a mock returns binary data like a QR code.
8+
9+
### Internal
10+
11+
- **normalize:** `normalizeAllSchemas` now rewrites OpenAPI 3.0 → 3.1 schemas under every media-type entry in `content`, not only `application/json`. Previously, schemas declared under e.g. `multipart/form-data` or `image/jpeg` missed the rewrite and could throw at validation time.
12+
313
## 0.1.4 (2026-04-08)
414

515
### Bug Fixes

package-lock.json

Lines changed: 152 additions & 152 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "openapi-mock-validator",
3-
"version": "0.1.4",
3+
"version": "0.2.0",
44
"description": "Validate JSON payloads against OpenAPI 3.0/3.1 specs — catch mock drift before it hits production",
55
"type": "module",
66
"main": "./dist/index.js",

src/schemas.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
import type { OpenAPISpec, ValidationWarning } from './types.js';
22

3+
const BINARY_PREFIXES = ['image/', 'video/', 'audio/'] as const;
4+
const BINARY_EXACT = new Set([
5+
'application/octet-stream',
6+
'application/pdf',
7+
'application/zip',
8+
]);
9+
10+
export function isBinaryContentType(contentType: string): boolean {
11+
return BINARY_PREFIXES.some((prefix) => contentType.startsWith(prefix))
12+
|| BINARY_EXACT.has(contentType);
13+
}
14+
15+
export function resolveMediaType(
16+
content: Record<string, Record<string, unknown>>,
17+
contentType: string,
18+
): Record<string, unknown> | null {
19+
if (content[contentType]) return content[contentType];
20+
21+
const slashIndex = contentType.indexOf('/');
22+
if (slashIndex > 0) {
23+
const family = `${contentType.slice(0, slashIndex)}/*`;
24+
if (content[family]) return content[family];
25+
}
26+
27+
if (content['*/*']) return content['*/*'];
28+
29+
return null;
30+
}
31+
332
interface SchemaExtractionResult {
433
schema: Record<string, unknown> | null;
534
warnings: ValidationWarning[];
@@ -10,6 +39,7 @@ export function extractResponseSchema(
1039
path: string,
1140
method: string,
1241
status: number,
42+
contentType: string = 'application/json',
1343
): SchemaExtractionResult {
1444
const warnings: ValidationWarning[] = [];
1545
const normalizedMethod = method.toLowerCase();
@@ -48,11 +78,14 @@ export function extractResponseSchema(
4878
return { schema: null, warnings };
4979
}
5080

51-
const mediaType = content['application/json'];
81+
const mediaType = resolveMediaType(content, contentType);
5282
if (!mediaType) {
83+
if (isBinaryContentType(contentType)) {
84+
return { schema: null, warnings: [] };
85+
}
5386
warnings.push({
5487
type: 'MISSING_SCHEMA',
55-
message: `No application/json content for ${method.toUpperCase()} ${path} (${status})`,
88+
message: `No ${contentType} content for ${method.toUpperCase()} ${path} (${status})`,
5689
});
5790
return { schema: null, warnings };
5891
}
@@ -73,6 +106,7 @@ export function extractRequestSchema(
73106
spec: OpenAPISpec,
74107
path: string,
75108
method: string,
109+
contentType: string = 'application/json',
76110
): SchemaExtractionResult {
77111
const warnings: ValidationWarning[] = [];
78112
const normalizedMethod = method.toLowerCase();
@@ -105,11 +139,14 @@ export function extractRequestSchema(
105139
return { schema: null, warnings };
106140
}
107141

108-
const mediaType = content['application/json'];
142+
const mediaType = resolveMediaType(content, contentType);
109143
if (!mediaType) {
144+
if (isBinaryContentType(contentType)) {
145+
return { schema: null, warnings: [] };
146+
}
110147
warnings.push({
111148
type: 'MISSING_SCHEMA',
112-
message: `No application/json content in requestBody for ${method.toUpperCase()} ${path}`,
149+
message: `No ${contentType} content in requestBody for ${method.toUpperCase()} ${path}`,
113150
});
114151
return { schema: null, warnings };
115152
}

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
export interface ValidatorOptions {
22
strict?: boolean;
3+
/**
4+
* Content-Type of the response or request being validated.
5+
* Default: `"application/json"`.
6+
* Accepts exact types (`"image/jpeg"`) or is matched against wildcard
7+
* content-type entries in the spec (`"image/*"`, `"*\/*"`).
8+
*/
9+
contentType?: string;
310
}
411

512
export interface PathMatch {

src/validator.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface InternalError extends ValidationError {
2121

2222
export class OpenAPIMockValidator {
2323
private spec: OpenAPISpec;
24-
private options: Required<ValidatorOptions>;
24+
private options: { strict: boolean };
2525
private compiledPaths: CompiledPath[] | null = null;
2626
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Ajv2020 lacks proper type exports
2727
private ajv: any = null;
@@ -86,7 +86,8 @@ export class OpenAPIMockValidator {
8686
): ValidationResult {
8787
this.ensureInitialized();
8888

89-
const { schema, warnings } = extractResponseSchema(this.spec, path, method, status);
89+
const contentType = options?.contentType ?? 'application/json';
90+
const { schema, warnings } = extractResponseSchema(this.spec, path, method, status, contentType);
9091
if (!schema) {
9192
return { valid: true, errors: [], warnings };
9293
}
@@ -103,7 +104,8 @@ export class OpenAPIMockValidator {
103104
): ValidationResult {
104105
this.ensureInitialized();
105106

106-
const { schema, warnings } = extractRequestSchema(this.spec, path, method);
107+
const contentType = options?.contentType ?? 'application/json';
108+
const { schema, warnings } = extractRequestSchema(this.spec, path, method, contentType);
107109
if (!schema) {
108110
return { valid: true, errors: [], warnings };
109111
}
@@ -260,29 +262,37 @@ export class OpenAPIMockValidator {
260262
if (key.startsWith('x-') || typeof value !== 'object' || value === null) continue;
261263
const operation = value as Record<string, unknown>;
262264

263-
// Normalize response schemas
265+
// Normalize response schemas across all content types
264266
const responses = operation.responses as Record<string, Record<string, unknown>> | undefined;
265267
if (responses) {
266268
for (const response of Object.values(responses)) {
267269
const content = response?.content as Record<string, Record<string, unknown>> | undefined;
268-
if (content?.['application/json']?.schema) {
269-
content['application/json'].schema = normalizeSpec(
270-
content['application/json'].schema as Record<string, unknown>,
271-
spec.openapi,
272-
);
270+
if (content) {
271+
for (const mediaTypeObj of Object.values(content)) {
272+
if (mediaTypeObj?.schema) {
273+
mediaTypeObj.schema = normalizeSpec(
274+
mediaTypeObj.schema as Record<string, unknown>,
275+
spec.openapi,
276+
);
277+
}
278+
}
273279
}
274280
}
275281
}
276282

277-
// Normalize request body schemas
283+
// Normalize request body schemas across all content types
278284
const requestBody = operation.requestBody as Record<string, unknown> | undefined;
279285
if (requestBody) {
280286
const content = requestBody.content as Record<string, Record<string, unknown>> | undefined;
281-
if (content?.['application/json']?.schema) {
282-
content['application/json'].schema = normalizeSpec(
283-
content['application/json'].schema as Record<string, unknown>,
284-
spec.openapi,
285-
);
287+
if (content) {
288+
for (const mediaTypeObj of Object.values(content)) {
289+
if (mediaTypeObj?.schema) {
290+
mediaTypeObj.schema = normalizeSpec(
291+
mediaTypeObj.schema as Record<string, unknown>,
292+
spec.openapi,
293+
);
294+
}
295+
}
286296
}
287297
}
288298
}

0 commit comments

Comments
 (0)