Describe the bug
When a createZodDto schema carries a .meta({ id: '<NewName>' }), cleanupOpenApiDoc correctly renames it under components.schemas (e.g. MemberDto → Member), and fixRefsInBodies rewrites top-level request/response schema.$ref to point at the new name.
However, fixRefsInBodies only inspects schema.$ref directly:
// packages/nestjs-zod/src/cleanupOpenApiDoc.ts
if (responseBodyObject.schema && '$ref' in responseBodyObject.schema) {
// ...rewrite...
}
It does not recurse into nested ref sites. The most common one — and the one that breaks Swagger UI in practice — is schema.items.$ref, which is what @nestjs/swagger emits for array responses declared via @ZodResponse({ type: [MyDto] }).
For an array response, the resulting OpenAPI fragment looks like:
…while components.schemas only contains MyName (the renamed entry). Swagger UI then errors with:
Resolver error at paths./api/v1/.../get.responses.default.content.application/json.schema.items.$ref
Could not resolve reference: Could not resolve pointer: /components/schemas/MyDto does not exist in document
Same shape applies to any other nested ref site that fixRefsInBodies doesn't traverse: schema.additionalProperties.$ref (record-typed responses), schema.oneOf[*].$ref / anyOf[*].$ref / allOf[*].$ref (union/intersection schemas), and inline-object responses whose properties.<key> is a $ref to a renamed schema.
Expected behavior
cleanupOpenApiDoc should rewrite every $ref in the document whose target appears in the rename map, regardless of where it sits in the schema tree (top-level schema.$ref, schema.items.$ref, schema.additionalProperties.$ref, items inside oneOf/anyOf/allOf, nested properties.<key>.$ref, etc.). After the function returns, the entire document should be self-consistent — every $ref resolves to an existing entry in components.schemas.
Actual behavior
Only top-level schema.$ref on request bodies and responses is rewritten. Any $ref that sits one level deeper — items, additionalProperties, oneOf[i], etc. — is left pointing at the old DTO class name, while the renamed schema lives under the meta({ id }) value. Swagger UI raises a "Could not resolve pointer" resolver error and stops rendering the affected endpoint.
The bug affects:
@ZodResponse({ type: [Dto] }) — an array response where the underlying DTO uses meta({ id }) (with or without { codec: true }).
- Any other operation whose request/response schema embeds a
$ref to a renamed DTO via items, additionalProperties, or composition keywords.
Test or repo
Minimal failing Vitest spec — drop into packages/nestjs-zod/src and the assertion on the array-response ref fails (the single-response ref assertion passes, demonstrating the asymmetry):
import { Controller, Get, Param } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { cleanupOpenApiDoc, createZodDto, ZodResponse } from 'nestjs-zod';
const ItemSchema = z.object({ id: z.string() }).meta({ id: 'Item', title: 'Item' });
class ItemDto extends createZodDto(ItemSchema) {}
@Controller('items')
class ItemsController {
@Get()
@ZodResponse({ type: [ItemDto] })
list(): ItemDto[] {
return [];
}
@Get(':id')
@ZodResponse({ type: ItemDto })
get(@Param('id') _id: string): ItemDto {
return { id: '1' };
}
}
describe('cleanupOpenApiDoc rewrites all renamed $refs', () => {
it('rewrites $ref in array-response `schema.items` to the renamed schema id', async () => {
const moduleRef = await Test.createTestingModule({
controllers: [ItemsController],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
try {
const config = new DocumentBuilder().setTitle('t').setVersion('1').build();
const doc = cleanupOpenApiDoc(SwaggerModule.createDocument(app, config));
const schemas = doc.components?.schemas ?? {};
// The schema is registered under its `meta({ id })` value, NOT the
// DTO class name.
expect(schemas).toHaveProperty('Item');
expect(schemas).not.toHaveProperty('ItemDto');
expect(schemas).not.toHaveProperty('ItemDto_Output');
// Single-response ref is rewritten correctly. ✓
const getRef = (doc.paths!['/items/{id}']!.get as any).responses.default.content[
'application/json'
].schema.$ref;
expect(getRef).toBe('#/components/schemas/Item');
// Array-response items ref is NOT rewritten. ✗
// Actual: '#/components/schemas/ItemDto' (or 'ItemDto_Output' if no
// `{ codec: true }` is set on the DTO) — both are absent from
// `components.schemas`, breaking Swagger UI.
const listItemsRef = (doc.paths!['/items']!.get as any).responses.default.content[
'application/json'
].schema.items.$ref;
expect(listItemsRef).toBe('#/components/schemas/Item');
} finally {
await app.close();
}
});
});
Suggested fix sketch — extract a depth-first ref walker and use it from fixRefsInBodies instead of the current shallow check:
function rewriteRefs(node: unknown, renames: Record<string, string>): void {
if (Array.isArray(node)) {
for (const value of node) rewriteRefs(value, renames);
return;
}
if (!node || typeof node !== 'object') return;
const obj = node as Record<string, unknown>;
const ref = obj.$ref;
if (typeof ref === 'string' && ref.startsWith('#/components/schemas/')) {
const oldName = ref.slice('#/components/schemas/'.length);
if (renames[oldName]) {
obj.$ref = `#/components/schemas/${renames[oldName]}`;
}
}
for (const value of Object.values(obj)) rewriteRefs(value, renames);
}
Then in fixRefsInBodies, call rewriteRefs(methodObject?.requestBody, renames) and rewriteRefs(methodObject?.responses, renames) (and arguably rewriteRefs(schemas, renames) after the schema-rename pass, to cover renamed schemas referenced from other schemas).
Workaround I'm using in the meantime: capture the rename map ourselves from the raw doc by reading the x-nestjs_zod-parent-id marker on each schema's first property, run cleanupOpenApiDoc, and then post-process the cleaned doc with the depth-first walker above. Happy to open a PR :)
Describe the bug
When a
createZodDtoschema carries a.meta({ id: '<NewName>' }),cleanupOpenApiDoccorrectly renames it undercomponents.schemas(e.g.MemberDto→Member), andfixRefsInBodiesrewrites top-level request/responseschema.$refto point at the new name.However,
fixRefsInBodiesonly inspectsschema.$refdirectly:It does not recurse into nested ref sites. The most common one — and the one that breaks Swagger UI in practice — is
schema.items.$ref, which is what@nestjs/swaggeremits for array responses declared via@ZodResponse({ type: [MyDto] }).For an array response, the resulting OpenAPI fragment looks like:
{ "responses": { "default": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/MyDto" }, // ← stale: should be `MyName` }, }, }, }, }, }…while
components.schemasonly containsMyName(the renamed entry). Swagger UI then errors with:Same shape applies to any other nested ref site that
fixRefsInBodiesdoesn't traverse:schema.additionalProperties.$ref(record-typed responses),schema.oneOf[*].$ref/anyOf[*].$ref/allOf[*].$ref(union/intersection schemas), and inline-object responses whoseproperties.<key>is a$refto a renamed schema.Expected behavior
cleanupOpenApiDocshould rewrite every$refin the document whose target appears in the rename map, regardless of where it sits in the schema tree (top-levelschema.$ref,schema.items.$ref,schema.additionalProperties.$ref, items insideoneOf/anyOf/allOf, nestedproperties.<key>.$ref, etc.). After the function returns, the entire document should be self-consistent — every$refresolves to an existing entry incomponents.schemas.Actual behavior
Only top-level
schema.$refon request bodies and responses is rewritten. Any$refthat sits one level deeper —items,additionalProperties,oneOf[i], etc. — is left pointing at the old DTO class name, while the renamed schema lives under themeta({ id })value. Swagger UI raises a "Could not resolve pointer" resolver error and stops rendering the affected endpoint.The bug affects:
@ZodResponse({ type: [Dto] })— an array response where the underlying DTO usesmeta({ id })(with or without{ codec: true }).$refto a renamed DTO viaitems,additionalProperties, or composition keywords.Test or repo
Minimal failing Vitest spec — drop into
packages/nestjs-zod/srcand the assertion on the array-response ref fails (the single-response ref assertion passes, demonstrating the asymmetry):Suggested fix sketch — extract a depth-first ref walker and use it from
fixRefsInBodiesinstead of the current shallow check:Then in
fixRefsInBodies, callrewriteRefs(methodObject?.requestBody, renames)andrewriteRefs(methodObject?.responses, renames)(and arguablyrewriteRefs(schemas, renames)after the schema-rename pass, to cover renamed schemas referenced from other schemas).Workaround I'm using in the meantime: capture the rename map ourselves from the raw doc by reading the
x-nestjs_zod-parent-idmarker on each schema's first property, runcleanupOpenApiDoc, and then post-process the cleaned doc with the depth-first walker above. Happy to open a PR :)