You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
54 lines
3.0 KiB
54 lines
3.0 KiB
import { readFile } from 'node:fs/promises';
|
|
import { z } from 'zod';
|
|
|
|
const schema = JSON.parse(await readFile(new URL('schema.json', import.meta.url), 'utf8'));
|
|
const validators = new Map<string, z.ZodType>();
|
|
|
|
// Contract tooling. Browser/native SDKs need their own bounded decoder.
|
|
export function validateModel(name: string, value: unknown): unknown {
|
|
if (!(name in schema.$defs)) throw new Error(`Unknown shared model: ${name}`);
|
|
let validator = validators.get(name);
|
|
if (!validator) {
|
|
validator = z.fromJSONSchema({ ...schema, $ref: `#/$defs/${name}` });
|
|
validators.set(name, validator);
|
|
}
|
|
const result = validator.parse(value);
|
|
validateRelations(value, name);
|
|
return result;
|
|
}
|
|
|
|
function validateRelations(value: unknown, name: string): void {
|
|
if (!value || typeof value !== 'object') return;
|
|
const object = value as Record<string, unknown>;
|
|
if (name === 'ErrorData') {
|
|
const recovery = object.recovery as Record<string, unknown>;
|
|
if (object.outcome === 'unknown' && recovery.action !== 'resolve-pending')
|
|
throw new Error('Unknown outcome must resolve the pending operation');
|
|
if (object.outcome === 'rejected' && recovery.action === 'retry-same-request')
|
|
throw new Error('A confirmed rejection must not automatically retry');
|
|
if ('retryAfterMs' in recovery && !['retry-same-request', 'resolve-pending'].includes(String(recovery.action)))
|
|
throw new Error('Retry delay requires a retry/status recovery');
|
|
}
|
|
if (name === 'CommandRejection' && ((object.error as Record<string, unknown>).outcome !== 'rejected' || (object.error as Record<string, unknown>).origin !== 'server'))
|
|
throw new Error('A rejection receipt needs a confirmed server rejection');
|
|
if (name === 'GameCard' && Number(object.minPlayers) > Number(object.maxPlayers))
|
|
throw new Error('Player limits are reversed');
|
|
if (name === 'HttpProblem' || name === 'ErrorMessagePayload') {
|
|
const error = object.error as Record<string, unknown>;
|
|
const correlation = object.correlation as Record<string, unknown>;
|
|
if (error.outcome === 'unknown' && !correlation.commandId && !correlation.operationId)
|
|
throw new Error('Unknown outcome needs an operation/command reference');
|
|
if ('status' in object && error.origin !== 'server')
|
|
throw new Error('An HTTP problem must carry a server-origin error');
|
|
if ('type' in object && String(object.type) !== `urn:juegoland:problem:${String(error.code).toLowerCase().replaceAll('_', '-')}`)
|
|
throw new Error('Problem type/code mismatch');
|
|
}
|
|
// Ignore optional fields unknown to this version; do not interpret their names.
|
|
for (const [key, child] of Object.entries(schema.$defs[name].properties ?? {}) as [string, { $ref?: string; type?: string; items?: { $ref?: string } }][]) {
|
|
if (child.$ref) validateRelations(object[key], child.$ref.split('/').at(-1)!);
|
|
if (child.type === 'array' && child.items?.$ref) {
|
|
for (const item of object[key] as unknown[]) validateRelations(item, child.items.$ref.split('/').at(-1)!);
|
|
}
|
|
}
|
|
}
|