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
59 changes: 59 additions & 0 deletions .changeset/schema-name-ref-deduplication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
'@cleverbrush/schema': minor
'@cleverbrush/schema-json': minor
'@cleverbrush/server-openapi': minor
---

Add `.schemaName()` and `components/schemas` `$ref` deduplication

### `@cleverbrush/schema`

New method `.schemaName(name: string)` on every schema builder. Attaches an OpenAPI component name to the schema as runtime metadata (accessible via `.introspect().schemaName`). Has no effect on validation. Follows the same immutable-builder pattern as `.describe()`.

```ts
import { object, string, number } from '@cleverbrush/schema';

export const UserSchema = object({
id: number(),
name: string(),
}).schemaName('User');

UserSchema.introspect().schemaName; // 'User'
```

### `@cleverbrush/schema-json`

New optional `nameResolver` option on `toJsonSchema()`. When provided, it is called for every schema node during recursive conversion. Returning a non-null string short-circuits conversion and emits a `$ref` pointer instead:

```ts
toJsonSchema(schema, {
$schema: false,
nameResolver: s => s.introspect().schemaName ?? null,
});
```

### `@cleverbrush/server-openapi`

Named schemas are now automatically collected into `components.schemas` and referenced via `$ref` throughout the generated OpenAPI document:

```ts
import { object, string, number, array } from '@cleverbrush/schema';
import { generateOpenApiSpec } from '@cleverbrush/server-openapi';
import { endpoint } from '@cleverbrush/server';

export const UserSchema = object({ id: number(), name: string() })
.schemaName('User');

const GetUser = endpoint.get('/users/:id').returns(UserSchema);
const ListUsers = endpoint.get('/users').returns(array(UserSchema));

// Both operations emit $ref: '#/components/schemas/User'
// A single components.schemas.User entry holds the full definition.
generateOpenApiSpec({ registrations: [...], info: { title: 'API', version: '1' } });
```

Two different schema instances with the same name throw at generation time — uniqueness is the caller's responsibility.

New exports from `@cleverbrush/server-openapi`:
- `SchemaRegistry` — low-level registry class (for custom tooling)
- `walkSchemas` — recursive schema walker used by the pre-pass
2 changes: 2 additions & 0 deletions libs/schema-json/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ Descriptions set via `.describe(text)` are emitted as the `description` field on
| --- | --- | --- | --- |
| `draft` | `'2020-12' \| '07'` | `'2020-12'` | JSON Schema draft version for the `$schema` URI |
| `$schema` | `boolean` | `true` | Whether to include the `$schema` header in the output |
| `nameResolver` | `(schema: SchemaBuilder) => string \| null` | `undefined` | Called for every node before conversion. Return a non-null string to emit `{ $ref: '#/components/schemas/<name>' }` instead of an inline schema. Used by `@cleverbrush/server-openapi` to wire named schemas from `.schemaName()` into `$ref` pointers. |

