Skip to content

cleanupOpenApiDoc doesn''t rewrite renamed $ref in schema.items.$ref for @ZodResponse({ type: [Dto] }) (and other nested ref sites) #375

Description

@thetw

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:

{
  "responses": {
    "default": {
      "content": {
        "application/json": {
          "schema": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/MyDto" }, // ← stale: should be `MyName`
          },
        },
      },
    },
  },
}

…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 :)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions