release: WRNexusJS 0.8.0
This commit is contained in:
@@ -24,6 +24,8 @@ export interface FieldDescriptor {
|
||||
optional?: boolean;
|
||||
/** Message used when a required field is empty. Defaults to "Required". */
|
||||
requiredMessage?: string;
|
||||
/** Message used when coercion to the declared type fails. */
|
||||
typeMessage?: string;
|
||||
label?: string;
|
||||
/** Trim string input before validating. */
|
||||
trim?: boolean;
|
||||
@@ -131,7 +133,7 @@ export function checkField(
|
||||
let value: unknown;
|
||||
if (desc.type === "number") {
|
||||
value = Number(pre);
|
||||
if (Number.isNaN(value)) return { value, error: "Must be a number" };
|
||||
if (Number.isNaN(value)) return { value, error: desc.typeMessage ?? "Must be a number" };
|
||||
} else {
|
||||
value = String(pre);
|
||||
}
|
||||
@@ -214,7 +216,9 @@ export abstract class FieldSchema {
|
||||
}
|
||||
}
|
||||
|
||||
export class StringSchema extends FieldSchema {
|
||||
export class StringSchema<TValue extends string = string> extends FieldSchema {
|
||||
/** Type-only marker used to preserve literal unions through schema inference. */
|
||||
declare readonly __value: TValue;
|
||||
readonly type = "string" as const;
|
||||
private _trim = false;
|
||||
email(message?: string): this {
|
||||
@@ -237,9 +241,14 @@ export class StringSchema extends FieldSchema {
|
||||
this.rules.push({ kind: "length", n, message });
|
||||
return this;
|
||||
}
|
||||
oneOf(values: string[], message?: string): this {
|
||||
this.rules.push({ kind: "oneOf", values, message });
|
||||
return this;
|
||||
oneOf<const TValues extends readonly string[]>(
|
||||
values: TValues,
|
||||
message?: string,
|
||||
): StringSchema<TValues extends readonly [string, ...string[]] ? TValues[number] : TValue> {
|
||||
this.rules.push({ kind: "oneOf", values: [...values], message });
|
||||
return this as unknown as StringSchema<
|
||||
TValues extends readonly [string, ...string[]] ? TValues[number] : TValue
|
||||
>;
|
||||
}
|
||||
trim(): this {
|
||||
this._trim = true;
|
||||
@@ -254,7 +263,9 @@ export class StringSchema extends FieldSchema {
|
||||
}
|
||||
}
|
||||
|
||||
export class NumberSchema extends FieldSchema {
|
||||
export class NumberSchema<TValue extends number = number> extends FieldSchema {
|
||||
/** Type-only marker used to preserve numeric literal unions through schema inference. */
|
||||
declare readonly __value: TValue;
|
||||
readonly type = "number" as const;
|
||||
integer(message?: string): this {
|
||||
this.rules.push({ kind: "integer", message });
|
||||
@@ -264,9 +275,14 @@ export class NumberSchema extends FieldSchema {
|
||||
this.rules.push({ kind: "min", n: Number.MIN_VALUE, message: message ?? "Must be positive" });
|
||||
return this;
|
||||
}
|
||||
oneOf(values: number[], message?: string): this {
|
||||
this.rules.push({ kind: "oneOf", values, message });
|
||||
return this;
|
||||
oneOf<const TValues extends readonly number[]>(
|
||||
values: TValues,
|
||||
message?: string,
|
||||
): NumberSchema<TValues extends readonly [number, ...number[]] ? TValues[number] : TValue> {
|
||||
this.rules.push({ kind: "oneOf", values: [...values], message });
|
||||
return this as unknown as NumberSchema<
|
||||
TValues extends readonly [number, ...number[]] ? TValues[number] : TValue
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +294,32 @@ export class UnknownSchema extends FieldSchema {
|
||||
readonly type = "unknown" as const;
|
||||
}
|
||||
|
||||
export type AnyFieldSchema = StringSchema | NumberSchema | BooleanSchema | UnknownSchema;
|
||||
export type AnyFieldSchema =
|
||||
StringSchema<string> | NumberSchema<number> | BooleanSchema | UnknownSchema;
|
||||
|
||||
/** Infer the runtime value produced by a field schema. */
|
||||
export type InferFieldValue<TField extends FieldSchema> =
|
||||
TField extends StringSchema<infer TValue>
|
||||
? TValue
|
||||
: TField extends NumberSchema<infer TValue>
|
||||
? TValue
|
||||
: TField extends BooleanSchema
|
||||
? boolean
|
||||
: unknown;
|
||||
|
||||
/** Infer the validated object produced by a field map. */
|
||||
export type InferObjectFields<TFields extends Record<string, FieldSchema>> = {
|
||||
[K in keyof TFields]: InferFieldValue<TFields[K]>;
|
||||
};
|
||||
|
||||
/** Infer the object value produced by an object schema. */
|
||||
export type InferSchema<TSchema extends ObjectSchema> =
|
||||
TSchema extends ObjectSchema<infer TValue> ? TValue : never;
|
||||
|
||||
export class ObjectSchema<TValue extends object = Record<string, unknown>> {
|
||||
/** Type-only marker used by helper functions to infer validated output. */
|
||||
declare readonly __output: TValue;
|
||||
|
||||
export class ObjectSchema {
|
||||
constructor(private readonly fields: Record<string, FieldSchema>) {}
|
||||
|
||||
/** Return a defensive copy of the schema fields. */
|
||||
@@ -289,17 +328,19 @@ export class ObjectSchema {
|
||||
}
|
||||
|
||||
/** Create a new schema with fields added or replaced. The original is unchanged. */
|
||||
extend(fields: Record<string, FieldSchema>): ObjectSchema {
|
||||
extend<TFields extends Record<string, FieldSchema>>(
|
||||
fields: TFields,
|
||||
): ObjectSchema<Omit<TValue, keyof TFields> & InferObjectFields<TFields>> {
|
||||
return new ObjectSchema({ ...this.fields, ...fields });
|
||||
}
|
||||
|
||||
/** Create a new schema containing fields from both schemas. */
|
||||
merge(schema: ObjectSchema): ObjectSchema {
|
||||
merge<TOther extends object>(schema: ObjectSchema<TOther>): ObjectSchema<TValue & TOther> {
|
||||
return new ObjectSchema({ ...this.fields, ...schema.getFields() });
|
||||
}
|
||||
|
||||
/** Validate an input object; returns coerced values + per-field errors. */
|
||||
parse(input: unknown): ParseResult {
|
||||
parse(input: unknown): ParseResult<TValue> {
|
||||
const source = (input ?? {}) as Record<string, unknown>;
|
||||
const value: Record<string, unknown> = {};
|
||||
const errors: Record<string, string> = {};
|
||||
@@ -323,7 +364,7 @@ export class ObjectSchema {
|
||||
value[name] = finalValue;
|
||||
}
|
||||
}
|
||||
return { ok: Object.keys(errors).length === 0, value, errors };
|
||||
return { ok: Object.keys(errors).length === 0, value: value as TValue, errors };
|
||||
}
|
||||
|
||||
describe(): SchemaDescriptor {
|
||||
@@ -335,11 +376,12 @@ export class ObjectSchema {
|
||||
|
||||
/** The fluent schema builder. */
|
||||
export const v = {
|
||||
string: () => new StringSchema(),
|
||||
number: () => new NumberSchema(),
|
||||
string: () => new StringSchema<string>(),
|
||||
number: () => new NumberSchema<number>(),
|
||||
boolean: () => new BooleanSchema(),
|
||||
unknown: () => new UnknownSchema(),
|
||||
object: (fields: Record<string, FieldSchema>) => new ObjectSchema(fields),
|
||||
object: <TFields extends Record<string, FieldSchema>>(fields: TFields) =>
|
||||
new ObjectSchema<InferObjectFields<TFields>>(fields),
|
||||
};
|
||||
|
||||
// --- Environment configuration --------------------------------------------
|
||||
@@ -364,8 +406,8 @@ function readEnv(): Record<string, string | undefined> {
|
||||
* PORT: v.number(),
|
||||
* }));
|
||||
*/
|
||||
export function parseEnv<T = Record<string, unknown>>(
|
||||
schema: ObjectSchema,
|
||||
export function parseEnv<T extends object>(
|
||||
schema: ObjectSchema<T>,
|
||||
source: Record<string, string | undefined> = readEnv(),
|
||||
): T {
|
||||
const result = schema.parse(source);
|
||||
@@ -373,7 +415,7 @@ export function parseEnv<T = Record<string, unknown>>(
|
||||
const lines = Object.entries(result.errors).map(([name, message]) => ` • ${name}: ${message}`);
|
||||
throw new Error(`Invalid environment variables:\n${lines.join("\n")}`);
|
||||
}
|
||||
return result.value as T;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
// --- API helpers -----------------------------------------------------------
|
||||
@@ -387,14 +429,14 @@ export function invalid(errors: Record<string, string>): Response {
|
||||
* Parse a request's JSON body against a schema. On failure returns
|
||||
* `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`.
|
||||
*/
|
||||
export async function parseBody<T = Record<string, unknown>>(
|
||||
schema: ObjectSchema,
|
||||
export async function parseBody<T extends object>(
|
||||
schema: ObjectSchema<T>,
|
||||
req: Request,
|
||||
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
|
||||
const body = await readBody(req);
|
||||
const result = schema.parse(body);
|
||||
if (!result.ok) return { ok: false, response: invalid(result.errors) };
|
||||
return { ok: true, value: result.value as T };
|
||||
return { ok: true, value: result.value };
|
||||
}
|
||||
|
||||
/** Read a request body as an object from JSON, form-urlencoded, or multipart. */
|
||||
@@ -428,3 +470,37 @@ export {
|
||||
mergeValidationResults,
|
||||
} from "./advanced.ts";
|
||||
export type { AsyncValidationContext, AsyncRefinement, OpenApiSchema } from "./advanced.ts";
|
||||
export {
|
||||
ValidationError,
|
||||
parseOrThrow,
|
||||
validationResponse,
|
||||
firstValidationError,
|
||||
validationSummary,
|
||||
schemaFieldNames,
|
||||
} from "./helpers.ts";
|
||||
export { validationPlugin, validationComponentsDir } from "./plugin.ts";
|
||||
export type { ValidationPluginOptions } from "./plugin.ts";
|
||||
export {
|
||||
toJsonSchema,
|
||||
openApiRequestBody,
|
||||
localizeDescriptor,
|
||||
parseDescriptor,
|
||||
} from "./interop.ts";
|
||||
export type {
|
||||
JsonSchemaDocument,
|
||||
ValidationMessageKey,
|
||||
ValidationMessageTranslator,
|
||||
} from "./interop.ts";
|
||||
export {
|
||||
ContractRegistry,
|
||||
checkContractCompatibility,
|
||||
defineContract,
|
||||
defineEvent,
|
||||
} from "./contracts.ts";
|
||||
export type {
|
||||
ContractDefinition,
|
||||
ContractIssue,
|
||||
ContractKind,
|
||||
ContractRecord,
|
||||
ContractSnapshot,
|
||||
} from "./contracts.ts";
|
||||
|
||||
Reference in New Issue
Block a user