Skip to content
Open
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
88 changes: 88 additions & 0 deletions packages/core/src/__tests__/generators/models-ref-alias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { generateModels } from '../../generators/models';

const specWith = (schemas: Record<string, any>) => ({
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<OpenShiftResponseDtoV2>?');

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 =');
});
});
29 changes: 29 additions & 0 deletions packages/core/src/generators/model-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/generators/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, any>;
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);
Expand Down Expand Up @@ -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}`);
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/utils/assertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading