1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124 | /**
* Validate a parsed API response against the OpenAPI schema the committed
* `spec/openapi.json` declares for it.
*
* Deliberately small: it checks the properties that actually catch drift
* between the spec and what the API returns — declared types, `required`
* keys, `enum`/`const` membership, `oneOf` alternatives, and — because
* every object in this spec is `additionalProperties: false` — fields the
* server sends that the schema does not list. Anything it does not
* understand it passes.
*/
import type { MethodKey } from './types.js';
export type Schema = Readonly<{
type?: string | readonly string[];
properties?: Readonly<Record<string, Schema>>;
required?: readonly string[];
items?: Schema;
enum?: readonly unknown[];
const?: unknown;
oneOf?: readonly Schema[];
additionalProperties?: boolean | Schema;
}>;
type MediaTypes = Readonly<Record<string, Readonly<{ schema?: Schema }>>>;
type Responses = Readonly<Record<string, Readonly<{ content?: MediaTypes }>>>;
export type OpenApiSpec = Readonly<{
paths?: Readonly<Record<string, Readonly<Partial<Record<MethodKey, Readonly<{ responses?: Responses }>>>>>>;
}>;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** How VALUE would be spelled as an OpenAPI type, for problem messages. */
const typeName = (value: unknown): string => {
if (value === null || value === undefined) return 'null';
if (Array.isArray(value)) return 'array';
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
return typeof value;
};
const typeMatches = (declared: string, value: unknown): boolean => {
switch (declared) {
case 'object':
return isObject(value);
case 'array':
return Array.isArray(value);
case 'string':
return typeof value === 'string';
case 'integer':
return typeof value === 'number' && Number.isInteger(value);
case 'number':
return typeof value === 'number';
case 'boolean':
return typeof value === 'boolean';
case 'null':
return value === null || value === undefined;
default:
return true;
}
};
const show = (value: unknown): string => JSON.stringify(value) ?? String(value);
const checkNode = (schema: Schema, value: unknown, path: string): readonly string[] => {
const { type, properties, required, items, oneOf, additionalProperties } = schema;
const allowed = schema.enum ?? ('const' in schema ? [schema.const] : undefined);
const types = type === undefined ? undefined : typeof type === 'string' ? [type] : type;
if (oneOf) {
return oneOf.some((alternative) => check(alternative, value, path).length === 0)
? []
: [`${path} matches none of oneOf ${show(oneOf.map((a) => a.type))} — got ${typeName(value)}`];
}
if (allowed && !allowed.includes(value)) {
return [`${path} = ${show(value)} is not in the declared enum ${show(allowed)}`];
}
if (types && !types.some((t) => typeMatches(t, value))) {
return [`${path} is declared ${types.join('|')} but the API returned ${typeName(value)}`];
}
const problems: string[] = [];
const object = types?.includes('object') && isObject(value) ? value : undefined;
if (object) {
for (const key of required ?? []) {
if (!(key in object)) problems.push(`${path}.${key} is required by the spec but absent from the response`);
}
// the inverse, and the one that finds under-documented responses:
// additionalProperties:false makes an unlisted field a violation
if (additionalProperties === false && properties) {
for (const key of Object.keys(object)) {
if (!(key in properties)) problems.push(`${path}.${key} is returned by the API but absent from the spec`);
}
}
}
if (properties && isObject(value)) {
for (const [name, propertySchema] of Object.entries(properties)) {
if (name in value) problems.push(...check(propertySchema, value[name], `${path}.${name}`));
}
}
// every row, never a sample: a value that appears only in rare rows (an
// uncommon action type, say) is exactly the one a schema omits
if (items && Array.isArray(value)) {
value.forEach((row, i) => problems.push(...check(items, row, `${path}[${i}]`)));
}
return problems;
};
/**
* Problems found validating VALUE against SCHEMA, as human-readable
* strings; empty when the response conforms. PATH prefixes each message so
* a problem can be located in a nested body.
*/
export const check = (schema: Schema | undefined, value: unknown, path = ''): readonly string[] =>
schema === undefined || Object.keys(schema).length === 0 ? [] : checkNode(schema, value, path);
/** The schema SPEC declares for METHOD PATH's STATUS response, or undefined when it documents no JSON body. */
export const responseSchema = (
spec: OpenApiSpec,
method: MethodKey,
path: string,
status: number,
): Schema | undefined => spec.paths?.[path]?.[method]?.responses?.[String(status)]?.content?.['application/json']?.schema;
|