```ts
// Embed in OpenAPI (suppress the $schema header)
Expand Down Expand Up @@ -221,3 +222,4 @@ type B = JsonSchemaNodeToBuilder<typeof S>;
| `allOf` in `fromJsonSchema` | Falls back to `SchemaBuilder<unknown>` (no deep merge) |
| Dual IP format (`ip()` with both v4 + v6) | `format` is omitted in `toJsonSchema` output (no standard keyword covers both) |
| JSDoc comments on properties | Not preserved in `toJsonSchema` output |
| `nameResolver` + `$ref` / `$defs` round-trip | `nameResolver` emits `$ref` pointers based on external registry; `fromJsonSchema` does not resolve `$ref` references — they fall back to `any()` |
43 changes: 34 additions & 9 deletions libs/schema-json/src/toJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,18 @@ function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function convertNodeInner(schema: SchemaBuilder<any, any, any>): Out {
function escapeJsonPointerSegment(s: string): string {
return s.replace(/~/g, '~0').replace(/\//g, '~1');
}

type Resolver =
| ((schema: SchemaBuilder<any, any, any>) => string | null)
| undefined;

function convertNodeInner(
schema: SchemaBuilder<any, any, any>,
resolver: Resolver
): Out {
const info = schema.introspect() as any;
const ext: Record<string, unknown> = info.extensions ?? {};
const readOnly: Out = info.isReadonly === true ? { readOnly: true } : {};
Expand Down Expand Up @@ -84,7 +95,7 @@ function convertNodeInner(schema: SchemaBuilder<any, any, any>): Out {
case 'array': {
const out: Out = { ...readOnly, type: 'array' };
if (info.elementSchema)
out['items'] = convertNode(info.elementSchema);
out['items'] = convertNode(info.elementSchema, resolver);
if (info.minLength !== undefined) out['minItems'] = info.minLength;
if (info.maxLength !== undefined) out['maxItems'] = info.maxLength;
if (ext['nonempty'] === true && out['minItems'] === undefined)
Expand All @@ -97,11 +108,11 @@ function convertNodeInner(schema: SchemaBuilder<any, any, any>): Out {
info.elements ?? [];
const out: Out = {
type: 'array',
prefixItems: elements.map(convertNode),
prefixItems: elements.map(e => convertNode(e, resolver)),
minItems: elements.length
};
if (info.restSchema) {
out['items'] = convertNode(info.restSchema);
out['items'] = convertNode(info.restSchema, resolver);
} else {
out['items'] = false;
out['maxItems'] = elements.length;
Expand All @@ -118,7 +129,7 @@ function convertNodeInner(schema: SchemaBuilder<any, any, any>): Out {
const outProps: Record<string, unknown> = {};
const required: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
outProps[key] = convertNode(propSchema);
outProps[key] = convertNode(propSchema, resolver);
if ((propSchema.introspect() as any).isRequired !== false)
required.push(key);
}
Expand Down Expand Up @@ -152,16 +163,30 @@ function convertNodeInner(schema: SchemaBuilder<any, any, any>): Out {
}
}
if (allConst) return { ...readOnly, enum: enumValues };
return { ...readOnly, anyOf: options.map(convertNode) };
return {
...readOnly,
anyOf: options.map(o => convertNode(o, resolver))
};
}

default:
return {};
}
}

function convertNode(schema: SchemaBuilder<any, any, any>): Out {
const out = convertNodeInner(schema);
function convertNode(
schema: SchemaBuilder<any, any, any>,
resolver: Resolver
): Out {
if (resolver) {
const name = resolver(schema);
if (typeof name === 'string' && name.length > 0) {
return {
$ref: `#/components/schemas/${escapeJsonPointerSegment(name)}`
};
}
}
Comment thread
andrewzolotukhin marked this conversation as resolved.
const out = convertNodeInner(schema, resolver);
const info = schema.introspect() as any;
if (typeof info.description === 'string' && info.description !== '')
out['description'] = info.description;
Expand Down Expand Up @@ -264,7 +289,7 @@ export function toJsonSchema(
schema: SchemaBuilder<any, any, any>,
opts?: ToJsonSchemaOptions
): Record<string, unknown> {
const body = convertNode(schema);
const body = convertNode(schema, opts?.nameResolver);
if (opts?.$schema === false) return body;
const draft = opts?.draft ?? '2020-12';
const uri =
Expand Down
22 changes: 22 additions & 0 deletions libs/schema-json/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,28 @@ export type ToJsonSchemaOptions = {
* @default true
*/
$schema?: boolean;

/**
* Optional hook called for every schema node before conversion.
*
* When provided, the function receives each {@link SchemaBuilder} instance
* encountered during recursive conversion (including nested ones inside
* objects, arrays, and unions). If the function returns a non-null string,
* conversion of that node is short-circuited and a
* `{ $ref: '#/components/schemas/<name>' }` object is returned instead of
* the full inline JSON Schema.
*
* Return `null` to let conversion proceed normally.
*
* Primarily used by `@cleverbrush/server-openapi` to emit `$ref` pointers
* for schemas registered via `.schemaName()`.
*
* @param schema - The schema node currently being converted.
* @returns The component name to reference, or `null` to inline.
*/
nameResolver?: (
schema: import('@cleverbrush/schema').SchemaBuilder<any, any, any>
) => string | null;
};

// ---------------------------------------------------------------------------
Expand Down
44 changes: 44 additions & 0 deletions libs/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,50 @@ console.log(schema.introspect().isReadonly); // true

> **Note:** `.readonly()` is **shallow** — only top-level object properties or the array itself are marked readonly. For deeply nested immutability consider applying `.readonly()` at each level, or use a `DeepReadonly` utility type post-validation.

## schemaName

Every schema builder supports `.schemaName(name)`. This is a **metadata-only** modifier — it attaches a component name to the schema for use by OpenAPI tooling. It has no effect on validation or type inference.

```typescript
import { object, string, number } from '@cleverbrush/schema';

export const UserSchema = object({
id: number(),
name: string(),
}).schemaName('User');

// Accessible at runtime
UserSchema.introspect().schemaName; // 'User'
```

Chains naturally with all other modifiers:

```typescript
const ProductSchema = object({
sku: string().nonempty(),
price: number().min(0),
})
.schemaName('Product')
.describe('A product in the catalogue');
```

When used with [`@cleverbrush/server-openapi`](../server-openapi), any schema that carries a `schemaName` is automatically extracted into `components/schemas` and all usages in the document are replaced with `$ref` pointers — eliminating repeated inline definitions:

```typescript
import { generateOpenApiSpec } from '@cleverbrush/server-openapi';

// UserSchema is emitted once under components.schemas.User
// Every endpoint that references it gets: { $ref: '#/components/schemas/User' }
generateOpenApiSpec({ registrations, info: { title: 'My API', version: '1.0.0' } });
```

> **Name uniqueness:** Registering two *different* schema instances under the same name throws an error. Always export named schemas as constants and reuse the same reference everywhere.

| Method / Property | Signature | Notes |
|---|---|---|
| `.schemaName(name)` | `schemaName(name: string): this` | Returns a new builder; original is unchanged |
| `.introspect().schemaName` | `string \| undefined` | The name passed to `.schemaName()`, or `undefined` |

## Describe

Every schema builder supports `.describe(text)`. This is a **metadata-only** modifier — it stores a human-readable description on the schema at runtime with no effect on validation.
Expand Down
52 changes: 52 additions & 0 deletions libs/schema/src/builders/SchemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export type SchemaBuilderProps<T> = {
catchValue?: T | (() => T);
hasCatch?: boolean;
description?: string;
schemaName?: string;
};

export type ValidationContext<
Expand Down Expand Up @@ -712,6 +713,7 @@ export abstract class SchemaBuilder<
#isNullable = false;
#isReadonly = false;
#description: string | undefined;
#schemaName: string | undefined;
#preprocessors: PreprocessorEntry<TResult>[] = [];
#validators: ValidatorEntry<TResult>[] = [];
#hasMutating = false;
Expand Down Expand Up @@ -1408,9 +1410,15 @@ export abstract class SchemaBuilder<
* or `undefined` if none was set.
*/
description: this.#description,
/**
* The component name attached to this schema via `.schemaName()`,
* or `undefined` if none was set.
*/
schemaName: this.#schemaName,
/**
* Whether a catch/fallback value has been set on this schema via `.catch()`.
*/

hasCatch: this.#hasCatch,
/**
* The catch/fallback value or factory function set via `.catch()`.
Expand Down Expand Up @@ -1570,6 +1578,46 @@ export abstract class SchemaBuilder<
}) as unknown as this;
}

/**
* Attaches a component name to this schema for OpenAPI `components/schemas`
* registration.
*
* When a named schema is passed to `generateOpenApiSpec()` from
* `@cleverbrush/server-openapi`, it is collected into
* `components.schemas[name]` and every occurrence in the spec is replaced
* with a `$ref: '#/components/schemas/<name>'` pointer instead of being
* inlined in full.
*
* **Naming rules**
* - Names must be unique across the spec. Registering two *different*
* schema instances under the same name throws at generation time.
* - Re-using the same constant (same object reference) in multiple
* endpoints is fine — it is detected and deduplicated automatically.
*
* The name has no effect on validation and is not emitted by
* `toJsonSchema()` from `@cleverbrush/schema-json`.
*
* @example
* ```ts
* import { object, string, number } from '@cleverbrush/schema';
*
* export const UserSchema = object({
* id: number(),
* name: string(),
* }).schemaName('User');
*
* // Both endpoints share the same constant → single components.schemas entry
* const GetUser = endpoint.get('/users/:id').returns(UserSchema);
* const ListUsers = endpoint.get('/users').returns(array(UserSchema));
* ```
*/
public schemaName(name: string): this {
return this.createFromProps({
...this.introspect(),
schemaName: name
}) as unknown as this;
}

/**
* Brands the schema with a phantom type tag, preventing structural mixing
* of semantically different values at the type level. Zero runtime cost.
Expand Down Expand Up @@ -2005,6 +2053,10 @@ export abstract class SchemaBuilder<
this.#description = props.description;
}

if (typeof props.schemaName === 'string') {
this.#schemaName = props.schemaName;
}

this.#requiredErrorMessageProvider =
this.assureValidationErrorMessageProvider(
props.requiredValidationErrorMessageProvider,
Expand Down
Loading
Loading