From 0c80669bd7a3b559db63132ffb7e5a3c77df2fab Mon Sep 17 00:00:00 2001 From: truffle Date: Sun, 31 May 2026 07:23:59 +0000 Subject: [PATCH] fix: follow chained $ref pointers in `dereference` The walker returned the target of a `$ref` as-is, so when the target was itself `{ $ref: ... }` the chain stopped and the unresolved pointer ended up in the output. Downstream this surfaced as empty response bodies in generated handlers. Now follows the chain to a non-`$ref` value with cycle detection, then recurses to resolve any `$ref`s inside the resulting structure. Signed-off-by: truffle --- src/open-api/utils/dereference.test.ts | 52 ++++++++++++++++++++++++++ src/open-api/utils/dereference.ts | 24 +++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/open-api/utils/dereference.test.ts b/src/open-api/utils/dereference.test.ts index 434b9bb..d7083cd 100644 --- a/src/open-api/utils/dereference.test.ts +++ b/src/open-api/utils/dereference.test.ts @@ -1,5 +1,57 @@ import { dereference } from './dereference.js' +it('follows chained $ref pointers to a leaf value', async () => { + await expect( + dereference({ + foo: { + $ref: '#/components/schemas/A', + }, + components: { + schemas: { + A: { $ref: '#/components/schemas/B' }, + B: { $ref: '#/components/schemas/C' }, + C: { type: 'string' }, + }, + }, + }), + ).resolves.toMatchInlineSnapshot(` + { + "components": { + "schemas": { + "A": { + "type": "string", + }, + "B": { + "type": "string", + }, + "C": { + "type": "string", + }, + }, + }, + "foo": { + "type": "string", + }, + } + `) +}) + +it('throws when a $ref chain is circular', async () => { + await expect( + dereference({ + foo: { + $ref: '#/components/schemas/A', + }, + components: { + schemas: { + A: { $ref: '#/components/schemas/B' }, + B: { $ref: '#/components/schemas/A' }, + }, + }, + }), + ).rejects.toThrow(/circular \$ref chain/i) +}) + it('dereferences', async () => { await expect( dereference({ diff --git a/src/open-api/utils/dereference.ts b/src/open-api/utils/dereference.ts index 904f713..2f4d8f3 100644 --- a/src/open-api/utils/dereference.ts +++ b/src/open-api/utils/dereference.ts @@ -19,8 +19,28 @@ export async function dereference(document: unknown, root?: any): Promise { if (typeof document === 'object' && document !== null) { if ('$ref' in document && typeof document['$ref'] === 'string') { - const path = pointerToPath(document['$ref']) - return path.reduce((item, key) => item[key], root) + const visited = new Set() + let resolved: any = document + while ( + resolved !== null && + typeof resolved === 'object' && + '$ref' in resolved && + typeof resolved['$ref'] === 'string' + ) { + const ref = resolved['$ref'] + if (visited.has(ref)) { + throw new Error( + `Failed to dereference document: circular $ref chain detected (${[ + ...visited, + ref, + ].join(' -> ')})`, + ) + } + visited.add(ref) + const path = pointerToPath(ref) + resolved = path.reduce((item, key) => item[key], root) + } + return dereference(resolved, root) } await Promise.all(