From 308c9ea06cbb07f6eeefb771de44fd1e1d1b8672 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 05:55:58 +0000 Subject: [PATCH 1/6] feat: add schemaName support for OpenAPI spec generation - Introduced .schemaName() method to schema builders for naming schemas. - Implemented SchemaRegistry to manage named schemas and prevent conflicts. - Enhanced generateOpenApiSpec to deduplicate schemas using $ref pointers. - Added tests for schemaName functionality and registry behavior. - Updated documentation to reflect new schema naming and deduplication features. --- .changeset/schema-name-ref-deduplication.md | 59 ++++++ libs/schema-json/README.md | 2 + libs/schema-json/src/toJsonSchema.ts | 35 +++- libs/schema-json/src/types.ts | 22 +++ libs/schema/README.md | 44 +++++ libs/schema/src/builders/SchemaBuilder.ts | 52 ++++++ libs/schema/src/builders/schemaName.test.ts | 129 +++++++++++++ libs/server-openapi/README.md | 71 ++++++++ .../src/generateOpenApiSpec.test.ts | 144 +++++++++++++++ .../server-openapi/src/generateOpenApiSpec.ts | 86 +++++++-- libs/server-openapi/src/index.ts | 1 + libs/server-openapi/src/schemaConverter.ts | 17 +- .../server-openapi/src/schemaRegistry.test.ts | 153 ++++++++++++++++ libs/server-openapi/src/schemaRegistry.ts | 172 ++++++++++++++++++ website/app/schema-json/page.tsx | 31 +++- website/app/schema/[[...slug]]/page.tsx | 2 + website/app/schema/sections/index.ts | 9 +- website/app/schema/sections/schema-name.tsx | 133 ++++++++++++++ website/app/server-openapi/page.tsx | 71 ++++++++ 19 files changed, 1204 insertions(+), 29 deletions(-) create mode 100644 .changeset/schema-name-ref-deduplication.md create mode 100644 libs/schema/src/builders/schemaName.test.ts create mode 100644 libs/server-openapi/src/schemaRegistry.test.ts create mode 100644 libs/server-openapi/src/schemaRegistry.ts create mode 100644 website/app/schema/sections/schema-name.tsx diff --git a/.changeset/schema-name-ref-deduplication.md b/.changeset/schema-name-ref-deduplication.md new file mode 100644 index 00000000..6428fd38 --- /dev/null +++ b/.changeset/schema-name-ref-deduplication.md @@ -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 diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md index 85877a25..be51e354 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -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/' }` 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) @@ -221,3 +222,4 @@ type B = JsonSchemaNodeToBuilder; | `allOf` in `fromJsonSchema` | Falls back to `SchemaBuilder` (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()` | diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index 2ad383ea..a9dc0f54 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -7,7 +7,14 @@ function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function convertNodeInner(schema: SchemaBuilder): Out { +type Resolver = + | ((schema: SchemaBuilder) => string | null) + | undefined; + +function convertNodeInner( + schema: SchemaBuilder, + resolver: Resolver +): Out { const info = schema.introspect() as any; const ext: Record = info.extensions ?? {}; const readOnly: Out = info.isReadonly === true ? { readOnly: true } : {}; @@ -84,7 +91,7 @@ function convertNodeInner(schema: SchemaBuilder): 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) @@ -97,11 +104,11 @@ function convertNodeInner(schema: SchemaBuilder): 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; @@ -118,7 +125,7 @@ function convertNodeInner(schema: SchemaBuilder): Out { const outProps: Record = {}; 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); } @@ -152,7 +159,10 @@ function convertNodeInner(schema: SchemaBuilder): Out { } } if (allConst) return { ...readOnly, enum: enumValues }; - return { ...readOnly, anyOf: options.map(convertNode) }; + return { + ...readOnly, + anyOf: options.map(o => convertNode(o, resolver)) + }; } default: @@ -160,8 +170,15 @@ function convertNodeInner(schema: SchemaBuilder): Out { } } -function convertNode(schema: SchemaBuilder): Out { - const out = convertNodeInner(schema); +function convertNode( + schema: SchemaBuilder, + resolver: Resolver +): Out { + if (resolver) { + const name = resolver(schema); + if (name !== null) return { $ref: `#/components/schemas/${name}` }; + } + const out = convertNodeInner(schema, resolver); const info = schema.introspect() as any; if (typeof info.description === 'string' && info.description !== '') out['description'] = info.description; @@ -264,7 +281,7 @@ export function toJsonSchema( schema: SchemaBuilder, opts?: ToJsonSchemaOptions ): Record { - const body = convertNode(schema); + const body = convertNode(schema, opts?.nameResolver); if (opts?.$schema === false) return body; const draft = opts?.draft ?? '2020-12'; const uri = diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts index a5c064ba..7ccc1e96 100644 --- a/libs/schema-json/src/types.ts +++ b/libs/schema-json/src/types.ts @@ -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/' }` 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 + ) => string | null; }; // --------------------------------------------------------------------------- diff --git a/libs/schema/README.md b/libs/schema/README.md index 272eb751..4580eee0 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -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. diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts index 10a88f08..4d62be2c 100644 --- a/libs/schema/src/builders/SchemaBuilder.ts +++ b/libs/schema/src/builders/SchemaBuilder.ts @@ -221,6 +221,7 @@ export type SchemaBuilderProps = { catchValue?: T | (() => T); hasCatch?: boolean; description?: string; + schemaName?: string; }; export type ValidationContext< @@ -712,6 +713,7 @@ export abstract class SchemaBuilder< #isNullable = false; #isReadonly = false; #description: string | undefined; + #schemaName: string | undefined; #preprocessors: PreprocessorEntry[] = []; #validators: ValidatorEntry[] = []; #hasMutating = false; @@ -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()`. @@ -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/'` 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. @@ -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, diff --git a/libs/schema/src/builders/schemaName.test.ts b/libs/schema/src/builders/schemaName.test.ts new file mode 100644 index 00000000..c9503e5b --- /dev/null +++ b/libs/schema/src/builders/schemaName.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, expectTypeOf, test } from 'vitest'; +import type { ArraySchemaBuilder } from './ArraySchemaBuilder.js'; +import { array } from './ArraySchemaBuilder.js'; +import type { BooleanSchemaBuilder } from './BooleanSchemaBuilder.js'; +import { boolean } from './BooleanSchemaBuilder.js'; +import type { DateSchemaBuilder } from './DateSchemaBuilder.js'; +import { date } from './DateSchemaBuilder.js'; +import type { NumberSchemaBuilder } from './NumberSchemaBuilder.js'; +import { number } from './NumberSchemaBuilder.js'; +import type { ObjectSchemaBuilder } from './ObjectSchemaBuilder.js'; +import { object } from './ObjectSchemaBuilder.js'; +import type { StringSchemaBuilder } from './StringSchemaBuilder.js'; +import { string } from './StringSchemaBuilder.js'; +import { union } from './UnionSchemaBuilder.js'; + +// ========================================================================== +// Type-level tests — schemaName() returns the same concrete builder type +// ========================================================================== + +describe('schemaName - type inference', () => { + test('string().schemaName() returns StringSchemaBuilder', () => { + const schema = string().schemaName('Str'); + expectTypeOf(schema).toMatchTypeOf>(); + }); + + test('number().schemaName() returns NumberSchemaBuilder', () => { + const schema = number().schemaName('Num'); + expectTypeOf(schema).toMatchTypeOf>(); + }); + + test('boolean().schemaName() returns BooleanSchemaBuilder', () => { + const schema = boolean().schemaName('Flag'); + expectTypeOf(schema).toMatchTypeOf< + BooleanSchemaBuilder + >(); + }); + + test('date().schemaName() returns DateSchemaBuilder', () => { + const schema = date().schemaName('When'); + expectTypeOf(schema).toMatchTypeOf>(); + }); + + test('object().schemaName() returns ObjectSchemaBuilder', () => { + const schema = object({ name: string() }).schemaName('User'); + expectTypeOf(schema).toMatchTypeOf< + ObjectSchemaBuilder< + { name: StringSchemaBuilder }, + true + > + >(); + }); + + test('array().schemaName() returns ArraySchemaBuilder', () => { + const schema = array(string()).schemaName('Names'); + expectTypeOf(schema).toMatchTypeOf< + ArraySchemaBuilder + >(); + }); +}); + +// ========================================================================== +// Runtime tests +// ========================================================================== + +describe('schemaName - runtime behaviour', () => { + test('stores the name in introspect().schemaName', () => { + expect(string().schemaName('Str').introspect().schemaName).toBe('Str'); + }); + + test('overwrites a previous name', () => { + const s = string().schemaName('First').schemaName('Second'); + expect(s.introspect().schemaName).toBe('Second'); + }); + + test('schemaName is undefined when not set', () => { + expect(string().introspect().schemaName).toBeUndefined(); + }); + + // --- Chaining survives other modifiers --- + + test('schemaName() + optional() — name survives', () => { + const schema = object({ id: number() }).schemaName('User').optional(); + expect(schema.introspect().schemaName).toBe('User'); + expect(schema.introspect().isRequired).toBe(false); + }); + + test('optional() + schemaName() — name set after optional', () => { + const schema = object({ id: number() }).optional().schemaName('User'); + expect(schema.introspect().schemaName).toBe('User'); + expect(schema.introspect().isRequired).toBe(false); + }); + + test('schemaName() + describe() — both survive', () => { + const schema = object({ id: number() }) + .schemaName('User') + .describe('A user object'); + expect(schema.introspect().schemaName).toBe('User'); + expect(schema.introspect().description).toBe('A user object'); + }); + + test('describe() + schemaName() — both survive regardless of order', () => { + const schema = object({ id: number() }) + .describe('A user object') + .schemaName('User'); + expect(schema.introspect().schemaName).toBe('User'); + expect(schema.introspect().description).toBe('A user object'); + }); + + test('schemaName() does not affect validation', async () => { + const schema = object({ id: number() }).schemaName('User'); + const result = await schema.validate({ id: 42 }); + expect(result.valid).toBe(true); + }); + + test('schemaName() on union', () => { + const schema = union(string()).or(number()).schemaName('StrOrNum'); + expect(schema.introspect().schemaName).toBe('StrOrNum'); + }); + + test('schemaName() on array', () => { + const schema = array(string()).schemaName('Names'); + expect(schema.introspect().schemaName).toBe('Names'); + }); + + test('schema without schemaName returns undefined from introspect', () => { + const schema = string().describe('hello'); + expect(schema.introspect().schemaName).toBeUndefined(); + }); +}); diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index 1de4219b..471ccc2d 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -86,6 +86,77 @@ await writeOpenApiSpec({ }); ``` +## $ref Deduplication (Named Schemas) + +When the same schema definition is used by multiple endpoints, you can mark it with `.schemaName()` from `@cleverbrush/schema` so that `generateOpenApiSpec()` extracts it once into `components/schemas` and replaces every inline occurrence with a `$ref` pointer. + +### How it works + +1. Call `.schemaName('ComponentName')` on any `@cleverbrush/schema` builder you want to extract. +2. Export the result as a **constant** and reuse the same reference wherever the schema is needed. +3. `generateOpenApiSpec()` detects all named schemas via a pre-pass walk, emits them under `components.schemas`, and replaces inline definitions with `$ref` pointers. + +```ts +import { object, string, number } from '@cleverbrush/schema'; +import { endpoint } from '@cleverbrush/server'; +import { generateOpenApiSpec } from '@cleverbrush/server-openapi'; + +// Mark once — reuse everywhere +const UserSchema = object({ + id: number(), + name: string(), +}).schemaName('User'); + +const GetUser = endpoint.get('/api/users/:id').returns(UserSchema); +const ListUsers = endpoint.get('/api/users').returns(array(UserSchema)); + +const spec = generateOpenApiSpec({ + registrations: [GetUser.registration, ListUsers.registration], + info: { title: 'My API', version: '1.0.0' } +}); +// components.schemas.User → { type: 'object', properties: { id: …, name: … } } +// GET /api/users/:id → responses.200.content['application/json'].schema: { $ref: '#/components/schemas/User' } +// GET /api/users → responses.200.content['application/json'].schema: { type: 'array', items: { $ref: '…/User' } } +``` + +Nested named schemas inside request bodies are also resolved: + +```ts +const AddressSchema = object({ street: string(), city: string() }).schemaName('Address'); + +// The wrapper is anonymous — inlined. The nested AddressSchema → $ref. +const CreateUserBody = object({ address: AddressSchema, name: string() }); +``` + +### Conflict rule + +Registering **two different schema instances** under the same name throws immediately during spec generation: + +```ts +const A = object({ x: string() }).schemaName('Thing'); +const B = object({ y: number() }).schemaName('Thing'); // different instance! + +generateOpenApiSpec({ registrations: [...], info: { … } }); +// Error: Schema name "Thing" is already registered by a different schema instance. +``` + +Re-registering the **same** instance (because it appears in multiple endpoints) is a no-op. + +### `SchemaRegistry` (advanced) + +`SchemaRegistry` and `walkSchemas` are also exported from `@cleverbrush/server-openapi` for custom tooling: + +```ts +import { SchemaRegistry, walkSchemas } from '@cleverbrush/server-openapi'; + +const registry = new SchemaRegistry(); +walkSchemas(MySchema, registry); + +registry.getName(MySchema); // 'MyComponentName' | null +registry.entries(); // IterableIterator<[name, SchemaBuilder]> +registry.isEmpty; // boolean +``` + ## Authentication & Security Schemes Pass the server's `AuthenticationConfig` to automatically generate `securitySchemes` and per-operation `security` arrays: diff --git a/libs/server-openapi/src/generateOpenApiSpec.test.ts b/libs/server-openapi/src/generateOpenApiSpec.test.ts index 04c883b4..fae8dac6 100644 --- a/libs/server-openapi/src/generateOpenApiSpec.test.ts +++ b/libs/server-openapi/src/generateOpenApiSpec.test.ts @@ -484,3 +484,147 @@ describe('generateOpenApiSpec', () => { expect(paths['/api/posts']['post']).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// $ref / component schema deduplication via .schemaName() +// --------------------------------------------------------------------------- + +describe('generateOpenApiSpec — $ref deduplication', () => { + it('emits components.schemas entry for a named response schema', () => { + const UserSchema = object({ id: number(), name: string() }).schemaName( + 'User' + ); + const spec = generateOpenApiSpec( + makeOptions([makeReg({ responseSchema: UserSchema })]) + ); + const components = spec['components'] as any; + expect(components).toBeDefined(); + expect(components['schemas']['User']).toMatchObject({ type: 'object' }); + }); + + it('emits $ref in operation response when schema is named', () => { + const UserSchema = object({ id: number() }).schemaName('User'); + const spec = generateOpenApiSpec( + makeOptions([makeReg({ responseSchema: UserSchema })]) + ); + const paths = spec['paths'] as any; + const content = + paths['/api/items']['get']['responses']['200']['content']; + expect(content['application/json']['schema']).toEqual({ + $ref: '#/components/schemas/User' + }); + }); + + it('shares a single components.schemas entry across two endpoints', () => { + const UserSchema = object({ id: number() }).schemaName('User'); + const spec = generateOpenApiSpec( + makeOptions([ + makeReg({ + basePath: '/api', + pathTemplate: '/users', + method: 'GET', + responseSchema: UserSchema + }), + makeReg({ + basePath: '/api', + pathTemplate: '/admin/user', + method: 'GET', + responseSchema: UserSchema + }) + ]) + ); + const schemas = (spec['components'] as any)['schemas']; + expect(Object.keys(schemas)).toEqual(['User']); + + const paths = spec['paths'] as any; + const ref1 = + paths['/api/users']['get']['responses']['200']['content'][ + 'application/json' + ]['schema']; + const ref2 = + paths['/api/admin/user']['get']['responses']['200']['content'][ + 'application/json' + ]['schema']; + expect(ref1).toEqual({ $ref: '#/components/schemas/User' }); + expect(ref2).toEqual({ $ref: '#/components/schemas/User' }); + }); + + it('inlines unnamed schemas as before', () => { + const AnonSchema = object({ val: string() }); + const spec = generateOpenApiSpec( + makeOptions([makeReg({ responseSchema: AnonSchema })]) + ); + const paths = spec['paths'] as any; + const schema = + paths['/api/items']['get']['responses']['200']['content'][ + 'application/json' + ]['schema']; + expect(schema).toMatchObject({ type: 'object' }); + expect(schema).not.toHaveProperty('$ref'); + // No components.schemas when everything is inline + no security + const components = spec['components'] as any; + expect(components?.schemas).toBeUndefined(); + }); + + it('emits $ref for a named schema nested inside the body', () => { + const AddressSchema = object({ city: string() }).schemaName('Address'); + const CreateUserBody = object({ address: AddressSchema }); + const spec = generateOpenApiSpec( + makeOptions([makeReg({ bodySchema: CreateUserBody })]) + ); + const schemas = (spec['components'] as any)['schemas']; + expect(schemas['Address']).toMatchObject({ type: 'object' }); + + const paths = spec['paths'] as any; + const bodySchema = + paths['/api/items']['get']['requestBody']['content'][ + 'application/json' + ]['schema']; + // The top-level body is anonymous — inlined + expect(bodySchema['properties']['address']).toEqual({ + $ref: '#/components/schemas/Address' + }); + }); + + it('throws when two different schema instances share a name', () => { + const A = object({ x: string() }).schemaName('Conflict'); + const B = object({ y: number() }).schemaName('Conflict'); + expect(() => + generateOpenApiSpec( + makeOptions([ + makeReg({ + basePath: '/api', + pathTemplate: '/a', + responseSchema: A + }), + makeReg({ + basePath: '/api', + pathTemplate: '/b', + responseSchema: B + }) + ]) + ) + ).toThrow(/Conflict/); + }); + + it('handles the same named schema in body and response without error', () => { + const UserSchema = object({ id: number() }).schemaName('User'); + expect(() => + generateOpenApiSpec( + makeOptions([ + makeReg({ + bodySchema: UserSchema, + responseSchema: UserSchema + }) + ]) + ) + ).not.toThrow(); + const spec = generateOpenApiSpec( + makeOptions([ + makeReg({ bodySchema: UserSchema, responseSchema: UserSchema }) + ]) + ); + const schemas = (spec['components'] as any)['schemas']; + expect(Object.keys(schemas)).toEqual(['User']); + }); +}); diff --git a/libs/server-openapi/src/generateOpenApiSpec.ts b/libs/server-openapi/src/generateOpenApiSpec.ts index 9ef3953f..21fc0a74 100644 --- a/libs/server-openapi/src/generateOpenApiSpec.ts +++ b/libs/server-openapi/src/generateOpenApiSpec.ts @@ -6,6 +6,7 @@ import type { } from '@cleverbrush/server'; import { resolvePath } from './pathUtils.js'; import { convertSchema } from './schemaConverter.js'; +import { SchemaRegistry, walkSchemas } from './schemaRegistry.js'; import { mapOperationSecurity, mapSecuritySchemes, @@ -88,9 +89,10 @@ function buildParameterObject( } function buildRequestBody( - bodySchema: SchemaBuilder + bodySchema: SchemaBuilder, + registry: SchemaRegistry ): Record { - const jsonSchema = convertSchema(bodySchema); + const jsonSchema = convertSchema(bodySchema, registry); const bodyInfo = bodySchema.introspect() as any; const body: Record = { required: bodyInfo.isRequired !== false, @@ -130,7 +132,8 @@ const PROBLEM_DETAILS_SCHEMA = { function buildResponses( meta: EndpointMetadata, - method: string + method: string, + registry: SchemaRegistry ): Record { const result: Record = {}; @@ -141,7 +144,7 @@ function buildResponses( const desc = HTTP_STATUS_DESCRIPTIONS[code] ?? `Response ${codeStr}`; if (schema) { - const jsonSchema = convertSchema(schema); + const jsonSchema = convertSchema(schema, registry); const respInfo = schema.introspect() as any; const customDesc = typeof respInfo.description === 'string' && @@ -158,7 +161,7 @@ function buildResponses( } } else if (meta.responseSchema) { // Legacy single-code path — .returns() was called - const jsonSchema = convertSchema(meta.responseSchema); + const jsonSchema = convertSchema(meta.responseSchema, registry); const respInfo = meta.responseSchema.introspect() as any; const desc = typeof respInfo.description === 'string' && @@ -214,7 +217,8 @@ function buildResponses( function buildOperation( meta: EndpointMetadata, pathParams: { name: string; schema: Record }[], - securitySchemeNames: string[] + securitySchemeNames: string[], + registry: SchemaRegistry ): Record { const operation: Record = {}; @@ -252,7 +256,7 @@ function buildOperation( buildParameterObject( name, 'query', - convertSchema(propSchema), + convertSchema(propSchema, registry), isRequired, description ) @@ -279,7 +283,7 @@ function buildOperation( buildParameterObject( name, 'header', - convertSchema(propSchema), + convertSchema(propSchema, registry), isRequired, description ) @@ -291,11 +295,15 @@ function buildOperation( // Request body if (meta.bodySchema) { - operation['requestBody'] = buildRequestBody(meta.bodySchema); + operation['requestBody'] = buildRequestBody(meta.bodySchema, registry); } // Responses - operation['responses'] = buildResponses(meta, meta.method.toUpperCase()); + operation['responses'] = buildResponses( + meta, + meta.method.toUpperCase(), + registry + ); // Security const security = mapOperationSecurity(authRoles(meta), securitySchemeNames); @@ -320,6 +328,41 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { securitySchemes ?? mapSecuritySchemes(authConfig); const securitySchemeNames = Object.keys(resolvedSchemes); + // Pre-pass: collect all named schemas from every endpoint into a registry. + // Walking happens before path generation so that $ref pointers are emitted + // correctly at every call site within buildOperation. + const registry = new SchemaRegistry(); + const visited = new Set>(); + for (const reg of registrations) { + const meta = reg.endpoint; + if (meta.bodySchema) walkSchemas(meta.bodySchema, registry, visited); + if (meta.responseSchema) + walkSchemas(meta.responseSchema, registry, visited); + if (meta.responsesSchemas) { + for (const schema of Object.values(meta.responsesSchemas)) { + if (schema) walkSchemas(schema, registry, visited); + } + } + if (meta.querySchema) { + const queryProps = + (meta.querySchema.introspect() as any).properties ?? {}; + for (const propSchema of Object.values< + SchemaBuilder + >(queryProps)) { + walkSchemas(propSchema, registry, visited); + } + } + if (meta.headerSchema) { + const headerProps = + (meta.headerSchema.introspect() as any).properties ?? {}; + for (const propSchema of Object.values< + SchemaBuilder + >(headerProps)) { + walkSchemas(propSchema, registry, visited); + } + } + } + // Build paths const paths: Record> = {}; @@ -332,7 +375,8 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { paths[path][method] = buildOperation( meta, pathParams as { name: string; schema: Record }[], - securitySchemeNames + securitySchemeNames, + registry ); } @@ -348,11 +392,21 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { doc['paths'] = paths; - // Components - if (securitySchemeNames.length > 0) { - doc['components'] = { - securitySchemes: { ...resolvedSchemes } - }; + // Components — security schemes + named component schemas + const componentSchemas: Record = {}; + for (const [name, schema] of registry.entries()) { + // Convert without nameResolver so the definition itself is fully inlined + // (avoids a self-referential $ref in the components section). + componentSchemas[name] = convertSchema(schema); + } + const hasSchemas = Object.keys(componentSchemas).length > 0; + + if (securitySchemeNames.length > 0 || hasSchemas) { + const components: Record = {}; + if (securitySchemeNames.length > 0) + components['securitySchemes'] = { ...resolvedSchemes }; + if (hasSchemas) components['schemas'] = componentSchemas; + doc['components'] = components; } return doc; diff --git a/libs/server-openapi/src/index.ts b/libs/server-openapi/src/index.ts index f5bcde5a..18a9461f 100644 --- a/libs/server-openapi/src/index.ts +++ b/libs/server-openapi/src/index.ts @@ -16,6 +16,7 @@ export { resolvePath } from './pathUtils.js'; export { convertSchema } from './schemaConverter.js'; +export { SchemaRegistry, walkSchemas } from './schemaRegistry.js'; export { mapOperationSecurity, mapSecuritySchemes, diff --git a/libs/server-openapi/src/schemaConverter.ts b/libs/server-openapi/src/schemaConverter.ts index bc53acbf..2e82ce9f 100644 --- a/libs/server-openapi/src/schemaConverter.ts +++ b/libs/server-openapi/src/schemaConverter.ts @@ -1,15 +1,28 @@ import type { SchemaBuilder } from '@cleverbrush/schema'; import { toJsonSchema } from '@cleverbrush/schema-json'; +import type { SchemaRegistry } from './schemaRegistry.js'; /** * Converts a `@cleverbrush/schema` builder to a JSON Schema object suitable * for embedding in an OpenAPI 3.1 spec (no `$schema` header, Draft 2020-12). * * Returns an empty schema `{}` when the input is `null` or `undefined`. + * + * When a {@link SchemaRegistry} is provided, any schema instance that was + * registered under a name will be emitted as a + * `$ref: '#/components/schemas/'` pointer instead of being inlined. + * + * @param schema - The schema to convert, or `null`/`undefined`. + * @param registry - Optional registry for `$ref` deduplication. */ export function convertSchema( - schema: SchemaBuilder | null | undefined + schema: SchemaBuilder | null | undefined, + registry?: SchemaRegistry ): Record { if (schema == null) return {}; - return toJsonSchema(schema, { $schema: false, draft: '2020-12' }); + return toJsonSchema(schema, { + $schema: false, + draft: '2020-12', + nameResolver: registry ? s => registry.getName(s) : undefined + }); } diff --git a/libs/server-openapi/src/schemaRegistry.test.ts b/libs/server-openapi/src/schemaRegistry.test.ts new file mode 100644 index 00000000..bd40b468 --- /dev/null +++ b/libs/server-openapi/src/schemaRegistry.test.ts @@ -0,0 +1,153 @@ +import { array, number, object, string, union } from '@cleverbrush/schema'; +import { describe, expect, it } from 'vitest'; +import { SchemaRegistry, walkSchemas } from './schemaRegistry.js'; + +// --------------------------------------------------------------------------- +// SchemaRegistry +// --------------------------------------------------------------------------- + +describe('SchemaRegistry', () => { + describe('register()', () => { + it('registers a named schema and makes it retrievable by getName()', () => { + const registry = new SchemaRegistry(); + const UserSchema = object({ id: number() }).schemaName('User'); + registry.register(UserSchema); + expect(registry.getName(UserSchema)).toBe('User'); + }); + + it('silently skips schemas without a schemaName', () => { + const registry = new SchemaRegistry(); + const anon = object({ id: number() }); + registry.register(anon); + expect(registry.getName(anon)).toBeNull(); + }); + + it('is idempotent — re-registering the same instance is a no-op', () => { + const registry = new SchemaRegistry(); + const UserSchema = object({ id: number() }).schemaName('User'); + registry.register(UserSchema); + expect(() => registry.register(UserSchema)).not.toThrow(); + expect(registry.getName(UserSchema)).toBe('User'); + }); + + it('throws when two different instances share the same name', () => { + const registry = new SchemaRegistry(); + const A = object({ x: string() }).schemaName('Thing'); + const B = object({ y: number() }).schemaName('Thing'); + registry.register(A); + expect(() => registry.register(B)).toThrow(/Thing/); + }); + }); + + describe('getName()', () => { + it('returns null for an unregistered schema', () => { + const registry = new SchemaRegistry(); + expect(registry.getName(string())).toBeNull(); + }); + + it('returns the name for a registered schema', () => { + const registry = new SchemaRegistry(); + const s = string().schemaName('MyString'); + registry.register(s); + expect(registry.getName(s)).toBe('MyString'); + }); + }); + + describe('entries()', () => { + it('yields all registered [name, schema] pairs', () => { + const registry = new SchemaRegistry(); + const A = string().schemaName('A'); + const B = number().schemaName('B'); + registry.register(A); + registry.register(B); + const entries = [...registry.entries()]; + expect(entries).toHaveLength(2); + expect(entries.map(([n]) => n)).toEqual(['A', 'B']); + }); + + it('yields nothing when empty', () => { + const registry = new SchemaRegistry(); + expect([...registry.entries()]).toHaveLength(0); + }); + }); + + describe('isEmpty', () => { + it('is true for a fresh registry', () => { + expect(new SchemaRegistry().isEmpty).toBe(true); + }); + + it('is false after a named schema is registered', () => { + const registry = new SchemaRegistry(); + registry.register(string().schemaName('S')); + expect(registry.isEmpty).toBe(false); + }); + + it('is true when only unnamed schemas were registered', () => { + const registry = new SchemaRegistry(); + registry.register(string()); + expect(registry.isEmpty).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// walkSchemas +// --------------------------------------------------------------------------- + +describe('walkSchemas', () => { + it('registers a top-level named schema', () => { + const registry = new SchemaRegistry(); + const UserSchema = object({ id: number() }).schemaName('User'); + walkSchemas(UserSchema, registry); + expect(registry.getName(UserSchema)).toBe('User'); + }); + + it('recurses into object properties', () => { + const registry = new SchemaRegistry(); + const AddressSchema = object({ city: string() }).schemaName('Address'); + const UserSchema = object({ address: AddressSchema }); + walkSchemas(UserSchema, registry); + expect(registry.getName(AddressSchema)).toBe('Address'); + }); + + it('recurses into array element schema', () => { + const registry = new SchemaRegistry(); + const TagSchema = object({ label: string() }).schemaName('Tag'); + walkSchemas(array(TagSchema), registry); + expect(registry.getName(TagSchema)).toBe('Tag'); + }); + + it('recurses into union options', () => { + const registry = new SchemaRegistry(); + const CatSchema = object({ meow: string() }).schemaName('Cat'); + const DogSchema = object({ bark: string() }).schemaName('Dog'); + walkSchemas(union(CatSchema).or(DogSchema), registry); + expect(registry.getName(CatSchema)).toBe('Cat'); + expect(registry.getName(DogSchema)).toBe('Dog'); + }); + + it('does not visit the same instance twice (cycle safety)', () => { + const registry = new SchemaRegistry(); + const Shared = object({ x: number() }).schemaName('Shared'); + // Shared appears as both a property value and the root's sibling + const root = object({ a: Shared, b: Shared }); + expect(() => walkSchemas(root, registry)).not.toThrow(); + expect(registry.getName(Shared)).toBe('Shared'); + }); + + it('throws when two different named schemas share a name (conflict)', () => { + const registry = new SchemaRegistry(); + const A = object({ x: string() }).schemaName('Thing'); + const B = object({ y: number() }).schemaName('Thing'); + const root = object({ a: A, b: B }); + expect(() => walkSchemas(root, registry)).toThrow(/Thing/); + }); + + it('skips lazy schemas without recursing', () => { + const registry = new SchemaRegistry(); + // Constructing a lazy schema that calls back into itself + // walkSchemas must not recurse into it + const lazySchema = { introspect: () => ({ type: 'lazy' }) } as any; + expect(() => walkSchemas(lazySchema, registry)).not.toThrow(); + }); +}); diff --git a/libs/server-openapi/src/schemaRegistry.ts b/libs/server-openapi/src/schemaRegistry.ts new file mode 100644 index 00000000..7110dc31 --- /dev/null +++ b/libs/server-openapi/src/schemaRegistry.ts @@ -0,0 +1,172 @@ +import type { SchemaBuilder } from '@cleverbrush/schema'; + +// --------------------------------------------------------------------------- +// SchemaRegistry +// --------------------------------------------------------------------------- + +/** + * Collects schemas that carry an explicit component name (set via + * `.schemaName()`) and provides a reference-based lookup used during OpenAPI + * spec generation to replace inline schema objects with + * `$ref: '#/components/schemas/'` pointers. + * + * **Conflict rule**: registering two *different* schema instances (different + * object references) under the same name throws immediately. Re-registering + * the same instance is a no-op. + * + * @example + * ```ts + * const registry = new SchemaRegistry(); + * registry.register(UserSchema); // UserSchema.schemaName('User') + * registry.getName(UserSchema); // 'User' + * registry.getName(someOtherSchema); // null + * ``` + */ +export class SchemaRegistry { + /** schema instance → registered name */ + private readonly byInstance = new Map< + SchemaBuilder, + string + >(); + /** name → first-registered schema instance */ + private readonly byName = new Map>(); + + /** + * Attempts to register `schema` in the registry. + * + * - If the schema has no `schemaName` in its introspect output, it is + * silently skipped. + * - If the same instance is already registered, this is a no-op. + * - If a **different** instance is already registered under the same name, + * an error is thrown. + * + * @param schema - The schema builder to register. + * @throws {Error} When two distinct schema instances share the same name. + */ + register(schema: SchemaBuilder): void { + const name = (schema.introspect() as any).schemaName as + | string + | undefined; + if (typeof name !== 'string') return; + + const existing = this.byName.get(name); + if (existing !== undefined) { + // Same instance → idempotent, nothing to do + if (existing === schema) return; + // Different instance → conflict + throw new Error( + `Schema name "${name}" is already registered by a different schema instance. ` + + `Each named schema must be a single, reused constant. ` + + `If you intended to register the same schema, ensure you are passing ` + + `the same object reference (not a rebuilt schema).` + ); + } + + this.byInstance.set(schema, name); + this.byName.set(name, schema); + } + + /** + * Returns the component name for a given schema instance, or `null` if it + * was not registered. + * + * @param schema - The schema builder to look up. + * @returns The registered name, or `null`. + */ + getName(schema: SchemaBuilder): string | null { + return this.byInstance.get(schema) ?? null; + } + + /** + * Iterates over all registered `[name, schema]` pairs in insertion order. + * + * Used to emit the `components.schemas` section of an OpenAPI document. + */ + entries(): IterableIterator<[string, SchemaBuilder]> { + return this.byName.entries(); + } + + /** Returns `true` when at least one schema has been registered. */ + get isEmpty(): boolean { + return this.byName.size === 0; + } +} + +// --------------------------------------------------------------------------- +// walkSchemas +// --------------------------------------------------------------------------- + +/** + * Recursively visits every {@link SchemaBuilder} reachable from `schema` and + * calls {@link SchemaRegistry.register} on each node. + * + * Cycle detection is performed via a `visited` `Set` of object references, so + * schemas may safely be shared across multiple branches without causing + * infinite recursion. + * + * **Excluded schema types** + * - `lazy` — deferred resolution would require calling the getter, which may + * itself reference the parent schema; lazy schemas are handled separately. + * + * @param schema - Root schema to start the walk from. + * @param registry - Registry to register named schemas into. + * @param visited - Shared set for cycle detection; pass a new `Set()` for the + * top-level call. + */ +export function walkSchemas( + schema: SchemaBuilder, + registry: SchemaRegistry, + visited: Set> = new Set() +): void { + if (visited.has(schema)) return; + visited.add(schema); + + registry.register(schema); + + const info = schema.introspect() as any; + + switch (info.type) { + case 'object': { + const props = info.properties as + | Record> + | undefined; + if (props) { + for (const child of Object.values(props)) { + walkSchemas(child, registry, visited); + } + } + break; + } + case 'array': + if (info.elementSchema) { + walkSchemas(info.elementSchema, registry, visited); + } + break; + case 'tuple': { + const elements: SchemaBuilder[] = + info.elements ?? []; + for (const el of elements) { + walkSchemas(el, registry, visited); + } + if (info.restSchema) { + walkSchemas(info.restSchema, registry, visited); + } + break; + } + case 'union': { + const options: SchemaBuilder[] = info.options ?? []; + for (const opt of options) { + walkSchemas(opt, registry, visited); + } + break; + } + case 'record': + if (info.valueSchema) { + walkSchemas(info.valueSchema, registry, visited); + } + break; + // 'lazy' intentionally excluded — resolved lazily, handled separately + default: + break; + } +} diff --git a/website/app/schema-json/page.tsx b/website/app/schema-json/page.tsx index 8ce681fc..8d3f4fad 100644 --- a/website/app/schema-json/page.tsx +++ b/website/app/schema-json/page.tsx @@ -405,7 +405,22 @@ const spec = toJsonSchema(ProductSchema); const openApiSchema = toJsonSchema(ProductSchema, { $schema: false }); // Use Draft 07 format -const draft7Schema = toJsonSchema(ProductSchema, { draft: '07' });` +const draft7Schema = toJsonSchema(ProductSchema, { draft: '07' }); + +// Use nameResolver to emit $ref pointers for named component schemas +// (used internally by @cleverbrush/server-openapi — see SchemaRegistry) +const registry = new Map([['Product', ProductSchema]]); +const withRefs = toJsonSchema(ProductSchema, { + $schema: false, + nameResolver: (schema) => { + for (const [name, s] of registry) { + if (s === schema) return name; + } + return null; + }, +}); +// Instead of inlining, nodes matching a registry entry become: +// { "$ref": "#/components/schemas/Product" }` ) }} /> @@ -533,6 +548,20 @@ type B = JsonSchemaNodeToBuilder; single JSON Schema keyword covers both + + + nameResolver +{' '} + $ref round-trip + + + nameResolver emits{' '} + $ref pointers based on an + external registry;{' '} + fromJsonSchema does not + resolve $ref — they fall + back to any() + + diff --git a/website/app/schema/[[...slug]]/page.tsx b/website/app/schema/[[...slug]]/page.tsx index a822aa56..ad25fd43 100644 --- a/website/app/schema/[[...slug]]/page.tsx +++ b/website/app/schema/[[...slug]]/page.tsx @@ -17,6 +17,7 @@ import PromiseSchemaSection from '../sections/promise-schema'; import PropertyDescriptorsSection from '../sections/property-descriptors'; import ReadonlySection from '../sections/readonly'; import RecursiveSchemasSection from '../sections/recursive-schemas'; +import SchemaNameSection from '../sections/schema-name'; import SchemaTypesSection from '../sections/schema-types'; import StandardSchemaSection from '../sections/standard-schema'; import ValidationSection from '../sections/validation'; @@ -42,6 +43,7 @@ const SECTION_COMPONENTS: Record = { 'catch-fallback': CatchFallbackSection, readonly: ReadonlySection, describe: DescribeSection, + 'schema-name': SchemaNameSection, extensions: ExtensionsSection, 'built-in-extensions': BuiltInExtensionsSection, 'standard-schema': StandardSchemaSection, diff --git a/website/app/schema/sections/index.ts b/website/app/schema/sections/index.ts index 6ce559fc..33214f70 100644 --- a/website/app/schema/sections/index.ts +++ b/website/app/schema/sections/index.ts @@ -27,7 +27,13 @@ export const SECTION_GROUPS = [ }, { label: 'Modifiers', - slugs: ['default-values', 'catch-fallback', 'readonly', 'describe'] + slugs: [ + 'default-values', + 'catch-fallback', + 'readonly', + 'describe', + 'schema-name' + ] }, { label: 'Extensions', @@ -91,6 +97,7 @@ export const SCHEMA_SECTIONS: SchemaSection[] = [ { slug: 'catch-fallback', title: 'Catch / Fallback', group: 'Modifiers' }, { slug: 'readonly', title: 'Readonly Modifier', group: 'Modifiers' }, { slug: 'describe', title: 'Describe', group: 'Modifiers' }, + { slug: 'schema-name', title: 'schemaName', group: 'Modifiers' }, { slug: 'extensions', title: 'Extensions', group: 'Extensions' }, { slug: 'built-in-extensions', diff --git a/website/app/schema/sections/schema-name.tsx b/website/app/schema/sections/schema-name.tsx new file mode 100644 index 00000000..677082e5 --- /dev/null +++ b/website/app/schema/sections/schema-name.tsx @@ -0,0 +1,133 @@ +import { highlightTS } from '@/lib/highlight'; + +export default function SchemaNameSection() { + return ( +
+

