103 lines
4.0 KiB
TypeScript
103 lines
4.0 KiB
TypeScript
import { invalid, type ObjectSchema, type ParseResult, type SchemaDescriptor } from "./index.ts";
|
|
|
|
export interface AsyncValidationContext<T> {
|
|
value: T;
|
|
addIssue(field: keyof T | string, message: string): void;
|
|
signal?: AbortSignal;
|
|
}
|
|
export type AsyncRefinement<T> = (context: AsyncValidationContext<T>) => void | Promise<void>;
|
|
|
|
export class AsyncObjectSchema<T extends object = Record<string, unknown>> {
|
|
readonly #refinements: AsyncRefinement<T>[] = [];
|
|
constructor(readonly base: ObjectSchema<T>) {}
|
|
refine(refinement: AsyncRefinement<T>): this {
|
|
this.#refinements.push(refinement);
|
|
return this;
|
|
}
|
|
describe(): SchemaDescriptor {
|
|
return this.base.describe();
|
|
}
|
|
async parse(input: unknown, signal?: AbortSignal): Promise<ParseResult<T>> {
|
|
const result = this.base.parse(input) as ParseResult<T>;
|
|
if (!result.ok) return result;
|
|
const errors: Record<string, string> = {};
|
|
const context: AsyncValidationContext<T> = {
|
|
value: result.value,
|
|
signal,
|
|
addIssue(field, message) {
|
|
if (!(String(field) in errors)) errors[String(field)] = message;
|
|
},
|
|
};
|
|
for (const refinement of this.#refinements) {
|
|
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
await refinement(context);
|
|
}
|
|
return { ok: Object.keys(errors).length === 0, value: result.value, errors };
|
|
}
|
|
}
|
|
|
|
export function asyncSchema<T extends object>(schema: ObjectSchema<T>): AsyncObjectSchema<T> {
|
|
return new AsyncObjectSchema<T>(schema);
|
|
}
|
|
|
|
export async function parseBodyAsync<T extends object>(
|
|
schema: AsyncObjectSchema<T>,
|
|
request: Request,
|
|
signal?: AbortSignal,
|
|
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
|
|
let body: Record<string, unknown> = {};
|
|
const contentType = request.headers.get("content-type") ?? "";
|
|
try {
|
|
if (contentType.includes("json")) body = (await request.json()) as Record<string, unknown>;
|
|
else {
|
|
const data = await request.formData();
|
|
for (const [key, value] of data) body[key] = value;
|
|
}
|
|
} catch {
|
|
/* handled by schema */
|
|
}
|
|
const result = await schema.parse(body, signal);
|
|
return result.ok
|
|
? { ok: true, value: result.value }
|
|
: { ok: false, response: invalid(result.errors) };
|
|
}
|
|
|
|
export interface OpenApiSchema {
|
|
type: "object";
|
|
properties: Record<string, Record<string, unknown>>;
|
|
required?: string[];
|
|
}
|
|
export function schemaToOpenApi(schema: ObjectSchema | AsyncObjectSchema): OpenApiSchema {
|
|
const descriptor = schema.describe();
|
|
const required: string[] = [];
|
|
const properties: Record<string, Record<string, unknown>> = {};
|
|
for (const [name, field] of Object.entries(descriptor.fields)) {
|
|
const property: Record<string, unknown> = {
|
|
type: field.type === "boolean" ? "boolean" : field.type,
|
|
};
|
|
if (field.label) property.title = field.label;
|
|
for (const rule of field.rules) {
|
|
if (rule.kind === "min") property[field.type === "string" ? "minLength" : "minimum"] = rule.n;
|
|
else if (rule.kind === "max")
|
|
property[field.type === "string" ? "maxLength" : "maximum"] = rule.n;
|
|
else if (rule.kind === "length") property.minLength = property.maxLength = rule.n;
|
|
else if (["email", "url", "uuid", "date"].includes(rule.kind))
|
|
property.format = rule.kind === "url" ? "uri" : rule.kind;
|
|
else if (rule.kind === "oneOf") property.enum = rule.values;
|
|
else if (rule.kind === "pattern") property.pattern = rule.source;
|
|
else if (rule.kind === "integer") property.type = "integer";
|
|
}
|
|
properties[name] = property;
|
|
if (!field.optional) required.push(name);
|
|
}
|
|
return { type: "object", properties, ...(required.length ? { required } : {}) };
|
|
}
|
|
|
|
export function mergeValidationResults<T>(...results: ParseResult<T>[]): ParseResult<T> {
|
|
return {
|
|
ok: results.every((result) => result.ok),
|
|
value: Object.assign({}, ...results.map((result) => result.value)) as T,
|
|
errors: Object.assign({}, ...results.map((result) => result.errors)),
|
|
};
|
|
}
|