From 2f6a52cd3d71522a0352b2c60f807a8366366583 Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Wed, 19 Aug 2026 11:24:40 +0800 Subject: [PATCH] fix(core): emit a typedef for a schema that is only a $ref A top-level schema whose whole body is a `$ref` is an alias, and OpenAPI 3.1 lets keywords sit beside the `$ref` - nestjs-zod writes `{ id, $ref }` when a DTO is exposed under a second name. Referring properties kept naming the alias while nothing generated a file for it, because getSchemas() drops anything carrying a `$ref` and isEmpty() reads what is left as an empty schema. The result was an import of a file nobody wrote and an undefined type: import 'open_shift_response_dto_v2.f.dart'; // no such file List? openShiftResponses, // no such class build_runner reports it as `Could not generate 'fromJson' code for ... InvalidType`. Aliases now generate a typedef. The re-export alongside the import is load-bearing: freezed writes its output as a `part of` the referring model and resolves the typedef to the underlying class there, so that class has to be in the referring file's scope. With only an import, build_runner still fails with InvalidType. A `$ref` carrying real constraints is an override rather than a second name, and is left to the paths that already handle it. Co-Authored-By: Claude Opus 5 (1M context) --- .../generators/models-ref-alias.test.ts | 88 +++++++++++++++++++ .../core/src/generators/model-generator.ts | 29 ++++++ packages/core/src/generators/models.ts | 20 ++++- packages/core/src/utils/assertion.ts | 25 ++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/__tests__/generators/models-ref-alias.test.ts diff --git a/packages/core/src/__tests__/generators/models-ref-alias.test.ts b/packages/core/src/__tests__/generators/models-ref-alias.test.ts new file mode 100644 index 0000000..6cb3e7e --- /dev/null +++ b/packages/core/src/__tests__/generators/models-ref-alias.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { generateModels } from '../../generators/models'; + +const specWith = (schemas: Record) => ({ + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: {}, + components: { schemas } +}) as any; + +const generate = (spec: any) => generateModels(spec, { + input: spec, + output: { target: './test', mode: 'split', client: 'dio' } +} as any); + +describe('a schema whose body is a $ref', () => { + const base = { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }; + + it('should emit a typedef for an alias carrying sibling keywords', async () => { + // OpenAPI 3.1 allows keywords next to $ref, and nestjs-zod emits `{ id, $ref }` + // when a DTO is aliased under a second name + const spec = specWith({ + OpenShiftResponseDto: base, + OpenShiftResponseDtoV2: { id: 'OpenShiftResponseDtoV2', $ref: '#/components/schemas/OpenShiftResponseDto' } + }); + const files = await generate(spec); + + const alias = files.find(f => f.path === 'models/open_shift_response_dto_v2.f.dart'); + expect(alias!.content).toContain('typedef OpenShiftResponseDtoV2 = OpenShiftResponseDto;'); + expect(alias!.content).toContain("import 'open_shift_response_dto.f.dart';"); + // The re-export is load-bearing: freezed writes its output as a `part of` + // the referring model and resolves the typedef to the underlying class + // there, so that class has to be in the referring file's scope + expect(alias!.content).toContain("export 'open_shift_response_dto.f.dart';"); + }); + + it('should emit a typedef for a bare $ref alias too', async () => { + const spec = specWith({ + TimeOffRequestResponseDto: base, + ManagersRequestsTimeOffRequestItemDto: { $ref: '#/components/schemas/TimeOffRequestResponseDto' } + }); + const files = await generate(spec); + + const alias = files.find(f => f.path === 'models/managers_requests_time_off_request_item_dto.f.dart'); + expect(alias!.content).toContain( + 'typedef ManagersRequestsTimeOffRequestItemDto = TimeOffRequestResponseDto;' + ); + }); + + it('should leave referring models with an import that resolves', async () => { + const spec = specWith({ + OpenShiftResponseDto: base, + OpenShiftResponseDtoV2: { id: 'OpenShiftResponseDtoV2', $ref: '#/components/schemas/OpenShiftResponseDto' }, + ShiftResponseDto: { + type: 'object', + properties: { + openShiftResponses: { type: 'array', items: { $ref: '#/components/schemas/OpenShiftResponseDtoV2' } } + } + } + }); + const files = await generate(spec); + + // The referring model names the alias, so a file has to carry that name + const referrer = files.find(f => f.path === 'models/shift_response_dto.f.dart'); + expect(referrer!.content).toContain("import 'open_shift_response_dto_v2.f.dart';"); + expect(referrer!.content).toContain('List?'); + + const paths = new Set(files.map(f => f.path)); + for (const match of referrer!.content.matchAll(/^import '([a-z0-9_]+\.f\.dart)';$/gm)) { + expect(paths.has(`models/${match[1]}`)).toBe(true); + } + + expect(files.find(f => f.path === 'models/index.dart')!.content) + .toContain("export 'open_shift_response_dto_v2.f.dart';"); + }); + + it('should not treat a $ref carrying constraints as an alias', async () => { + // A $ref with real keywords beside it is an override, not a second name + const spec = specWith({ + Base: base, + Narrowed: { $ref: '#/components/schemas/Base', type: 'object', properties: { extra: { type: 'string' } } } + }); + const files = await generate(spec); + + const narrowed = files.find(f => f.path === 'models/narrowed.f.dart'); + expect(narrowed?.content ?? '').not.toContain('typedef Narrowed ='); + }); +}); diff --git a/packages/core/src/generators/model-generator.ts b/packages/core/src/generators/model-generator.ts index d428ea1..0372e2f 100644 --- a/packages/core/src/generators/model-generator.ts +++ b/packages/core/src/generators/model-generator.ts @@ -99,6 +99,35 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = }; } + /** + * Emit a typedef for a schema whose whole body is a `$ref`. + * + * The re-export is load-bearing. Freezed writes its output as a `part of` the + * referring model, and a typedef resolves to the underlying class there, so + * that class has to be in the referring file's scope. Reaching it through the + * alias import alone leaves json_serializable seeing an InvalidType. + */ + generateRefAliasTypedef(name: string, schema: OpenAPIV3.SchemaObject): GeneratedFile { + const className = TypeMapper.toDartClassName(name); + const fileName = TypeMapper.toSnakeCase(name); + + const targetName = TypeMapper.extractTypeFromRef((schema as any).$ref); + const targetClass = TypeMapper.toDartClassName(targetName); + const targetFile = `${TypeMapper.toSnakeCase(targetName)}.f.dart`; + + const content = `// Generated typedef: this schema is an alias for another +import '${targetFile}'; +export '${targetFile}'; + +${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = ${targetClass}; +`; + + return { + path: `models/${fileName}.f.dart`, + content + }; + } + /** * Emit a typedef for an enum Dart cannot express, so `$ref`s to it still * resolve and the value simply keeps its scalar type. diff --git a/packages/core/src/generators/models.ts b/packages/core/src/generators/models.ts index 419357f..fd45636 100644 --- a/packages/core/src/generators/models.ts +++ b/packages/core/src/generators/models.ts @@ -9,7 +9,7 @@ import { OpenAPIParser } from '../parser/openapi-parser'; import { ReferenceResolver } from '../resolvers'; import { combineSchemas } from '../getters/combine'; import { getObject } from '../getters/object'; -import { hasComposition, hasDiscriminatedUnion, isEnum, isRepresentableEnum, isEmpty } from '../utils/assertion'; +import { hasComposition, hasDiscriminatedUnion, isEnum, isRefAlias, isRepresentableEnum, isEmpty } from '../utils/assertion'; import { TypeMapper } from '../utils'; // Helper functions @@ -298,6 +298,16 @@ export async function generateModels( const parser = new OpenAPIParser(); await parser.parseWithoutDereference(spec); const schemas = parser.getSchemas(); + + // getSchemas() drops every schema whose body is a `$ref`. At the top level + // one of those is an alias, and referring properties still name it, so it + // needs a file of its own - put those back. + const specSchemas = (spec.components?.schemas ?? {}) as Record; + Object.entries(specSchemas).forEach(([name, schema]) => { + if (!schemas[name] && isRefAlias(schema)) { + schemas[name] = schema; + } + }); // Create ReferenceResolver with the full spec const refResolver = new ReferenceResolver(spec); @@ -347,6 +357,14 @@ export async function generateModels( // Generate model for each schema Object.entries(schemas).forEach(([name, schema]) => { + // A schema whose body is just a `$ref` is another name for the target, and + // referring properties keep that name. Skipping it as "empty" left them + // importing a file nobody wrote. + if (isRefAlias(schema)) { + files.push(generator.generateRefAliasTypedef(name, schema)); + return; + } + // Skip completely empty schemas (no type, no properties, no composition) if (isEmpty(schema)) { console.log(`Skipping empty model: ${name}`); diff --git a/packages/core/src/utils/assertion.ts b/packages/core/src/utils/assertion.ts index c121511..d40a112 100644 --- a/packages/core/src/utils/assertion.ts +++ b/packages/core/src/utils/assertion.ts @@ -225,6 +225,31 @@ export function shouldCreateInterface(schema: OpenAPIV3.SchemaObject): boolean { /** * Check if schema is empty (no meaningful content) */ +/** + * Keys that can sit next to a `$ref` without making the schema more than an + * alias. `id` is what nestjs-zod emits when a DTO is aliased under a new name. + */ +const REF_ALIAS_METADATA_KEYS = new Set([ + '$ref', '$id', '$schema', '$comment', 'id', 'title', 'description', + 'example', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'default' +]); + +/** + * Whether a schema is nothing but a reference to another one. + * + * OpenAPI 3.1 allows keywords alongside `$ref`, so `{ id, $ref }` is a whole + * schema body that means "another name for that one". Anything carrying real + * constraints of its own is not an alias but a reference with an override, and + * is left to the paths that already handle it. + */ +export function isRefAlias(schema: any): boolean { + if (!schema || typeof schema !== 'object' || typeof schema.$ref !== 'string') { + return false; + } + + return Object.keys(schema).every(key => REF_ALIAS_METADATA_KEYS.has(key)); +} + export function isEmpty(schema: any): boolean { if (!isSchema(schema)) return true;