release: WRNexusJS 0.8.0
This commit is contained in:
@@ -4,6 +4,31 @@
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Boundary contracts
|
||||
|
||||
Use `ContractRegistry` with `defineContract` or `defineEvent` to publish the
|
||||
same schema descriptors for APIs, actions, webhooks, realtime, queues, cron,
|
||||
pub/sub, plugins, configuration, and environment variables.
|
||||
|
||||
```ts
|
||||
import { ContractRegistry, defineEvent, v } from "@wrnexus/validation";
|
||||
|
||||
export const contracts = new ContractRegistry().register(
|
||||
defineEvent({
|
||||
name: "user.created",
|
||||
version: 1,
|
||||
consumers: ["notification-worker", "audit-service"],
|
||||
payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }),
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Export the registry from `app/contracts.ts`, then accept a baseline with
|
||||
`wrnexus contracts snapshot`. CI can run `wrnexus contracts check`; removed
|
||||
contracts/fields, required-field additions, type changes, narrowed enums, and
|
||||
tighter validation fail with stable `WRN-CONTRACT-*` diagnostics and list known
|
||||
consumers. A generated `wrnexus.contracts.json` can be used instead of a module.
|
||||
|
||||
## Overview
|
||||
|
||||
Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`.
|
||||
@@ -178,3 +203,55 @@ const head = `<script>${renderSchemasScript({ signup: signupSchema.describe() })
|
||||
- Refinements (`refine`) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same `RuleDescriptor` list.
|
||||
- No runtime dependencies. Ships as TypeScript source (`src/index.ts`) executed directly by Bun.
|
||||
- Pairs with the WrNexus server (`@wrnexus/core`) for route handlers and the SSR layer that injects `renderSchemasScript` / `VALIDATE_RUNTIME`.
|
||||
|
||||
## Helper and component kit
|
||||
|
||||
The public helper API includes `parseOrThrow`, `ValidationError`, `validationResponse`, `firstValidationError`, `validationSummary`, and `schemaFieldNames`.
|
||||
|
||||
Schema output is inferred automatically by `ObjectSchema`, `parseOrThrow`, `parseBody`, `parseEnv`, and `asyncSchema`. Use `InferSchema<typeof schema>` when a named output type is useful:
|
||||
|
||||
```ts
|
||||
const accountSchema = v.object({
|
||||
email: v.string().email(),
|
||||
attempts: v.number().integer(),
|
||||
});
|
||||
|
||||
type AccountInput = InferSchema<typeof accountSchema>;
|
||||
const account = parseOrThrow(accountSchema, input);
|
||||
// account.email: string
|
||||
// account.attempts: number
|
||||
```
|
||||
|
||||
Enable `validationPlugin()` for:
|
||||
|
||||
- `<ValidationSummary />`
|
||||
- `<FieldError />`
|
||||
|
||||
The summary block composes `Alert` from `@wrnexus/ui`, while `FieldError` remains a lightweight accessible field-level primitive.
|
||||
Schemas can drive external contracts without maintaining a second definition:
|
||||
|
||||
```ts
|
||||
import {
|
||||
localizeDescriptor,
|
||||
openApiRequestBody,
|
||||
parseDescriptor,
|
||||
toJsonSchema,
|
||||
} from "@wrnexus/validation";
|
||||
|
||||
const jsonSchema = toJsonSchema(contactSchema, {
|
||||
id: "urn:example:contact",
|
||||
title: "Contact request",
|
||||
});
|
||||
const requestBody = openApiRequestBody(contactSchema);
|
||||
|
||||
const mr = localizeDescriptor(contactSchema, (key, params) =>
|
||||
translations.t(`validation.${key}`, params),
|
||||
);
|
||||
const result = parseDescriptor(mr, input);
|
||||
```
|
||||
|
||||
JSON Schema output targets draft 2020-12, closes unknown object properties, and
|
||||
maps lengths/ranges/formats/enums/patterns/integer rules. OpenAPI request bodies
|
||||
reuse the same properties. Localized descriptors preserve explicit custom
|
||||
messages and fill default required, type-coercion, and rule messages; the same
|
||||
descriptor is consumable by server parsing and the eval-free browser runtime.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
component FieldError {
|
||||
props {
|
||||
field: string = ""
|
||||
message: string = ""
|
||||
color: string = "danger"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<p {...attrs} id='{field ? field + "-error" : ""}' data-error='{field}' role="alert" hidden='{!message}' class='m-0 min-h-4 text-xs text-[var(--wire-color-danger)] {class}'>{message}</p>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
component ValidationSummary {
|
||||
props {
|
||||
errors: unknown[] = []
|
||||
title: string = "Please correct the following"
|
||||
color: string = "danger"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
{#if errors.length > 0}
|
||||
<Alert {...attrs} title='{title}' icon="icon-[lucide--circle-alert]" color='{color}' size='{size}' variant="soft" class='{class}'>
|
||||
<ul class="m-0 mt-2 list-disc space-y-1 pl-5 text-sm">
|
||||
{#each errors as error}<li><a href='#{error.field}' class="underline underline-offset-2">{error.message}</a></li>{/each}
|
||||
</ul>
|
||||
</Alert>
|
||||
{/if}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,36 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./plugin": "./src/plugin.ts",
|
||||
"./components/*": "./components/*"
|
||||
},
|
||||
"description": "Shared server/browser schemas, validation helpers, form runtime, and reusable error components.",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"components",
|
||||
"README.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./src/plugin.ts",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ export interface AsyncValidationContext<T> {
|
||||
}
|
||||
export type AsyncRefinement<T> = (context: AsyncValidationContext<T>) => void | Promise<void>;
|
||||
|
||||
export class AsyncObjectSchema<T extends Record<string, unknown> = Record<string, unknown>> {
|
||||
export class AsyncObjectSchema<T extends object = Record<string, unknown>> {
|
||||
readonly #refinements: AsyncRefinement<T>[] = [];
|
||||
constructor(readonly base: ObjectSchema) {}
|
||||
constructor(readonly base: ObjectSchema<T>) {}
|
||||
refine(refinement: AsyncRefinement<T>): this {
|
||||
this.#refinements.push(refinement);
|
||||
return this;
|
||||
@@ -36,13 +36,11 @@ export class AsyncObjectSchema<T extends Record<string, unknown> = Record<string
|
||||
}
|
||||
}
|
||||
|
||||
export function asyncSchema<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
schema: ObjectSchema,
|
||||
): AsyncObjectSchema<T> {
|
||||
export function asyncSchema<T extends object>(schema: ObjectSchema<T>): AsyncObjectSchema<T> {
|
||||
return new AsyncObjectSchema<T>(schema);
|
||||
}
|
||||
|
||||
export async function parseBodyAsync<T extends Record<string, unknown>>(
|
||||
export async function parseBodyAsync<T extends object>(
|
||||
schema: AsyncObjectSchema<T>,
|
||||
request: Request,
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { ObjectSchema, SchemaDescriptor } from "./index.ts";
|
||||
|
||||
export type ContractKind =
|
||||
| "api"
|
||||
| "action"
|
||||
| "webhook"
|
||||
| "realtime"
|
||||
| "queue"
|
||||
| "cron"
|
||||
| "pubsub"
|
||||
| "plugin"
|
||||
| "config"
|
||||
| "env";
|
||||
|
||||
export interface ContractDefinition<T extends object = Record<string, unknown>> {
|
||||
kind: ContractKind;
|
||||
name: string;
|
||||
version: number;
|
||||
payload: ObjectSchema<T> | SchemaDescriptor;
|
||||
consumers?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ContractRecord {
|
||||
kind: ContractKind;
|
||||
name: string;
|
||||
version: number;
|
||||
payload: SchemaDescriptor;
|
||||
consumers: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ContractSnapshot {
|
||||
format: 1;
|
||||
contracts: ContractRecord[];
|
||||
}
|
||||
|
||||
export interface ContractIssue {
|
||||
code:
|
||||
| "WRN-CONTRACT-REMOVED"
|
||||
| "WRN-CONTRACT-FIELD-REMOVED"
|
||||
| "WRN-CONTRACT-FIELD-REQUIRED"
|
||||
| "WRN-CONTRACT-FIELD-TYPE"
|
||||
| "WRN-CONTRACT-RULE-TIGHTENED";
|
||||
contract: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
consumers: string[];
|
||||
}
|
||||
|
||||
function isDescriptor(value: ObjectSchema<object> | SchemaDescriptor): value is SchemaDescriptor {
|
||||
return "type" in value && value.type === "object" && "fields" in value;
|
||||
}
|
||||
|
||||
function keyOf(contract: Pick<ContractRecord, "kind" | "name" | "version">): string {
|
||||
return `${contract.kind}:${contract.name}@${contract.version}`;
|
||||
}
|
||||
|
||||
export function defineContract<T extends object>(
|
||||
definition: ContractDefinition<T>,
|
||||
): ContractDefinition<T> {
|
||||
if (!definition.name.trim()) throw new TypeError("Contract name is required.");
|
||||
if (!Number.isInteger(definition.version) || definition.version < 1) {
|
||||
throw new TypeError("Contract version must be a positive integer.");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function defineEvent<T extends object>(
|
||||
definition: Omit<ContractDefinition<T>, "kind"> & { kind?: "realtime" | "pubsub" },
|
||||
): ContractDefinition<T> {
|
||||
return defineContract({ ...definition, kind: definition.kind ?? "pubsub" });
|
||||
}
|
||||
|
||||
export class ContractRegistry {
|
||||
private readonly records = new Map<string, ContractRecord>();
|
||||
|
||||
register<T extends object>(definition: ContractDefinition<T>): this {
|
||||
const valid = defineContract(definition);
|
||||
const record: ContractRecord = {
|
||||
kind: valid.kind,
|
||||
name: valid.name,
|
||||
version: valid.version,
|
||||
payload: isDescriptor(valid.payload)
|
||||
? structuredClone(valid.payload)
|
||||
: valid.payload.describe(),
|
||||
consumers: [...new Set(valid.consumers ?? [])].sort(),
|
||||
description: valid.description,
|
||||
};
|
||||
const key = keyOf(record);
|
||||
if (this.records.has(key)) throw new Error(`Duplicate contract: ${key}`);
|
||||
this.records.set(key, record);
|
||||
return this;
|
||||
}
|
||||
|
||||
snapshot(): ContractSnapshot {
|
||||
return {
|
||||
format: 1,
|
||||
contracts: [...this.records.values()]
|
||||
.map((record) => structuredClone(record))
|
||||
.sort((left, right) => keyOf(left).localeCompare(keyOf(right))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ruleStrength(
|
||||
rule: SchemaDescriptor["fields"][string]["rules"][number],
|
||||
): number | undefined {
|
||||
if (rule.kind === "min" || rule.kind === "length") return rule.n;
|
||||
if (rule.kind === "max") return -rule.n;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function checkContractCompatibility(
|
||||
previous: ContractSnapshot,
|
||||
current: ContractSnapshot,
|
||||
): ContractIssue[] {
|
||||
const now = new Map(current.contracts.map((contract) => [keyOf(contract), contract]));
|
||||
const issues: ContractIssue[] = [];
|
||||
for (const before of previous.contracts) {
|
||||
const contract = keyOf(before);
|
||||
const after = now.get(contract);
|
||||
if (!after) {
|
||||
issues.push({
|
||||
code: "WRN-CONTRACT-REMOVED",
|
||||
contract,
|
||||
message: `Contract removed: ${contract}`,
|
||||
consumers: before.consumers,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const [field, oldField] of Object.entries(before.payload.fields)) {
|
||||
const newField = after.payload.fields[field];
|
||||
if (!newField) {
|
||||
issues.push({
|
||||
code: "WRN-CONTRACT-FIELD-REMOVED",
|
||||
contract,
|
||||
field,
|
||||
message: `Field removed: ${field}`,
|
||||
consumers: before.consumers,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (oldField.type !== newField.type) {
|
||||
issues.push({
|
||||
code: "WRN-CONTRACT-FIELD-TYPE",
|
||||
contract,
|
||||
field,
|
||||
message: `Field type changed: ${field} (${oldField.type} -> ${newField.type})`,
|
||||
consumers: before.consumers,
|
||||
});
|
||||
}
|
||||
if (oldField.optional && !newField.optional) {
|
||||
issues.push({
|
||||
code: "WRN-CONTRACT-FIELD-REQUIRED",
|
||||
contract,
|
||||
field,
|
||||
message: `Optional field became required: ${field}`,
|
||||
consumers: before.consumers,
|
||||
});
|
||||
}
|
||||
for (const newRule of newField.rules) {
|
||||
const oldRule = oldField.rules.find((rule) => rule.kind === newRule.kind);
|
||||
const newStrength = ruleStrength(newRule);
|
||||
const oldStrength = oldRule && ruleStrength(oldRule);
|
||||
const enumNarrowed =
|
||||
newRule.kind === "oneOf" &&
|
||||
oldRule?.kind === "oneOf" &&
|
||||
oldRule.values.some((value) => !newRule.values.includes(value));
|
||||
const constraintAdded =
|
||||
!oldRule &&
|
||||
[
|
||||
"email",
|
||||
"url",
|
||||
"uuid",
|
||||
"date",
|
||||
"pattern",
|
||||
"integer",
|
||||
"min",
|
||||
"max",
|
||||
"length",
|
||||
"oneOf",
|
||||
].includes(newRule.kind);
|
||||
if (
|
||||
constraintAdded ||
|
||||
enumNarrowed ||
|
||||
(newStrength !== undefined && oldStrength !== undefined && newStrength > oldStrength)
|
||||
) {
|
||||
issues.push({
|
||||
code: "WRN-CONTRACT-RULE-TIGHTENED",
|
||||
contract,
|
||||
field,
|
||||
message: `Validation tightened: ${field}.${newRule.kind}`,
|
||||
consumers: before.consumers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [field, descriptor] of Object.entries(after.payload.fields)) {
|
||||
if (!before.payload.fields[field] && !descriptor.optional) {
|
||||
issues.push({
|
||||
code: "WRN-CONTRACT-FIELD-REQUIRED",
|
||||
contract,
|
||||
field,
|
||||
message: `Required field added: ${field}`,
|
||||
consumers: before.consumers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ObjectSchema, ParseResult, SchemaDescriptor } from "./index.ts";
|
||||
|
||||
export class ValidationError<T = Record<string, unknown>> extends Error {
|
||||
constructor(public readonly result: ParseResult<T>) {
|
||||
super("Validation failed");
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOrThrow<T extends object>(schema: ObjectSchema<T>, input: unknown): T {
|
||||
const result = schema.parse(input);
|
||||
if (!result.ok) throw new ValidationError(result);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function validationResponse(
|
||||
result: ParseResult,
|
||||
options: { successStatus?: number; failureStatus?: number } = {},
|
||||
): Response {
|
||||
return Response.json(
|
||||
result.ok
|
||||
? { ok: true, value: result.value }
|
||||
: { ok: false, errors: result.errors, value: result.value },
|
||||
{
|
||||
status: result.ok ? (options.successStatus ?? 200) : (options.failureStatus ?? 422),
|
||||
headers: { "cache-control": "no-store" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function firstValidationError(errors: Record<string, string>): string | null {
|
||||
return Object.values(errors).find(Boolean) ?? null;
|
||||
}
|
||||
|
||||
export function validationSummary(
|
||||
errors: Record<string, string>,
|
||||
): Array<{ field: string; message: string }> {
|
||||
return Object.entries(errors)
|
||||
.filter(([, message]) => Boolean(message))
|
||||
.map(([field, message]) => ({ field, message }));
|
||||
}
|
||||
|
||||
export function schemaFieldNames(schema: ObjectSchema | SchemaDescriptor): string[] {
|
||||
const descriptor = "describe" in schema ? schema.describe() : schema;
|
||||
return Object.keys(descriptor.fields);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
checkField,
|
||||
type ObjectSchema,
|
||||
type ParseResult,
|
||||
type RuleDescriptor,
|
||||
type SchemaDescriptor,
|
||||
} from "./index.ts";
|
||||
|
||||
export interface JsonSchemaDocument {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema";
|
||||
$id?: string;
|
||||
title?: string;
|
||||
type: "object";
|
||||
properties: Record<string, Record<string, unknown>>;
|
||||
required?: string[];
|
||||
additionalProperties: false;
|
||||
}
|
||||
|
||||
function descriptorOf(schema: ObjectSchema | SchemaDescriptor): SchemaDescriptor {
|
||||
return "describe" in schema ? schema.describe() : schema;
|
||||
}
|
||||
|
||||
function fieldJsonSchema(field: SchemaDescriptor["fields"][string]): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = field.type === "unknown" ? {} : { type: field.type };
|
||||
if (field.label) output.title = field.label;
|
||||
if (field.trim) output["x-wrn-trim"] = true;
|
||||
if (field.typeMessage) output["x-wrn-type-message"] = field.typeMessage;
|
||||
for (const rule of field.rules) {
|
||||
switch (rule.kind) {
|
||||
case "min":
|
||||
output[field.type === "string" ? "minLength" : "minimum"] = rule.n;
|
||||
break;
|
||||
case "max":
|
||||
output[field.type === "string" ? "maxLength" : "maximum"] = rule.n;
|
||||
break;
|
||||
case "length":
|
||||
output.minLength = rule.n;
|
||||
output.maxLength = rule.n;
|
||||
break;
|
||||
case "email":
|
||||
case "uuid":
|
||||
case "date":
|
||||
output.format = rule.kind;
|
||||
break;
|
||||
case "url":
|
||||
output.format = "uri";
|
||||
break;
|
||||
case "oneOf":
|
||||
output.enum = rule.values;
|
||||
break;
|
||||
case "pattern":
|
||||
output.pattern = rule.source;
|
||||
if (rule.flags) output["x-wrn-pattern-flags"] = rule.flags;
|
||||
break;
|
||||
case "integer":
|
||||
output.type = "integer";
|
||||
break;
|
||||
}
|
||||
if (rule.message) {
|
||||
const messages = (output["x-wrn-messages"] ??= []) as string[];
|
||||
messages.push(rule.message);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function toJsonSchema(
|
||||
schema: ObjectSchema | SchemaDescriptor,
|
||||
options: { id?: string; title?: string } = {},
|
||||
): JsonSchemaDocument {
|
||||
const descriptor = descriptorOf(schema);
|
||||
const properties: Record<string, Record<string, unknown>> = {};
|
||||
const required: string[] = [];
|
||||
for (const [name, field] of Object.entries(descriptor.fields)) {
|
||||
properties[name] = fieldJsonSchema(field);
|
||||
if (!field.optional) required.push(name);
|
||||
}
|
||||
return {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
...(options.id ? { $id: options.id } : {}),
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
type: "object",
|
||||
properties,
|
||||
...(required.length ? { required } : {}),
|
||||
additionalProperties: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function openApiRequestBody(
|
||||
schema: ObjectSchema | SchemaDescriptor,
|
||||
options: { description?: string; required?: boolean; contentTypes?: string[] } = {},
|
||||
) {
|
||||
const document = toJsonSchema(schema);
|
||||
const { $schema: _, ...openApiSchema } = document;
|
||||
const content = Object.fromEntries(
|
||||
(options.contentTypes ?? ["application/json"]).map((contentType) => [
|
||||
contentType,
|
||||
{ schema: openApiSchema },
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...(options.description ? { description: options.description } : {}),
|
||||
required: options.required ?? true,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
export type ValidationMessageKey = "required" | "number" | `rule.${RuleDescriptor["kind"]}`;
|
||||
export type ValidationMessageTranslator = (
|
||||
key: ValidationMessageKey,
|
||||
params: Record<string, unknown>,
|
||||
) => string;
|
||||
|
||||
export function localizeDescriptor(
|
||||
schema: ObjectSchema | SchemaDescriptor,
|
||||
translate: ValidationMessageTranslator,
|
||||
): SchemaDescriptor {
|
||||
const descriptor = structuredClone(descriptorOf(schema));
|
||||
for (const [field, value] of Object.entries(descriptor.fields)) {
|
||||
value.requiredMessage ??= translate("required", { field, label: value.label ?? field });
|
||||
if (value.type === "number") {
|
||||
value.typeMessage ??= translate("number", { field, label: value.label ?? field });
|
||||
}
|
||||
value.rules = value.rules.map((rule) => ({
|
||||
...rule,
|
||||
message:
|
||||
rule.message ??
|
||||
translate(`rule.${rule.kind}`, {
|
||||
field,
|
||||
label: value.label ?? field,
|
||||
...(rule as unknown as Record<string, unknown>),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export function parseDescriptor<T = Record<string, unknown>>(
|
||||
descriptor: SchemaDescriptor,
|
||||
source: Record<string, unknown>,
|
||||
): ParseResult<T> {
|
||||
const value: Record<string, unknown> = {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const [name, field] of Object.entries(descriptor.fields)) {
|
||||
const result = checkField(field, source[name]);
|
||||
value[name] = result.value;
|
||||
if (result.error) errors[name] = result.error;
|
||||
}
|
||||
return { ok: Object.keys(errors).length === 0, value: value as T, errors };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
export interface ValidationPluginOptions {
|
||||
components?: boolean;
|
||||
componentDir?: string;
|
||||
}
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
export function validationComponentsDir(): string {
|
||||
return join(packageRoot, "components");
|
||||
}
|
||||
export function validationPlugin(options: ValidationPluginOptions = {}) {
|
||||
return definePlugin({
|
||||
name: "@wrnexus/validation",
|
||||
version: "0.8.0",
|
||||
componentDirs:
|
||||
options.components === false ? [] : [options.componentDir ?? validationComponentsDir()],
|
||||
});
|
||||
}
|
||||
export default validationPlugin;
|
||||
@@ -52,7 +52,7 @@ export const VALIDATE_RUNTIME = String.raw`
|
||||
var empty = pre === undefined || pre === null || pre === "";
|
||||
if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
|
||||
var value;
|
||||
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
|
||||
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return desc.typeMessage || "Must be a number"; }
|
||||
else value = String(pre);
|
||||
for (var i = 0; i < desc.rules.length; i++) {
|
||||
var err = applyRule(desc.type, desc.rules[i], value);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ContractRegistry, checkContractCompatibility, defineEvent, v } from "../src/index.ts";
|
||||
|
||||
describe("boundary contract registry", () => {
|
||||
test("registers typed, deterministic multi-boundary snapshots", () => {
|
||||
const registry = new ContractRegistry()
|
||||
.register(
|
||||
defineEvent({
|
||||
name: "user.created",
|
||||
version: 1,
|
||||
payload: v.object({ id: v.string().uuid() }),
|
||||
}),
|
||||
)
|
||||
.register({
|
||||
kind: "queue",
|
||||
name: "email.send",
|
||||
version: 1,
|
||||
payload: v.object({ to: v.string().email() }),
|
||||
});
|
||||
expect(registry.snapshot().contracts.map((item) => `${item.kind}:${item.name}`)).toEqual([
|
||||
"pubsub:user.created",
|
||||
"queue:email.send",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects duplicate identities", () => {
|
||||
const registry = new ContractRegistry();
|
||||
const contract = {
|
||||
kind: "api",
|
||||
name: "users",
|
||||
version: 1,
|
||||
payload: v.object({ id: v.string() }),
|
||||
} as const;
|
||||
registry.register(contract);
|
||||
expect(() => registry.register(contract)).toThrow("Duplicate contract");
|
||||
});
|
||||
|
||||
test("reports breaking fields, types, requirements, rules, and consumers", () => {
|
||||
const previous = new ContractRegistry()
|
||||
.register({
|
||||
kind: "webhook",
|
||||
name: "invoice.paid",
|
||||
version: 1,
|
||||
consumers: ["accounting", "email"],
|
||||
payload: v.object({
|
||||
email: v.string().optional(),
|
||||
count: v.number().min(0),
|
||||
legacy: v.string(),
|
||||
}),
|
||||
})
|
||||
.snapshot();
|
||||
const current = new ContractRegistry()
|
||||
.register({
|
||||
kind: "webhook",
|
||||
name: "invoice.paid",
|
||||
version: 1,
|
||||
payload: v.object({ email: v.string(), count: v.string().min(2), added: v.string() }),
|
||||
})
|
||||
.snapshot();
|
||||
const issues = checkContractCompatibility(previous, current);
|
||||
expect(issues.map((issue) => issue.code)).toEqual([
|
||||
"WRN-CONTRACT-FIELD-REQUIRED",
|
||||
"WRN-CONTRACT-FIELD-TYPE",
|
||||
"WRN-CONTRACT-RULE-TIGHTENED",
|
||||
"WRN-CONTRACT-FIELD-REMOVED",
|
||||
"WRN-CONTRACT-FIELD-REQUIRED",
|
||||
]);
|
||||
expect(issues.every((issue) => issue.consumers.includes("accounting"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
ValidationError,
|
||||
firstValidationError,
|
||||
parseOrThrow,
|
||||
validationResponse,
|
||||
validationSummary,
|
||||
v,
|
||||
type InferSchema,
|
||||
type ObjectSchema,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("validation helper kit", () => {
|
||||
test("throws a structured validation error", () => {
|
||||
const schema = v.object({ email: v.string().email() });
|
||||
expect(() => parseOrThrow(schema, { email: "invalid" })).toThrow(ValidationError);
|
||||
const value = parseOrThrow(schema, { email: "user@example.com" });
|
||||
const email: string = value.email;
|
||||
expect(value).toEqual({ email });
|
||||
});
|
||||
|
||||
test("preserves literal unions from oneOf", () => {
|
||||
interface ContactInput {
|
||||
topic: "general" | "security" | "billing" | "integration";
|
||||
priority: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
const schema: ObjectSchema<ContactInput> = v.object({
|
||||
topic: v.string().oneOf(["general", "security", "billing", "integration"]),
|
||||
priority: v.number().oneOf([1, 2, 3]),
|
||||
});
|
||||
const value = parseOrThrow(schema, { topic: "security", priority: 2 });
|
||||
const topic: ContactInput["topic"] = value.topic;
|
||||
const priority: ContactInput["priority"] = value.priority;
|
||||
expect({ topic, priority }).toEqual({ topic: "security", priority: 2 });
|
||||
|
||||
const _inferredSchema = v.object({
|
||||
topic: v.string().oneOf(["general", "security"]),
|
||||
});
|
||||
type Inferred = InferSchema<typeof _inferredSchema>;
|
||||
const inferredTopic: Inferred["topic"] = "general";
|
||||
expect(inferredTopic).toBe("general");
|
||||
|
||||
const dynamicOptions: string[] = ["draft", "published"];
|
||||
const dynamicSchema = v.object({ status: v.string().oneOf(dynamicOptions) });
|
||||
const dynamicValue: string = parseOrThrow(dynamicSchema, {
|
||||
status: "draft",
|
||||
}).status;
|
||||
expect(dynamicValue).toBe("draft");
|
||||
});
|
||||
|
||||
test("normalizes error summaries and HTTP responses", async () => {
|
||||
const errors = { email: "Email is invalid", password: "Password is required" };
|
||||
expect(firstValidationError(errors)).toBe("Email is invalid");
|
||||
expect(validationSummary(errors)).toHaveLength(2);
|
||||
expect((await validationResponse({ ok: false, value: {}, errors }).json()).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
localizeDescriptor,
|
||||
openApiRequestBody,
|
||||
parseDescriptor,
|
||||
toJsonSchema,
|
||||
v,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const contact = v.object({
|
||||
email: v.string().trim().email().label("Email address"),
|
||||
age: v.number().integer().min(18),
|
||||
role: v
|
||||
.string()
|
||||
.oneOf(["user", "admin"] as const)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
test("exports standards-compatible JSON Schema and OpenAPI request bodies", () => {
|
||||
expect(toJsonSchema(contact, { id: "urn:wrn:contact", title: "Contact" })).toEqual({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "urn:wrn:contact",
|
||||
title: "Contact",
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string", title: "Email address", "x-wrn-trim": true, format: "email" },
|
||||
age: { type: "integer", minimum: 18 },
|
||||
role: { type: "string", enum: ["user", "admin"] },
|
||||
},
|
||||
required: ["email", "age"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
const body = openApiRequestBody(contact, {
|
||||
description: "Contact input",
|
||||
contentTypes: ["application/json", "application/x-www-form-urlencoded"],
|
||||
});
|
||||
expect(body.required).toBe(true);
|
||||
expect(Object.keys(body.content)).toEqual([
|
||||
"application/json",
|
||||
"application/x-www-form-urlencoded",
|
||||
]);
|
||||
});
|
||||
|
||||
test("localized descriptors produce the same server/browser-ready errors", () => {
|
||||
const messages: Record<string, string> = {
|
||||
required: "{label} आवश्यक आहे",
|
||||
number: "{label} संख्या असणे आवश्यक आहे",
|
||||
"rule.email": "वैध ईमेल द्या",
|
||||
"rule.integer": "पूर्णांक द्या",
|
||||
"rule.min": "किमान {n}",
|
||||
"rule.oneOf": "परवानगी असलेले मूल्य द्या",
|
||||
};
|
||||
const descriptor = localizeDescriptor(contact, (key, params) =>
|
||||
(messages[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? "")),
|
||||
);
|
||||
expect(parseDescriptor(descriptor, { email: "bad", age: "nope" }).errors).toEqual({
|
||||
email: "वैध ईमेल द्या",
|
||||
age: "age संख्या असणे आवश्यक आहे",
|
||||
});
|
||||
expect(parseDescriptor(descriptor, {}).errors.email).toBe("Email address आवश्यक आहे");
|
||||
});
|
||||
Reference in New Issue
Block a user