You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
161 lines
3.8 KiB
161 lines
3.8 KiB
import { issue } from './issue';
|
|
import { SiumAsyncSchemaError, SiumValidationError } from '../errors';
|
|
import type { Ctx, MetaAnnotations, RefineFailure, Step } from './types';
|
|
import { isPromiseLike } from './internals';
|
|
|
|
type StepOptions = {
|
|
/**
|
|
* Mark callbacks as async when they are regular functions that return a
|
|
* Promise. JavaScript cannot detect that shape before executing the callback.
|
|
*/
|
|
async?: boolean;
|
|
};
|
|
|
|
function isAsyncFunction(fn: Function): boolean {
|
|
return fn.constructor.name === 'AsyncFunction';
|
|
}
|
|
|
|
function createAsyncStepError() {
|
|
return new SiumAsyncSchemaError(
|
|
undefined,
|
|
'Step contains async logic — use decode() instead of decodeSync().'
|
|
);
|
|
}
|
|
|
|
function createRefineIssue(path: ReadonlyArray<string | number>, fail: RefineFailure) {
|
|
return issue({
|
|
path,
|
|
code: fail.code ?? 'custom',
|
|
message: fail.message ?? '#?sium.errors.custom|Invalid value',
|
|
...(fail.params === undefined ? {} : { params: fail.params })
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates a refinement step that validates the current value without changing it.
|
|
*
|
|
* Failed checks emit a single `Issue` built from `fail`, defaulting to
|
|
* `code='custom'` and `message='#?sium.errors.custom|Invalid value'`.
|
|
*/
|
|
export function refine<T>(
|
|
check: (value: T, ctx: Ctx) => boolean | Promise<boolean>,
|
|
fail: RefineFailure,
|
|
opts?: StepOptions
|
|
): Step<T, T> {
|
|
const async = opts?.async ?? isAsyncFunction(check);
|
|
|
|
return {
|
|
kind: 'refine',
|
|
async,
|
|
meta: {},
|
|
decode: async (input, ctx) => {
|
|
const result = await check(input, ctx);
|
|
if (result) {
|
|
return input;
|
|
}
|
|
|
|
throw new SiumValidationError([createRefineIssue(ctx.path, fail)]);
|
|
},
|
|
decodeSync: (input, ctx) => {
|
|
if (async) {
|
|
throw createAsyncStepError();
|
|
}
|
|
|
|
const result = check(input, ctx);
|
|
if (isPromiseLike(result)) {
|
|
throw createAsyncStepError();
|
|
}
|
|
|
|
if (result) {
|
|
return input;
|
|
}
|
|
|
|
throw new SiumValidationError([createRefineIssue(ctx.path, fail)]);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a one-way transformation step.
|
|
*
|
|
* `transform` affects decode only. It intentionally does not expose `encode`,
|
|
* so `pipe(...).encode(...)` treats it as identity in reverse composition.
|
|
*/
|
|
export function transform<I, O>(
|
|
fn: (value: I, ctx: Ctx) => O | Promise<O>,
|
|
opts?: StepOptions
|
|
): Step<I, O> {
|
|
const async = opts?.async ?? isAsyncFunction(fn);
|
|
|
|
return {
|
|
kind: 'transform',
|
|
async,
|
|
meta: {},
|
|
decode: async (input, ctx) => await fn(input, ctx),
|
|
decodeSync: (input, ctx) => {
|
|
if (async) {
|
|
throw createAsyncStepError();
|
|
}
|
|
|
|
const result = fn(input, ctx);
|
|
if (isPromiseLike(result)) {
|
|
throw createAsyncStepError();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a bidirectional step with explicit decode and encode sides.
|
|
*
|
|
* Unlike `transform`, `codec` participates in reverse composition during
|
|
* `pipe(...).encode(...)` by applying the provided `encode` function.
|
|
*/
|
|
export function codec<I, O>(
|
|
decode: (value: I, ctx: Ctx) => O | Promise<O>,
|
|
encode: (value: O) => I,
|
|
opts?: StepOptions
|
|
): Step<I, O> {
|
|
const async = opts?.async ?? isAsyncFunction(decode);
|
|
|
|
return {
|
|
kind: 'codec',
|
|
async,
|
|
meta: {},
|
|
decode: async (input, ctx) => await decode(input, ctx),
|
|
decodeSync: (input, ctx) => {
|
|
if (async) {
|
|
throw createAsyncStepError();
|
|
}
|
|
|
|
const result = decode(input, ctx);
|
|
if (isPromiseLike(result)) {
|
|
throw createAsyncStepError();
|
|
}
|
|
|
|
return result;
|
|
},
|
|
encode
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a pure annotation step that leaves the value untouched.
|
|
*
|
|
* `meta` never changes runtime data and never participates in encode; it only
|
|
* contributes shallow-copied annotations to the final schema introspection.
|
|
*/
|
|
export function meta<T>(annotations: MetaAnnotations): Step<T, T> {
|
|
const copiedMeta = { ...annotations };
|
|
|
|
return {
|
|
kind: 'meta',
|
|
async: false,
|
|
meta: copiedMeta,
|
|
decode: async (input) => input,
|
|
decodeSync: (input) => input
|
|
};
|
|
}
|