import Ajv2020, { ValidateFunction } from 'ajv/dist/2020'; import spec from '@tlecommunity/api-spec/build/spec.json'; /** Applies the same widening as relaxKnownDivergences to a list of types. */ function widenTypeList(types: string[]) { const widened = new Set(types); if (widened.has('string') || widened.has('boolean')) widened.add('number'); if (widened.has('string') || widened.has('array') || widened.has('object')) widened.add('null'); return [...widened]; } /** * Widens the compiled spec, spec-wide, to tolerate a handful of systemic, * already-observed divergences between what @tlecommunity/api-spec declares * and what the live server actually sends. Each of these only ever *adds* an * accepted type/value to a schema node - it can never mask a field that's * genuinely the wrong shape, only these specific, documented divergences * (see README "Spec Discrepancies"): * * - #1: numeric-looking *string* values get turned into JS `number`s * client-side by `fixNumbers()` (lib/core/util.ts), regardless of what the * spec says the field's type is. Every `"type": "string"` node is widened * to also accept `number`. * - #3: boolean-flag fields (e.g. `buildable.*.can`) are typed `boolean` in * the spec but the server actually sends the IntBool convention (`0`/`1`) * used pervasively elsewhere in this same API. Every `"type": "boolean"` * node is widened to also accept `number`. * - #4: `reason` fields (e.g. `building.upgrade.reason`, * `building.downgrade.reason`, `buildable.*.reason`) hold either a * `[code, message]` pair or the literal number `0` for "no reason" - * inconsistently typed `array` in some places and `string` in others by * the spec (e.g. `upgrade.reason` vs `downgrade.reason`), neither of which * matches both real shapes - and `redefine_species_limits.reason` adds * `null` to the pile. Every schema node *named* `reason` is widened to * accept `array`, `number`, `string`, and `null` regardless of its * declared type. * - #5: fields of any shape (arrays, objects, and plain strings - e.g. * `building.pending_build`, `spaceport/get_fleet_for`'s `ships`, * `alliance/view_profile`'s `profile.description`) are sent as `null` * instead of `[]`/`{}`/`""` when empty/absent - a common Rails-ism the * spec doesn't mark as nullable anywhere. Every `"type": "array"`, * `"type": "object"`, or `"type": "string"` node is widened to also * accept `null`. * * The same widening applies to nodes that already declare a *list* of types * (the spec's one nullable field, `stats/empire_rank`'s `alliance_id`, is * `["string", "null"]` and arrives as a number), so a type list containing * `"string"` or `"boolean"` picks up `"number"` too. */ function relaxKnownDivergences(node: unknown, key?: string): unknown { if (Array.isArray(node)) return node.map((n) => relaxKnownDivergences(n)); if (node && typeof node === 'object') { const next: Record = {}; for (const [k, value] of Object.entries(node as Record)) { next[k] = relaxKnownDivergences(value, k); } if (key === 'reason' && typeof next.type !== 'undefined') next.type = ['array', 'number', 'string', 'null']; else if (next.type === 'string') next.type = ['string', 'number', 'null']; else if (next.type === 'boolean') next.type = ['boolean', 'number']; else if (next.type === 'array') next.type = ['array', 'null']; else if (next.type === 'object') next.type = ['object', 'null']; else if (Array.isArray(next.type)) next.type = widenTypeList(next.type as string[]); return next; } return node; } const relaxedSpec = relaxKnownDivergences(structuredClone(spec)); const ajv = new Ajv2020({ // The spec has real bugs (e.g. the `buildings_list` component's extra // wrapping `buildings` level) - strict mode would throw at *compile* time // instead of surfacing them as an ordinary, allowlistable validation error. strict: false, allErrors: true, }); // Registered under an $id so the per-path schemas below can $ref into // `#/components/schemas/...` exactly like the spec document itself does. ajv.addSchema(relaxedSpec as object, 'spec'); const escapePointerSegment = (s: string) => s.replace(/~/g, '~0').replace(/\//g, '~1'); const validators = new Map(); function getValidator(module: string, method: string): ValidateFunction { const key = `${module}/${method}`; const cached = validators.get(key); if (cached) return cached; const pathKey = `/v2/${module}/${method}`; const pathItem = (spec.paths as Record)[pathKey] as { post?: { responses?: { '200'?: unknown } } } | undefined; if (!pathItem?.post?.responses?.['200']) { throw new Error( `validate-schema: no 200 response schema for POST ${pathKey} in ` + `@tlecommunity/api-spec. Check the module/method names passed to expectMatchesApiSchema().` ); } const pointer = [ 'paths', pathKey, 'post', 'responses', '200', 'content', 'application/json', 'schema', 'properties', 'result', ] .map(escapePointerSegment) .join('/'); const validate = ajv.compile({ $ref: `spec#/${pointer}` }); validators.set(key, validate); return validate; } export interface AllowedSchemaError { /** ajv error `keyword`, e.g. 'required', 'type'. */ keyword: string; /** * ajv error `instancePath`, e.g. '' or '/body'. Array indices are * normalized to `*` on both sides before matching (e.g. `/laws/*`), so one * entry covers a divergence that repeats across every item of an * array-valued response field regardless of how many items it has. */ instancePath: string; } const normalizeInstancePath = (instancePath: string) => instancePath.replace(/\/\d+/g, '/*'); /** * Asserts that `result` (the already-fixNumbers'd `.result` from a * `lacuna..()` call) matches the response schema * `@tlecommunity/api-spec` declares for `POST /v2/{module}/{method}`. * * `module`/`method` are wire names (e.g. 'waterstorage', 'get_stats_for_level'), * matching the `url` slugs in lib/lacuna.ts and the snake_case method names * passed to `callWithSession` throughout lib/endpoints/ - not the client's * camelCase method names. * * `allowedErrors`, if given, must be already-README-documented divergences. * Each entry must match at least one real ajv error, or the assertion fails * - so a spec bug getting fixed upstream surfaces as a new failure (stale * allowlist entry) instead of silently rotting forever. */ export function expectMatchesApiSchema( module: string, method: string, result: unknown, allowedErrors: AllowedSchemaError[] = [] ): void { const validate = getValidator(module, method); if (validate(result)) { if (allowedErrors.length > 0) { throw new Error( `expectMatchesApiSchema('${module}', '${method}', ...) allowlisted ` + `${allowedErrors.length} error(s) that didn't occur. The spec bug this was ` + `working around may be fixed - remove the allowlist entry and its README note.` ); } return; } const errors = validate.errors ?? []; const unexpected = errors.filter( (e) => !allowedErrors.some( (a) => a.keyword === e.keyword && a.instancePath === normalizeInstancePath(e.instancePath) ) ); const unusedAllowances = allowedErrors.filter( (a) => !errors.some( (e) => e.keyword === a.keyword && normalizeInstancePath(e.instancePath) === a.instancePath ) ); if (unexpected.length === 0 && unusedAllowances.length === 0) return; const parts: string[] = []; if (unexpected.length > 0) { parts.push(`Unexpected schema violations:\n${JSON.stringify(unexpected, null, 2)}`); } if (unusedAllowances.length > 0) { parts.push( `Allowlisted error(s) that didn't occur (spec bug may be fixed - update README):\n` + JSON.stringify(unusedAllowances, null, 2) ); } throw new Error( `${module}/${method} response doesn't match @tlecommunity/api-spec:\n\n${parts.join('\n\n')}` + `\n\nActual response:\n${JSON.stringify(result, null, 2)}` ); }