schemaName

+

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

+

+ When used with{' '} + + @cleverbrush/server-openapi + + , any schema carrying a schemaName is automatically + extracted into components/schemas and every usage + in the generated document is replaced with a{' '} + {'$ref'} pointer — eliminating repeated inline + definitions and producing cleaner, more readable OpenAPI specs. +

+
+ + + + + + + + + + + + + + + + + + + + +
Method / PropertySignatureNotes
+ .schemaName(name) + + {'schemaName(name: string): this'} + + Returns a new builder; original is unchanged +
+ .introspect().schemaName + + {'string | undefined'} + + The name passed to .schemaName(), + or undefined if not set +
+
+
+                
+            
+

Name uniqueness

+

+ Registering two different schema instances under the + same name throws during spec generation. Always export named + schemas as constants and reuse the same reference: +

+
+                
+            
+
+ ); +} diff --git a/website/app/server-openapi/page.tsx b/website/app/server-openapi/page.tsx index c1b1123d..f124a57b 100644 --- a/website/app/server-openapi/page.tsx +++ b/website/app/server-openapi/page.tsx @@ -177,6 +177,77 @@ await writeOpenApiSpec({ + {/* ── $ref Deduplication ───────────────────────────── */} +
+

+ $ref Deduplication — Named Schemas +

+

+ Call .schemaName('Name') on any{' '} + @cleverbrush/schema builder to mark it as a + named component. generateOpenApiSpec(){' '} + automatically extracts all named schemas into{' '} + components/schemas and replaces every + inline occurrence with a $ref pointer — + eliminating repetition and producing cleaner specs. +

+
+                        
+                    
+

+ Nested named schemas inside request bodies and response + objects are resolved automatically too: +

+
+                        
+                    
+

+ Conflict rule: registering two{' '} + different schema instances under the same name + throws during spec generation. Always export named + schemas as constants and share the same object + reference. +

+
+ {/* ── Auth ─────────────────────────────────────────── */}

Security Schemes from Auth Config

From 8c4009ef76b228b0f2fce9409a25ac09568feaf1 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 09:03:43 +0300 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/schema-json/src/toJsonSchema.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index a9dc0f54..7e7d9cb4 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -7,6 +7,10 @@ function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function escapeJsonPointerSegment(s: string): string { + return s.replace(/~/g, '~0').replace(/\//g, '~1'); +} + type Resolver = | ((schema: SchemaBuilder) => string | null) | undefined; @@ -176,7 +180,11 @@ function convertNode( ): Out { if (resolver) { const name = resolver(schema); - if (name !== null) return { $ref: `#/components/schemas/${name}` }; + if (typeof name === 'string' && name.length > 0) { + return { + $ref: `#/components/schemas/${escapeJsonPointerSegment(name)}`, + }; + } } const out = convertNodeInner(schema, resolver); const info = schema.introspect() as any; From 5c9df846e2fa282e0442e57c110220d293ced87a Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 09:04:21 +0300 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/server-openapi/src/generateOpenApiSpec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/libs/server-openapi/src/generateOpenApiSpec.ts b/libs/server-openapi/src/generateOpenApiSpec.ts index 21fc0a74..8ea1ec37 100644 --- a/libs/server-openapi/src/generateOpenApiSpec.ts +++ b/libs/server-openapi/src/generateOpenApiSpec.ts @@ -363,6 +363,11 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { } } + const resolveComponentSchemaName = + (rootSchema: SchemaBuilder) => + (candidate: SchemaBuilder) => + candidate === rootSchema ? undefined : registry.getName(candidate); + // Build paths const paths: Record> = {}; @@ -395,9 +400,13 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { // Components — security schemes + named component schemas const componentSchemas: Record = {}; for (const [name, schema] of registry.entries()) { - // Convert without nameResolver so the definition itself is fully inlined - // (avoids a self-referential $ref in the components section). - componentSchemas[name] = convertSchema(schema); + // Inline the root schema to avoid a self-referential $ref, but resolve + // nested named schemas through the shared registry so component + // definitions can still deduplicate via $ref. + componentSchemas[name] = convertSchema( + schema, + resolveComponentSchemaName(schema) + ); } const hasSchemas = Object.keys(componentSchemas).length > 0; From d4e33dc9e710943765ed594ac459fa3603e11228 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 09:04:42 +0300 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/server-openapi/src/schemaRegistry.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/server-openapi/src/schemaRegistry.ts b/libs/server-openapi/src/schemaRegistry.ts index 7110dc31..59d56f30 100644 --- a/libs/server-openapi/src/schemaRegistry.ts +++ b/libs/server-openapi/src/schemaRegistry.ts @@ -161,6 +161,9 @@ export function walkSchemas( break; } case 'record': + if (info.keySchema) { + walkSchemas(info.keySchema, registry, visited); + } if (info.valueSchema) { walkSchemas(info.valueSchema, registry, visited); } From 8ebe83c4172ae32ef5b223e338b5c8b1632b8aea Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 09:04:57 +0300 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/server-openapi/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index 471ccc2d..6ddaa089 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -97,7 +97,7 @@ When the same schema definition is used by multiple endpoints, you can mark it w 3. `generateOpenApiSpec()` detects all named schemas via a pre-pass walk, emits them under `components.schemas`, and replaces inline definitions with `$ref` pointers. ```ts -import { object, string, number } from '@cleverbrush/schema'; +import { object, string, number, array } from '@cleverbrush/schema'; import { endpoint } from '@cleverbrush/server'; import { generateOpenApiSpec } from '@cleverbrush/server-openapi'; From 80cd028e5702d8adf393f0cd1d499bcf1ba4a825 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 06:09:55 +0000 Subject: [PATCH 6/6] feat: enhance schema conversion with name resolver support --- libs/schema-json/src/toJsonSchema.ts | 2 +- libs/server-openapi/src/schemaConverter.ts | 26 +++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index 7e7d9cb4..c99d4ab6 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -182,7 +182,7 @@ function convertNode( const name = resolver(schema); if (typeof name === 'string' && name.length > 0) { return { - $ref: `#/components/schemas/${escapeJsonPointerSegment(name)}`, + $ref: `#/components/schemas/${escapeJsonPointerSegment(name)}` }; } } diff --git a/libs/server-openapi/src/schemaConverter.ts b/libs/server-openapi/src/schemaConverter.ts index 2e82ce9f..c0e83dfc 100644 --- a/libs/server-openapi/src/schemaConverter.ts +++ b/libs/server-openapi/src/schemaConverter.ts @@ -2,27 +2,43 @@ import type { SchemaBuilder } from '@cleverbrush/schema'; import { toJsonSchema } from '@cleverbrush/schema-json'; import type { SchemaRegistry } from './schemaRegistry.js'; +type NameResolver = ( + schema: SchemaBuilder +) => string | null | undefined; + /** * Converts a `@cleverbrush/schema` builder to a JSON Schema object suitable * for embedding in an OpenAPI 3.1 spec (no `$schema` header, Draft 2020-12). * * Returns an empty schema `{}` when the input is `null` or `undefined`. * - * When a {@link SchemaRegistry} is provided, any schema instance that was - * registered under a name will be emitted as a + * When a {@link SchemaRegistry} or a custom resolver function is provided, any + * schema instance that resolves to a name will be emitted as a * `$ref: '#/components/schemas/'` pointer instead of being inlined. * * @param schema - The schema to convert, or `null`/`undefined`. - * @param registry - Optional registry for `$ref` deduplication. + * @param registry - Optional registry or resolver function for `$ref` deduplication. */ export function convertSchema( schema: SchemaBuilder | null | undefined, - registry?: SchemaRegistry + registry?: SchemaRegistry | NameResolver ): Record { if (schema == null) return {}; + + let nameResolver: + | ((s: SchemaBuilder) => string | null) + | undefined; + if (registry) { + if (typeof registry === 'function') { + nameResolver = s => registry(s) ?? null; + } else { + nameResolver = s => registry.getName(s); + } + } + return toJsonSchema(schema, { $schema: false, draft: '2020-12', - nameResolver: registry ? s => registry.getName(s) : undefined + nameResolver }); }