release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+77
View File
@@ -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}
}
}
+29 -3
View File
@@ -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
}
}
}
+4 -6
View File
@@ -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,
+212
View File
@@ -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;
}
+46
View File
@@ -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);
}
+100 -24
View File
@@ -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";
+150
View File
@@ -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 };
}
+20
View File
@@ -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;
+1 -1
View File
@@ -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);
});
});
+58
View File
@@ -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);
});
});
+61
View File
@@ -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 आवश्यक आहे");
});