Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl

`validateFormanWithDomainsInternal` is the core; it builds a `roots` map per domain then calls `validateFormanValue` recursively. Handlers: `handleCollectionType`, `handleArrayType`, `handleSelectType`, `handleFilterType`, `handlePathType`, `handlePrimitiveType`, `handleNestedFields`, `handleBooleanNestedFields`. `resolveRemote` is wrapped into a closure that merges `context.tail` into the `data` argument.

`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas? }`. `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain.
`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas?, resolvedSchemas?, normalizedValues, appliedDefaults }` (`FormanNormalizedValidationResult`; `normalizedValues`/`appliedDefaults` are always present on entry-point results — without fills they echo the input values / an empty list). `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. `resolvedSchemas` is the same data as `schemas` but present on the failure path too, so a rejection can name remote-resolved fields the caller never saw.

**Default filling** (`options.fillDefaults: 'requiredOnly' | 'always'`, off by default; contract in the `FormanValidationOptions` JSDoc): the substitution happens just before the mandatory check in `validateFormanValue` — an `undefined` or `''` value with a non-`null`/`''` default fills (BlueprintValidator's `useDefaults` predicate and modes; explicit `null` stays a provided value). `'requiredOnly'` fills required fields only; `'always'` fills omitted optional fields too. The filled value flows through the rest of the walk and defaults under an armed nested branch fill recursively, `rpc://`-resolved specs included. Nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`DomainRoot.appliedDefaults`, raw path segments); the result exposes `appliedDefaults` (dot-joined paths, matching error paths) and `normalizedValues`, built with `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair.

Per-domain inputs accept `restoreExtras` (extra values injected into restore states, keyed by dot-notation path) and `allowDynamicValues` (when true, IML expressions and unresolved RPC select options produce warnings instead of errors; default false). `allowDynamicValues` can also be set globally via `FormanValidationOptions`.

Expand Down
57 changes: 56 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

Conversion and validation utilities for Forman Schema.

## v2.0.0 — validated values on every result

`validateForman` and `validateFormanWithDomains` now always return `normalizedValues` and
`appliedDefaults`. Nothing was removed or renamed and `valid`/`errors`/`warnings` are unaffected,
but a caller that deep-compares the whole result, or forwards it into a fixed-shape response, will
see two new keys — assert on the fields you care about, or drop the keys before forwarding.

Also new: `fillDefaults: 'always'`. See [Filling defaults](#filling-defaults).

## v1.14.0 — advanced field tracking

Non-breaking minor release. New surface for working with `advanced: true` Forman fields:
Expand Down Expand Up @@ -137,7 +146,16 @@ Validate Forman values against a Forman Schema. Two entry points are available:
- `validateForman(values, schema, options?)` — validate without domains.
- `validateFormanWithDomains(domains, options?)` — validate multiple domains at once.

Both return `{ valid: boolean, errors: { path: string, message: string }[] }`.
Both return `{ valid: boolean, errors: { path: string, message: string }[] }`, plus
`normalizedValues` (the input values per domain, with any filled defaults applied — see
[Filling defaults](#filling-defaults)) and `appliedDefaults` (what was filled, empty when
nothing was). The two are always present, so the consuming pattern is the same whether or
not default filling is enabled:

```typescript
const { valid, errors, normalizedValues } = await validateForman(values, schema);
if (valid) persist(normalizedValues.default);
```

#### Basic validation

Expand Down Expand Up @@ -219,6 +237,43 @@ const result = await validateForman(values, schema, {
});
```

#### Filling defaults

With `fillDefaults: 'requiredOnly'`, an omitted required field whose schema declares a usable
default (`null` and `''` cannot satisfy a required check) validates as that default instead of
failing as mandatory. With `fillDefaults: 'always'`, omitted optional fields with usable defaults
are filled too — the same modes as the platform's BlueprintValidator `useDefaults` option. The
filled value participates in the rest of the walk, so a filled boolean conditions its nested branch
exactly as a provided one would, and defaults under an armed branch fill recursively — including
fields injected by `rpc://`-resolved specs. Fills land in `normalizedValues` (the values with fills
applied; the input is never mutated, though subtrees nothing was written into are shared with it)
and are itemized in `appliedDefaults`, on the failure path too, so remaining errors can be repaired
on top of the filled values. Values you provide are never overwritten, **except `''`, which counts as
an omission and fills** — matching blueprint validation and the builder UI. Under `'always'` that
means an optional field you deliberately cleared comes back with its default; pass `'requiredOnly'`
if you need a cleared optional field left alone. An explicit `null` is a provided value: it never
fills and still fails as mandatory. Inactive nested branches are never filled.

```typescript
const schema = [
{
name: 'fallbackEnabled',
type: 'boolean',
required: true,
default: false,
nested: [{ name: 'fallbackConnectionId', type: 'text', required: true }],
},
];

const result = await validateForman({}, schema, { fillDefaults: 'requiredOnly' });
// {
// valid: true,
// errors: [],
// normalizedValues: { default: { fallbackEnabled: false } },
// appliedDefaults: [{ domain: 'default', path: 'fallbackEnabled', value: false }]
// }
```

#### Multi-domain validation

Use `validateFormanWithDomains` to validate cross-domain schemas (e.g., `default` and `additional`).
Expand Down
7 changes: 4 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { JSONSchema7 } from 'json-schema';
import { toJSONSchemaInternal, createDefaultContext } from './forman';
import type {
FormanSchemaField,
FormanValidationResult,
FormanNormalizedValidationResult,
FormanValidationOptions,
FormanJsonSchemaOptions,
FormanJsonSchemaResult,
Expand All @@ -24,6 +24,7 @@ export type {
FormanSchemaRPCButton,
FormanValidationOptions,
FormanValidationResult,
FormanNormalizedValidationResult,
FormanExternalValidationResult,
FormanJsonSchemaOptions,
FormanJsonSchemaResult,
Expand Down Expand Up @@ -130,7 +131,7 @@ export function validateFormanWithDomains(
}
>,
options?: FormanValidationOptions,
): Promise<FormanValidationResult> {
): Promise<FormanNormalizedValidationResult> {
return validateFormanWithDomainsInternal(domains, options);
}

Expand All @@ -149,7 +150,7 @@ export function validateForman(
schema: FormanSchemaField[],
options?: FormanValidationOptions,
restoreExtras?: Record<string, Record<string, unknown>>,
): Promise<FormanValidationResult> {
): Promise<FormanNormalizedValidationResult> {
return validateFormanWithDomains(
{ default: { values, schema, restoreExtras, allowDynamicValues: options?.allowDynamicValues } },
options,
Expand Down
37 changes: 37 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,31 @@ export type FormanValidationResult = {
* which fields it rejected.
*/
resolvedSchemas?: Record<string, FormanSchemaField[]>;
/**
* The input values with filled defaults applied, per domain, so a caller can persist or repair
* the filled configuration alongside any remaining errors. The input `values` are never
* mutated, but subtrees no default was written into are shared with the input — with no fills
* the domain's entry IS the caller's own object.
*/
normalizedValues?: Record<string, Record<string, unknown>>;
/** The defaults that were filled (`options.fillDefaults`), in walk order within each domain. */
appliedDefaults?: {
domain: string;
path: string;
/** The default that was filled in. Loosely typed on purpose: `default` is declared as
* `FormanSchemaValue`, but schemas are JSON at source and may carry object or array
* defaults at runtime, which are filled as (cloned) values too. */
value: unknown;
}[];
};

/**
* The type returned by `validateForman` and `validateFormanWithDomains`. The fields stay optional
* on the base type because intermediate results assembled during the walk do not carry them.
*/
export type FormanNormalizedValidationResult = FormanValidationResult &
Required<Pick<FormanValidationResult, 'normalizedValues' | 'appliedDefaults'>>;

export type FormanSchemaFieldState = {
mode?: 'chose' | 'edit';
label?: string;
Expand Down Expand Up @@ -379,6 +402,20 @@ export type FormanValidationOptions = {
schema: JSONSchema7,
value: unknown,
): FormanExternalValidationResult | Promise<FormanExternalValidationResult>;
/** Fill declared defaults for fields the caller omitted, mirroring BlueprintValidator's
* `useDefaults` modes. `'requiredOnly'` fills only required fields (instead of failing them
* as mandatory); `'always'` also fills omitted optional fields. A field is fillable when its
* value is `undefined` or `''` and its schema declares a default that is not `null` or `''`
* (a default that could not satisfy a required check) — the same fillable predicate as
* BlueprintValidator and the builder UI. The filled value flows through the rest of the
* walk, so a filled boolean conditions its nested branch exactly as a provided one would
* (`false` leaves the branch inactive), and defaults under an armed branch fill
* recursively, in the same single pass. Fills are reported on `normalizedValues` and
* `appliedDefaults`. Values the caller provided are never overwritten, except `''`, which
* counts as an omission and fills — so under `'always'` a deliberately cleared optional
* field comes back with its default. An explicit `null` is a provided value: it never fills
* and still fails as mandatory. Inactive branches are never filled. */
fillDefaults?: 'requiredOnly' | 'always';
/** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */
domainAliases?: Record<string, string>;
/** Whether to allow dynamic values (IML expressions, unresolved RPC options).
Expand Down
35 changes: 35 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,41 @@ export function findValueInSelectOptions(
return found as FormanSchemaOption | undefined; // If there was a value, then it has to be an option, because option groups don't have values
}

function setIn(container: unknown, path: Array<string | number>, value: unknown): unknown {
const [head, ...rest] = path;
if (head === undefined) return value;
if (typeof head === 'number') {
const items = Array.isArray(container) ? container.slice() : [];
items[head] = setIn(items[head], rest, value);
return items;
}
const record: Record<string, unknown> = isObject<Record<string, unknown>>(container) ? { ...container } : {};
record[head] = setIn(record[head], rest, value);
return record;
}

/**
* Returns a copy of `values` with `value` written at `path` (string segments for object keys,
* numbers for array indices). Containers along the path are cloned (and created when missing);
* everything off the path is shared with the input, which is never mutated.
*/
export function setValueAtPath(
values: Record<string, unknown>,
path: Array<string | number>,
value: unknown,
): Record<string, unknown> {
const [head] = path;
// Unreachable by construction: a domain root is a named collection, so every recorded fill
// path starts with a field name. Loud rather than silent, because returning `values` here
// would leave a fill reported in `appliedDefaults` that `normalizedValues` does not contain.
if (typeof head !== 'string') {
throw new Error(`Cannot write a value at path '${path.join('.')}': the first segment must be a field name.`);
}
const record = { ...values };
record[head] = setIn(record[head], path.slice(1), value);
return record;
}

/**
* Converts a path array to a string representation, joining elements with dots and using brackets for numeric indices.
* @param path
Expand Down
48 changes: 47 additions & 1 deletion src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { JSONSchema7 } from 'json-schema';
import type {
FormanSchemaField,
FormanValidationResult,
FormanNormalizedValidationResult,
FormanExternalValidationResult,
FormanSchemaExtendedNested,
FormanSchemaExtendedOptions,
Expand Down Expand Up @@ -29,6 +30,7 @@ import {
IML_FILTER_OPERATORS,
findValueInSelectOptions,
pathToString,
setValueAtPath,
stringToPath,
} from './utils';
import { udttypeExpand } from './composites/udttype';
Expand Down Expand Up @@ -58,6 +60,8 @@ export interface ValidationContext {
path: (string | number)[];
/** Unknown fields are not allowed when strict is true */
strict: boolean;
/** Fill declared defaults for omitted fields (see FormanValidationOptions.fillDefaults) */
fillDefaults?: 'requiredOnly' | 'always';
suppressRequired?: boolean;
registerOnly?: boolean;
/** Maps domain names used in nested.domain to actual domain keys */
Expand Down Expand Up @@ -98,6 +102,8 @@ export interface DomainRoot {
}>;
/** Resolved schema fields collected during validation */
schemaFields: FormanSchemaField[];
/** Defaults filled during validation (`options.fillDefaults`), with raw path segments */
appliedDefaults: Array<{ path: Array<string | number>; value: unknown }>;
/** Whether the domain allows dynamic values (IML expressions, unresolved RPC select options) */
allowDynamicValues: boolean;
}
Expand Down Expand Up @@ -241,7 +247,7 @@ export async function validateFormanWithDomainsInternal(
}
>,
options?: FormanValidationOptions,
): Promise<FormanValidationResult> {
): Promise<FormanNormalizedValidationResult> {
const errors: FormanValidationResult['errors'] = [];
const warnings: FormanValidationResult['warnings'] = [];

Expand All @@ -251,6 +257,7 @@ export async function validateFormanWithDomainsInternal(
seenFields: new Set(),
fieldStates: [],
schemaFields: [],
appliedDefaults: [],
allowDynamicValues: domains[domain]!.allowDynamicValues ?? options?.allowDynamicValues ?? false,
validateFields: (fields: FormanSchemaField[], context: ValidationContext) => {
return validateFormanValue(
Expand Down Expand Up @@ -290,6 +297,7 @@ export async function validateFormanWithDomainsInternal(
path: [],
tail: [],
strict: options?.strict === true,
fillDefaults: options?.fillDefaults,
domainAliases: options?.domainAliases ?? {},
validateNestedFields: () => {
throw new Error('Cannot validate nested fields without parent field.');
Expand Down Expand Up @@ -381,6 +389,23 @@ export async function validateFormanWithDomainsInternal(
resolvedSchemas: options?.schemas
? Object.fromEntries(Object.keys(domains).map(domain => [domain, roots[domain]!.schemaFields]))
: undefined,
normalizedValues: Object.fromEntries(
Object.keys(domains).map(domain => [
domain,
(roots[domain]?.appliedDefaults ?? []).reduce(
(values, { path, value }) => setValueAtPath(values, path, value),
domains[domain]?.values ?? {},
),
]),
),
appliedDefaults: Object.keys(domains).flatMap(
domain =>
roots[domain]?.appliedDefaults.map(({ path, value }) => ({
domain,
path: path.join('.'),
value,
})) ?? [],
),
};
}

Expand Down Expand Up @@ -434,6 +459,27 @@ async function validateFormanValue(
// Normalize field type (handle prefixed types)
const normalizedField = normalizeFormanFieldType(field);

// Same fillable predicate as BlueprintValidator's `useDefaults` (and the builder UI it cites),
// hence `=== undefined` rather than `== null`: an explicit `null` stays a provided value. A
// `null`/`''` default could not satisfy a required check, and on an optional field it is
// indistinguishable from the omission itself.
if (
context.fillDefaults != null &&
!context.suppressRequired &&
(value === undefined || value === '') &&
Comment thread
david0723 marked this conversation as resolved.
(context.fillDefaults === 'always' || normalizedField.required)
) {
const fillable = normalizedField.default;
if (fillable != null && fillable !== '') {
// `default` is typed as a primitive, but schemas are JSON at source: a runtime object or
// array default is cloned so the result never aliases the schema's own default instance.
const filled =
isObject(fillable) || Array.isArray(fillable) ? structuredClone<unknown>(fillable) : fillable;
value = filled;
context.roots[context.domain]!.appliedDefaults.push({ path: [...context.path], value: filled });
}
}

if (normalizedField.required && !context.suppressRequired && (value == null || value === '')) {
return {
valid: false,
Expand Down
10 changes: 5 additions & 5 deletions test/chosen-option-nested.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('Chosen option nested spec in field states', () => {
},
);

expect(result).toEqual({
expect(result).toMatchObject({
valid: true,
errors: [],
warnings: [],
Expand Down Expand Up @@ -64,7 +64,7 @@ describe('Chosen option nested spec in field states', () => {
{ states: true },
);

expect(result).toEqual({
expect(result).toMatchObject({
valid: true,
errors: [],
warnings: [],
Expand Down Expand Up @@ -92,7 +92,7 @@ describe('Chosen option nested spec in field states', () => {
{ states: true },
);

expect(result).toEqual({
expect(result).toMatchObject({
valid: true,
errors: [],
warnings: [],
Expand Down Expand Up @@ -127,7 +127,7 @@ describe('Chosen option nested spec in field states', () => {
{ states: true },
);

expect(result).toEqual({
expect(result).toMatchObject({
valid: true,
errors: [],
warnings: [],
Expand Down Expand Up @@ -164,7 +164,7 @@ describe('Chosen option nested spec in field states', () => {

// Dependent field states are siblings of the select state; the spec array on the
// select's state must survive buildRestoreStructure untouched.
expect(result).toEqual({
expect(result).toMatchObject({
valid: true,
errors: [],
warnings: [],
Expand Down
Loading