ts / src / spec-check.ts
665 words
  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;

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/ts.git