feat: validate HTTP envelopes and payload schemas
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const metaSchema = z
|
||||
.object({
|
||||
requestId: z.string().min(1),
|
||||
traceId: z.string().min(1),
|
||||
correlationId: z.string().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const successEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
data: z.unknown(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const failureEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(false),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string().min(1),
|
||||
category: z.string().min(1).optional(),
|
||||
message: z.string().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
details: z.unknown().optional(),
|
||||
})
|
||||
.strict(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
|
||||
successEnvelopeSchema,
|
||||
failureEnvelopeSchema,
|
||||
]);
|
||||
|
||||
const sampleResourceSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
createdAt: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const payloadSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
|
||||
SampleResourceListPayload: z.array(sampleResourceSchema),
|
||||
SampleResourcePayload: sampleResourceSchema,
|
||||
}));
|
||||
|
||||
const requestSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
|
||||
SampleResourceListQuery: z
|
||||
.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.int().min(1).max(100).default(20),
|
||||
})
|
||||
.strict(),
|
||||
CreateSampleResourceCommand: z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
.strict(),
|
||||
}));
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function validateEnvelope(value) {
|
||||
return projectResult(responseEnvelopeSchema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId @param {unknown} value */
|
||||
export function validateOperationPayload(schemaId, value) {
|
||||
const schema = payloadSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId @param {unknown} value */
|
||||
export function validateOperationRequest(schemaId, value) {
|
||||
const schema = requestSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId */
|
||||
function missingSchema(schemaId) {
|
||||
return {
|
||||
success: /** @type {false} */ (false),
|
||||
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ success: true, data: unknown } |
|
||||
* { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result
|
||||
*/
|
||||
function projectResult(result) {
|
||||
if (result.success) {
|
||||
return {
|
||||
success: /** @type {true} */ (true),
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: /** @type {false} */ (false),
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user