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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.2.0 (2026-04-20)

### Features

- **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.

### Internal

- **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.

## 0.1.4 (2026-04-08)

### Bug Fixes
Expand Down
304 changes: 152 additions & 152 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openapi-mock-validator",
"version": "0.1.4",
"version": "0.2.0",
"description": "Validate JSON payloads against OpenAPI 3.0/3.1 specs — catch mock drift before it hits production",
"type": "module",
"main": "./dist/index.js",
Expand Down
45 changes: 41 additions & 4 deletions src/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
import type { OpenAPISpec, ValidationWarning } from './types.js';

const BINARY_PREFIXES = ['image/', 'video/', 'audio/'] as const;
const BINARY_EXACT = new Set([
'application/octet-stream',
'application/pdf',
'application/zip',
]);

export function isBinaryContentType(contentType: string): boolean {
return BINARY_PREFIXES.some((prefix) => contentType.startsWith(prefix))
|| BINARY_EXACT.has(contentType);
}

export function resolveMediaType(
content: Record<string, Record<string, unknown>>,
contentType: string,
): Record<string, unknown> | null {
if (content[contentType]) return content[contentType];

const slashIndex = contentType.indexOf('/');
if (slashIndex > 0) {
const family = `${contentType.slice(0, slashIndex)}/*`;
if (content[family]) return content[family];
}

if (content['*/*']) return content['*/*'];

return null;
}

interface SchemaExtractionResult {
schema: Record<string, unknown> | null;
warnings: ValidationWarning[];
Expand All @@ -10,6 +39,7 @@ export function extractResponseSchema(
path: string,
method: string,
status: number,
contentType: string = 'application/json',
): SchemaExtractionResult {
const warnings: ValidationWarning[] = [];
const normalizedMethod = method.toLowerCase();
Expand Down Expand Up @@ -48,11 +78,14 @@ export function extractResponseSchema(
return { schema: null, warnings };
}

const mediaType = content['application/json'];
const mediaType = resolveMediaType(content, contentType);
if (!mediaType) {
if (isBinaryContentType(contentType)) {
return { schema: null, warnings: [] };
}
warnings.push({
type: 'MISSING_SCHEMA',
message: `No application/json content for ${method.toUpperCase()} ${path} (${status})`,
message: `No ${contentType} content for ${method.toUpperCase()} ${path} (${status})`,
});
return { schema: null, warnings };
}
Expand All @@ -73,6 +106,7 @@ export function extractRequestSchema(
spec: OpenAPISpec,
path: string,
method: string,
contentType: string = 'application/json',
): SchemaExtractionResult {
const warnings: ValidationWarning[] = [];
const normalizedMethod = method.toLowerCase();
Expand Down Expand Up @@ -105,11 +139,14 @@ export function extractRequestSchema(
return { schema: null, warnings };
}

const mediaType = content['application/json'];
const mediaType = resolveMediaType(content, contentType);
if (!mediaType) {
if (isBinaryContentType(contentType)) {
return { schema: null, warnings: [] };
}
warnings.push({
type: 'MISSING_SCHEMA',
message: `No application/json content in requestBody for ${method.toUpperCase()} ${path}`,
message: `No ${contentType} content in requestBody for ${method.toUpperCase()} ${path}`,
});
return { schema: null, warnings };
}
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
export interface ValidatorOptions {
strict?: boolean;
/**
* Content-Type of the response or request being validated.
* Default: `"application/json"`.
* Accepts exact types (`"image/jpeg"`) or is matched against wildcard
* content-type entries in the spec (`"image/*"`, `"*\/*"`).
*/
contentType?: string;
}

export interface PathMatch {
Expand Down
40 changes: 25 additions & 15 deletions src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface InternalError extends ValidationError {

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

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

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

// Normalize response schemas
// Normalize response schemas across all content types
const responses = operation.responses as Record<string, Record<string, unknown>> | undefined;
if (responses) {
for (const response of Object.values(responses)) {
const content = response?.content as Record<string, Record<string, unknown>> | undefined;
if (content?.['application/json']?.schema) {
content['application/json'].schema = normalizeSpec(
content['application/json'].schema as Record<string, unknown>,
spec.openapi,
);
if (content) {
for (const mediaTypeObj of Object.values(content)) {
if (mediaTypeObj?.schema) {
mediaTypeObj.schema = normalizeSpec(
mediaTypeObj.schema as Record<string, unknown>,
spec.openapi,
);
}
}
}
}
}

// Normalize request body schemas
// Normalize request body schemas across all content types
const requestBody = operation.requestBody as Record<string, unknown> | undefined;
if (requestBody) {
const content = requestBody.content as Record<string, Record<string, unknown>> | undefined;
if (content?.['application/json']?.schema) {
content['application/json'].schema = normalizeSpec(
content['application/json'].schema as Record<string, unknown>,
spec.openapi,
);
if (content) {
for (const mediaTypeObj of Object.values(content)) {
if (mediaTypeObj?.schema) {
mediaTypeObj.schema = normalizeSpec(
mediaTypeObj.schema as Record<string, unknown>,
spec.openapi,
);
}
}
}
}
}
Expand Down
Loading