Open dev-dsp opened 12 hours ago
Hey @dev-dsp, this has been requested a few times already. My only question is, how would you resolve something like self-referencing schemas?
@mrlubos, it's great to hear there’s been interest in this feature! I only found #993, which addresses a different aspect of schema references.
One way to manage recursive references could be a maxDepth option in the configuration. This would allow users to specify how many levels deep the references should expand. Once the set depth is reached, any further $refs would remain as references rather than unfolding indefinitely.
For instance, a configuration might look like this:
// Config option to limit unfolding depth
{
unfoldRefs: {
enabled: true,
maxDepth: 2 // Stops unfolding after 2 levels
}
}
Consider a self-referencing schema structure like NodeSchema:
const NodeSchema = {
properties: {
value: { type: 'string' },
children: {
type: 'array',
items: { $ref: '#/components/schemas/Node' }
}
}
}
With maxDepth: 2, this would unfold references up to two levels, beyond which $ref is left intact to prevent infinite loops:
// Schema after applying maxDepth = 2
const UnfoldedNodeSchema = {
properties: {
value: { type: 'string' },
children: {
type: 'array',
items: {
properties: {
value: { type: 'string' },
children: {
type: 'array',
items: {
properties: {
value: { type: 'string' },
children: {
type: 'array',
items: { $ref: '#/components/schemas/Node' } // Stops unfolding here
}
}
}
}
}
}
}
}
}
@mrlubos Another approach could be providing helper utilities that resolve references on-demand. Here are a few possible patterns:
// 1. Static helper function in generated code:
export function resolveFilterSchema(filter: {type: string}) {
const mapping = {
'first': FirstFilterSchema,
'last': LastFilterSchema,
'regex': RegexFilterSchema,
// etc
} as const;
return mapping[filter.type as keyof typeof mapping];
}
// Ideal for direct lookups and runtime type validation without unfolding references,
// suitable for most non-recursive references.
// 2. Instance method on schema objects:
export const FilterRuleSchema = {
// ... existing schema ...
resolveFilter(type: string) {
return this.filter.discriminator.mapping[type];
}
} as const;
// This is useful when FilterRuleSchema instances might dynamically need to resolve
// types based on runtime information, maintaining separation from hard-coded mappings.
// 3. Getter property that provides resolved versions:
export const FilterRuleSchema = {
// ... base schema ...
get resolvedOneOf() {
return this.filter.oneOf.map(ref => {
const schemaName = ref.$ref.split('/').pop();
return schemas[schemaName]; // Where schemas is an export of all definitions
});
}
} as const;
// Suitable when references need to be resolved “just-in-time” for usage, offering
// efficient memory use by resolving only as necessary. This fits scenarios with frequent
// or varied lookups without statically embedding all schema data.
// 4. Type-safe schema registry pattern:
export const SchemaRegistry = {
schemas: {
FilterRule: FilterRuleSchema,
FirstFilter: FirstFilterSchema,
// etc
},
resolve<T extends keyof typeof this.schemas>(ref: string): typeof this.schemas[T] {
const schemaName = ref.split('/').pop() as T;
return this.schemas[schemaName];
}
} as const;
// This registry pattern provides high reusability and scalability, allowing for robust,
// centralized schema management. It’s type-safe and ideal for projects with numerous
// schema references that benefit from centralized access control.
Oh, @mrlubos, and a different take that might work - how about using a Proxy to lazy-load the $refs? Instead of expanding everything up-front or on-demand via functions, we could wrap the schema in a Proxy and intercept access to any $ref. So when a $ref is actually accessed, the Proxy fetches and resolves it right then and there, caching the resolved schema for future access.
const schemas = {
FilterRule: {
properties: {
type: { type: 'string', enum: ['filter'], const: 'filter', title: 'Type' },
filter: {
oneOf: [
{ $ref: '#/components/schemas/RegexFilter' },
{ $ref: '#/components/schemas/RangeFilter' },
// more filters
],
title: 'Filter',
description: 'Filter configuration'
},
target_field: { type: 'string', title: 'Target Field', description: 'Target field' }
},
type: 'object',
required: ['type', 'filter', 'target_field']
},
RegexFilter: { /* RegexFilter schema details */ },
RangeFilter: { /* RangeFilter schema details */ }
};
// Proxy handler to resolve `$ref`s when accessed
const refResolverHandler = {
get(target, prop) {
if (target[prop] && target[prop].$ref) {
const ref = target[prop].$ref.split('/').pop();
if (schemas[ref]) {
target[prop] = new Proxy(schemas[ref], refResolverHandler); // Cache resolved schema
}
}
return target[prop];
}
};
// Wrap FilterRuleSchema in the Proxy
const FilterRuleSchema = new Proxy(schemas.FilterRule, refResolverHandler);
// Usage example
console.log(FilterRuleSchema.properties.filter.oneOf[0]); // Triggers resolution of RegexFilter schema
Here's also some kind of a combined approach:
// Schema Registry with all definitions
const schemas = {
FilterRule: {
properties: {
type: { type: 'string', enum: ['filter'], const: 'filter', title: 'Type' },
filter: {
oneOf: [
{ $ref: '#/components/schemas/RegexFilter' },
{ $ref: '#/components/schemas/RangeFilter' },
// Other filters
],
title: 'Filter',
description: 'Filter configuration'
},
target_field: { type: 'string', title: 'Target Field', description: 'Target field' }
},
type: 'object',
required: ['type', 'filter', 'target_field']
},
RegexFilter: { /* RegexFilter schema */ },
RangeFilter: { /* RangeFilter schema */ },
// Additional schemas
};
// Dynamic Resolver Helper Function with Depth Control
function resolveSchema(schema, depth = 0, maxDepth = 2) {
if (depth > maxDepth) return schema; // Stop if max depth is reached
// Lazy-expand any `$ref` properties within the schema
if (schema.$ref) {
const schemaName = schema.$ref.split('/').pop();
return resolveSchema(schemas[schemaName], depth + 1, maxDepth);
}
// Recursively resolve properties or items if they're objects or arrays
const resolvedSchema = { ...schema };
for (const key in schema) {
if (typeof schema[key] === 'object') {
resolvedSchema[key] = resolveSchema(schema[key], depth + 1, maxDepth);
}
}
return resolvedSchema;
}
// Lazy Proxy Handler
const refResolverHandler = {
get(target, prop) {
if (target[prop] && target[prop].$ref) {
const schemaName = target[prop].$ref.split('/').pop();
target[prop] = new Proxy(resolveSchema(schemas[schemaName]), refResolverHandler);
}
return target[prop];
}
};
// Wrapping main schema with lazy proxy
const FilterRuleSchema = new Proxy(schemas.FilterRule, refResolverHandler);
// Usage examples
// Direct access, lazy loads only when properties accessed, within max depth
console.log(FilterRuleSchema.properties.filter.oneOf[0]); // Resolves RegexFilter lazily
// Explicit deep resolution if we need it
const fullyResolvedFilterRule = resolveSchema(FilterRuleSchema, 0, Infinity);
console.log(fullyResolvedFilterRule); // Fully expanded to any depth
@dev-dsp What's your use case for schemas? Any of these could be a valid approach, depending on the objective...
In my project, I use schemas to create dynamic forms for user input. These forms are based on the schemas, which helps ensure that the data users enter is valid before it’s sent to the backend (which is built with FastAPI). Validating data on the frontend like this helps keep everything consistent when it reaches the backend.
I get these schemas from two main places:
The issue came up when I tried to reuse components that were built to work with “prepared” schemas (schemas where all $ref links were already resolved on the backend) - when I switched to using openapi-ts-generated schemas, which still contain $ref references, my components couldn’t handle these unresolved references.
Ideally, I need a way to resolve $refs on the frontend as I use them.
@dev-dsp prior to this, were you maintaining the schemas manually? Do you have any code to share? What are you using to validate the form input?
Description
Given a schema like this:
The schemas.gen.ts contains the following declarations:
I'd like to have an option to include the referred definitions inside the oneOf/anyOf/...
If that's more like
won't fix
issue - I'd be grateful for any workarounds..