From 6041467084ce11a34203515428d28131f9c66570 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 16:00:00 -0400 Subject: [PATCH 1/3] refactor: vendor to reduce deps --- package.json | 1 - pnpm-lock.yaml | 138 ------------------------------------ src/Openapi.ts | 4 +- src/internal/dereference.ts | 60 ++++++++++++++++ 4 files changed, 62 insertions(+), 141 deletions(-) create mode 100644 src/internal/dereference.ts diff --git a/package.json b/package.json index 24efd90..3900f6b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/server": "^2.0.0-alpha.2", - "@readme/openapi-parser": "^6.0.0", "@toon-format/toon": "^2.1.0", "tokenx": "^1.3.0", "yaml": "^2.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b145e5e..cf252c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@modelcontextprotocol/server': specifier: ^2.0.0-alpha.2 version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) - '@readme/openapi-parser': - specifier: ^6.0.0 - version: 6.0.0(openapi-types@12.1.3) '@toon-format/toon': specifier: ^2.1.0 version: 2.1.0 @@ -82,21 +79,11 @@ importers: packages: - '@apidevtools/json-schema-ref-parser@14.2.1': - resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} - engines: {node: '>= 20'} - peerDependencies: - '@types/json-schema': ^7.0.15 - '@asteasolutions/zod-to-openapi@8.4.3': resolution: {integrity: sha512-lwfMTN7kDbFDwMniYZUebiGGHxVGBw9ZSI4IBYjm6Ey22Kd5z/fsQb2k+Okr8WMbCCC553vi/ZM9utl5/XcvuQ==} peerDependencies: zod: ^4.0.0 - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -355,10 +342,6 @@ packages: hono: '>=3.9.0' zod: ^3.25.0 || ^4.0.0 - '@humanwhocodes/momoa@2.0.4': - resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} - engines: {node: '>=10.10.0'} - '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -709,22 +692,6 @@ packages: cpu: [x64] os: [win32] - '@readme/better-ajv-errors@2.4.0': - resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==} - engines: {node: '>=18'} - peerDependencies: - ajv: 4.11.8 - 8 - - '@readme/openapi-parser@6.0.0': - resolution: {integrity: sha512-PaTnrKlKgEJZzjJ77AAhGe28NiyLBdiKMx95rJ9xlLZ8QLqYitMpPBQAKhsuEGOWQQbsIMfBZEPavbXghACQHA==} - engines: {node: '>=20'} - peerDependencies: - openapi-types: '>=7' - - '@readme/openapi-schemas@3.1.0': - resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==} - engines: {node: '>=18'} - '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -878,9 +845,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -925,17 +889,6 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - ajv-draft-04@1.0.0: - resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} - peerDependencies: - ajv: ^8.5.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1029,16 +982,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1147,9 +1094,6 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -1158,20 +1102,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonpointer@5.0.1: - resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} - engines: {node: '>=0.10.0'} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -1209,9 +1142,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} @@ -1305,10 +1235,6 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1549,22 +1475,11 @@ packages: snapshots: - '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': - dependencies: - '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 - '@asteasolutions/zod-to-openapi@8.4.3(zod@4.3.6)': dependencies: openapi3-ts: 4.5.0 zod: 4.3.6 - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -1830,8 +1745,6 @@ snapshots: hono: 4.12.5 zod: 4.3.6 - '@humanwhocodes/momoa@2.0.4': {} - '@inquirer/external-editor@1.0.3(@types/node@25.5.0)': dependencies: chardet: 2.1.1 @@ -2032,28 +1945,6 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.50.0': optional: true - '@readme/better-ajv-errors@2.4.0(ajv@8.18.0)': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 - '@humanwhocodes/momoa': 2.0.4 - ajv: 8.18.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - picocolors: 1.1.1 - - '@readme/openapi-parser@6.0.0(openapi-types@12.1.3)': - dependencies: - '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) - '@readme/better-ajv-errors': 2.4.0(ajv@8.18.0) - '@readme/openapi-schemas': 3.1.0 - '@types/json-schema': 7.0.15 - ajv: 8.18.0 - ajv-draft-04: 1.0.0(ajv@8.18.0) - openapi-types: 12.1.3 - - '@readme/openapi-schemas@3.1.0': {} - '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -2142,8 +2033,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/json-schema@7.0.15': {} - '@types/node@12.20.55': {} '@types/node@25.5.0': @@ -2203,17 +2092,6 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 - ajv-draft-04@1.0.0(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -2323,8 +2201,6 @@ snapshots: extendable-error@0.1.7: {} - fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2333,8 +2209,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-uri@3.1.0: {} - fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -2431,8 +2305,6 @@ snapshots: js-tokens@10.0.0: {} - js-tokens@4.0.0: {} - js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -2442,16 +2314,10 @@ snapshots: dependencies: argparse: 2.0.1 - json-schema-traverse@1.0.0: {} - jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 - jsonpointer@5.0.1: {} - - leven@3.1.0: {} - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -2485,8 +2351,6 @@ snapshots: obug@2.1.1: {} - openapi-types@12.1.3: {} - openapi3-ts@4.5.0: dependencies: yaml: 2.8.2 @@ -2594,8 +2458,6 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 - require-from-string@2.0.2: {} - resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/Openapi.ts b/src/Openapi.ts index 135359f..0564d95 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -1,7 +1,7 @@ -import { dereference } from '@readme/openapi-parser' import { z } from 'zod' import * as Fetch from './Fetch.js' +import { dereference } from './internal/dereference.js' /** A minimal OpenAPI 3.x spec shape. Accepts both hand-written specs and generated ones (e.g. from `@hono/zod-openapi`). */ export type OpenAPISpec = { paths?: {} | undefined } @@ -46,7 +46,7 @@ export async function generateCommands( fetch: FetchHandler, options: { basePath?: string | undefined } = {}, ): Promise> { - const resolved = (await dereference(structuredClone(spec) as any)) as unknown as OpenAPISpec + const resolved = dereference(structuredClone(spec)) as OpenAPISpec const commands = new Map() const paths = (resolved.paths ?? {}) as Record> diff --git a/src/internal/dereference.ts b/src/internal/dereference.ts new file mode 100644 index 0000000..2e2177f --- /dev/null +++ b/src/internal/dereference.ts @@ -0,0 +1,60 @@ +/** + * Dereferences all local `$ref` pointers in a JSON object (e.g. `{"$ref": "#/components/schemas/User"}`), + * replacing them inline with the resolved values. Only handles local (`#/...`) references. + * + * Handles circular references by reusing already-resolved objects. + * + * Based on the dereferencing behavior of `@apidevtools/json-schema-ref-parser` + * (https://github.com/APIDevTools/json-schema-ref-parser), vendored here to avoid + * pulling in the full dependency tree. + */ +export function dereference(root: value): value { + const cache = new Map() + return walk(root, root, cache) as value +} + +function walk(node: unknown, root: unknown, cache: Map): unknown { + if (Array.isArray(node)) return node.map((item) => walk(item, root, cache)) + + if (typeof node !== 'object' || node === null) return node + + const obj = node as Record + + // Resolve $ref pointer + if (typeof obj.$ref === 'string' && obj.$ref.startsWith('#')) { + const ref = obj.$ref + if (cache.has(ref)) return cache.get(ref) + + const resolved = resolvePointer(root, ref) + // Cache before recursing to handle circular refs + cache.set(ref, resolved) + const dereferenced = walk(resolved, root, cache) + cache.set(ref, dereferenced) + return dereferenced + } + + const result: Record = {} + for (const key of Object.keys(obj)) result[key] = walk(obj[key], root, cache) + return result +} + +/** Resolves a JSON Pointer (e.g. `#/components/schemas/User`) against a root object. */ +function resolvePointer(root: unknown, pointer: string): unknown { + // "#" or "#/" → root + const fragment = pointer.slice(1) + if (fragment === '' || fragment === '/') return root + + const parts = fragment + .slice(1) + .split('/') + .map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~')) + + let current: unknown = root + for (const part of parts) { + if (typeof current !== 'object' || current === null) + throw new Error(`Cannot resolve $ref "${pointer}": path segment "${part}" not found`) + current = (current as Record)[part] + if (current === undefined) throw new Error(`Cannot resolve $ref "${pointer}": "${part}" not found`) + } + return current +} From 0b23654170b65bbc1a289404c0141a879cc3dcd2 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 16:00:14 -0400 Subject: [PATCH 2/3] chore: changeset --- .changeset/vendor-dereference.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/vendor-dereference.md diff --git a/.changeset/vendor-dereference.md b/.changeset/vendor-dereference.md new file mode 100644 index 0000000..4e012d3 --- /dev/null +++ b/.changeset/vendor-dereference.md @@ -0,0 +1,5 @@ +--- +"incur": patch +--- + +Replaced `@readme/openapi-parser` with a vendored `dereference` implementation, removing a heavy dependency tree. From e26825c6e1f4ba293b5503b571ac0671ce1525f4 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 16:42:07 -0400 Subject: [PATCH 3/3] chore: up --- src/internal/dereference.test.ts | 695 +++++++++++++++++++++++++++++++ src/internal/dereference.ts | 31 +- 2 files changed, 718 insertions(+), 8 deletions(-) create mode 100644 src/internal/dereference.test.ts diff --git a/src/internal/dereference.test.ts b/src/internal/dereference.test.ts new file mode 100644 index 0000000..6d3ca47 --- /dev/null +++ b/src/internal/dereference.test.ts @@ -0,0 +1,695 @@ +import { describe, expect, test } from 'vitest' + +import { dereference } from './dereference.js' + +describe('dereference', () => { + test('resolves basic $ref', () => { + const spec = { + paths: { + '/users': { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + }, + } + const result = dereference(spec) as any + expect(result.paths['/users'].get.responses['200'].content['application/json'].schema).toEqual({ + type: 'object', + properties: { name: { type: 'string' } }, + }) + }) + + test('resolves nested $ref (ref target contains another ref)', () => { + const spec = { + components: { + schemas: { + Name: { type: 'string' }, + User: { + type: 'object', + properties: { name: { $ref: '#/components/schemas/Name' } }, + }, + }, + }, + root: { $ref: '#/components/schemas/User' }, + } + const result = dereference(spec) as any + expect(result.root).toEqual({ + type: 'object', + properties: { name: { type: 'string' } }, + }) + }) + + test('handles circular $ref without infinite loop', () => { + const spec = { + components: { + schemas: { + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + child: { $ref: '#/components/schemas/Node' }, + }, + }, + }, + }, + root: { $ref: '#/components/schemas/Node' }, + } + const result = dereference(spec) as any + // Should resolve without hanging + expect(result.root.type).toBe('object') + expect(result.root.properties.value).toEqual({ type: 'string' }) + // Circular ref should point back to the same resolved object + expect(result.root.properties.child).toBe(result.root) + }) + + test('resolves multiple refs to same target (shares identity)', () => { + const spec = { + components: { schemas: { Id: { type: 'number' } } }, + a: { $ref: '#/components/schemas/Id' }, + b: { $ref: '#/components/schemas/Id' }, + } + const result = dereference(spec) as any + expect(result.a).toEqual({ type: 'number' }) + expect(result.a).toBe(result.b) + }) + + test('resolves $ref in arrays', () => { + const spec = { + components: { schemas: { Tag: { type: 'string' } } }, + items: [{ $ref: '#/components/schemas/Tag' }, { $ref: '#/components/schemas/Tag' }], + } + const result = dereference(spec) as any + expect(result.items[0]).toEqual({ type: 'string' }) + expect(result.items[1]).toEqual({ type: 'string' }) + }) + + test('handles deeply nested path', () => { + const spec = { + a: { b: { c: { d: { value: 42 } } } }, + ref: { $ref: '#/a/b/c/d' }, + } + const result = dereference(spec) as any + expect(result.ref).toEqual({ value: 42 }) + }) + + test('handles JSON Pointer escaping (~0 for ~, ~1 for /)', () => { + const spec = { + 'a/b': { 'c~d': { value: 'escaped' } }, + ref: { $ref: '#/a~1b/c~0d' }, + } + const result = dereference(spec) as any + expect(result.ref).toEqual({ value: 'escaped' }) + }) + + test('throws on unresolvable $ref', () => { + const spec = { ref: { $ref: '#/does/not/exist' } } + expect(() => dereference(spec)).toThrow('Cannot resolve $ref') + }) + + test('passes through primitives unchanged', () => { + expect(dereference('hello')).toBe('hello') + expect(dereference(42)).toBe(42) + expect(dereference(null)).toBe(null) + expect(dereference(true)).toBe(true) + }) + + test('does not mutate original object', () => { + const spec = { + components: { schemas: { User: { type: 'object' } } }, + ref: { $ref: '#/components/schemas/User' }, + } + const original = JSON.stringify(spec) + dereference(spec) + expect(JSON.stringify(spec)).toBe(original) + }) + + test('resolves $ref: "#" to root', () => { + const spec = { type: 'object', self: { $ref: '#' } } + const result = dereference(spec) as any + expect(result.self.type).toBe('object') + }) + + test('realistic OpenAPI spec with shared parameter and request body refs', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/{id}': { + get: { + operationId: 'getUser', + parameters: [{ $ref: '#/components/parameters/UserId' }], + responses: { + '200': { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + put: { + operationId: 'updateUser', + parameters: [{ $ref: '#/components/parameters/UserId' }], + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UserInput' }, + }, + }, + }, + }, + }, + }, + components: { + parameters: { + UserId: { + name: 'id', + in: 'path', + required: true, + schema: { type: 'number' }, + }, + }, + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }, + UserInput: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + }, + } + const result = dereference(spec) as any + const getParams = result.paths['/users/{id}'].get.parameters + expect(getParams[0].name).toBe('id') + expect(getParams[0].in).toBe('path') + // Both GET and PUT share the same resolved parameter + const putParams = result.paths['/users/{id}'].put.parameters + expect(putParams[0]).toBe(getParams[0]) + // Request body schema resolved + const bodySchema = + result.paths['/users/{id}'].put.requestBody.content['application/json'].schema + expect(bodySchema.properties.name).toEqual({ type: 'string' }) + expect(bodySchema.required).toEqual(['name']) + }) + + test('mutual circular refs', () => { + const spec = { + components: { + schemas: { + A: { + type: 'object', + properties: { b: { $ref: '#/components/schemas/B' } }, + }, + B: { + type: 'object', + properties: { a: { $ref: '#/components/schemas/A' } }, + }, + }, + }, + root: { $ref: '#/components/schemas/A' }, + } + const result = dereference(spec) as any + expect(result.root.type).toBe('object') + expect(result.root.properties.b.type).toBe('object') + expect(result.root.properties.b.properties.a).toBe(result.root) + }) + + test('$ref target is a primitive (string)', () => { + const spec = { + components: { values: { name: 'Alice' } }, + ref: { $ref: '#/components/values/name' }, + } + const result = dereference(spec) as any + expect(result.ref).toBe('Alice') + }) + + test('$ref target is a primitive (number)', () => { + const spec = { + components: { values: { count: 42 } }, + ref: { $ref: '#/components/values/count' }, + } + const result = dereference(spec) as any + expect(result.ref).toBe(42) + }) + + test('$ref target is null', () => { + const spec = { + components: { values: { empty: null } }, + ref: { $ref: '#/components/values/empty' }, + } + const result = dereference(spec) as any + expect(result.ref).toBe(null) + }) + + test('$ref target is an array', () => { + const spec = { + components: { values: { tags: ['a', 'b', 'c'] } }, + ref: { $ref: '#/components/values/tags' }, + } + const result = dereference(spec) as any + expect(result.ref).toEqual(['a', 'b', 'c']) + }) + + test('$ref to array element by index', () => { + const spec = { + items: [{ name: 'first' }, { name: 'second' }], + ref: { $ref: '#/items/1' }, + } + const result = dereference(spec) as any + expect(result.ref).toEqual({ name: 'second' }) + }) + + test('chain of refs (A -> B -> C)', () => { + const spec = { + a: { $ref: '#/b' }, + b: { $ref: '#/c' }, + c: { value: 'end' }, + } + const result = dereference(spec) as any + expect(result.a).toEqual({ value: 'end' }) + expect(result.b).toEqual({ value: 'end' }) + }) + + test('triple circular (A -> B -> C -> A)', () => { + const spec = { + components: { + schemas: { + A: { type: 'A', next: { $ref: '#/components/schemas/B' } }, + B: { type: 'B', next: { $ref: '#/components/schemas/C' } }, + C: { type: 'C', next: { $ref: '#/components/schemas/A' } }, + }, + }, + root: { $ref: '#/components/schemas/A' }, + } + const result = dereference(spec) as any + expect(result.root.type).toBe('A') + expect(result.root.next.type).toBe('B') + expect(result.root.next.next.type).toBe('C') + expect(result.root.next.next.next).toBe(result.root) + }) + + test('$ref with sibling properties (OpenAPI 3.1 style)', () => { + const spec = { + components: { + schemas: { + User: { type: 'object', properties: { name: { type: 'string' } } }, + }, + }, + ref: { + $ref: '#/components/schemas/User', + description: 'A user object', + }, + } + const result = dereference(spec) as any + // siblings are dropped (ref replaces the whole node) + expect(result.ref.type).toBe('object') + expect(result.ref.description).toBeUndefined() + }) + + test('root is an array', () => { + const root = [{ a: 1 }, { b: 2 }] + const result = dereference(root) as any + expect(result).toEqual([{ a: 1 }, { b: 2 }]) + }) + + test('empty object', () => { + expect(dereference({})).toEqual({}) + }) + + test('$ref "#/" resolves to root', () => { + const spec = { type: 'root', self: { $ref: '#/' } } + const result = dereference(spec) as any + expect(result.self.type).toBe('root') + }) + + test('falsy primitive targets (false, 0, empty string)', () => { + const spec = { + vals: { a: false, b: 0, c: '' }, + refA: { $ref: '#/vals/a' }, + refB: { $ref: '#/vals/b' }, + refC: { $ref: '#/vals/c' }, + } + const result = dereference(spec) as any + expect(result.refA).toBe(false) + expect(result.refB).toBe(0) + expect(result.refC).toBe('') + }) + + test('$ref target is an empty array', () => { + const spec = { + vals: { empty: [] as unknown[] }, + ref: { $ref: '#/vals/empty' }, + } + const result = dereference(spec) as any + expect(result.ref).toEqual([]) + }) + + test('$ref target is an empty object', () => { + const spec = { + vals: { empty: {} }, + ref: { $ref: '#/vals/empty' }, + } + const result = dereference(spec) as any + expect(result.ref).toEqual({}) + }) + + test('chained ref to primitive (A -> B -> string)', () => { + const spec = { + vals: { greeting: 'hello' }, + b: { $ref: '#/vals/greeting' }, + a: { $ref: '#/b' }, + } + const result = dereference(spec) as any + expect(result.a).toBe('hello') + expect(result.b).toBe('hello') + }) + + test('$ref with non-string value is treated as normal object', () => { + const spec = { obj: { $ref: 123, other: 'value' } } + const result = dereference(spec) as any + expect(result.obj).toEqual({ $ref: 123, other: 'value' }) + }) + + test('$ref that does not start with # is left as-is', () => { + const spec = { obj: { $ref: 'http://example.com/schema.json' } } + const result = dereference(spec) as any + expect(result.obj).toEqual({ $ref: 'http://example.com/schema.json' }) + }) + + test('$ref inside array inside a $ref target', () => { + const spec = { + components: { + schemas: { + Tag: { type: 'string' }, + User: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { $ref: '#/components/schemas/Tag' }, + }, + }, + }, + }, + }, + root: { $ref: '#/components/schemas/User' }, + } + const result = dereference(spec) as any + expect(result.root.properties.tags.items).toEqual({ type: 'string' }) + }) + + test('allOf/oneOf/anyOf with $ref items', () => { + const spec = { + components: { + schemas: { + Name: { type: 'string' }, + Age: { type: 'number' }, + }, + }, + root: { + allOf: [ + { $ref: '#/components/schemas/Name' }, + { $ref: '#/components/schemas/Age' }, + ], + }, + } + const result = dereference(spec) as any + expect(result.root.allOf[0]).toEqual({ type: 'string' }) + expect(result.root.allOf[1]).toEqual({ type: 'number' }) + }) + + test('$ref inside deeply nested arrays', () => { + const spec = { + vals: { x: { value: 1 } }, + nested: [[{ $ref: '#/vals/x' }]], + } + const result = dereference(spec) as any + expect(result.nested[0][0]).toEqual({ value: 1 }) + }) + + test('circular ref inside an array (items ref self)', () => { + const spec = { + components: { + schemas: { + Tree: { + type: 'object', + properties: { + children: { + type: 'array', + items: { $ref: '#/components/schemas/Tree' }, + }, + }, + }, + }, + }, + root: { $ref: '#/components/schemas/Tree' }, + } + const result = dereference(spec) as any + expect(result.root.type).toBe('object') + expect(result.root.properties.children.items).toBe(result.root) + }) + + test('$ref target is a boolean true', () => { + const spec = { + vals: { flag: true }, + ref: { $ref: '#/vals/flag' }, + } + const result = dereference(spec) as any + expect(result.ref).toBe(true) + }) + + test('same $ref used in different subtrees resolves identically', () => { + const spec = { + components: { schemas: { S: { type: 'object' } } }, + tree: { + left: { schema: { $ref: '#/components/schemas/S' } }, + right: { schema: { $ref: '#/components/schemas/S' } }, + }, + } + const result = dereference(spec) as any + expect(result.tree.left.schema).toBe(result.tree.right.schema) + }) + + test('ref target with array value containing refs', () => { + const spec = { + components: { + schemas: { Tag: { type: 'string' } }, + lists: { + tags: [{ $ref: '#/components/schemas/Tag' }, { literal: true }], + }, + }, + ref: { $ref: '#/components/lists/tags' }, + } + const result = dereference(spec) as any + expect(result.ref[0]).toEqual({ type: 'string' }) + expect(result.ref[1]).toEqual({ literal: true }) + }) + + test('root object is itself a $ref (self-referential)', () => { + const spec = { $ref: '#', type: 'object' } + const result = dereference(spec) as any + // $ref takes precedence, siblings (type) are dropped per OpenAPI 3.0. + // Circular self-ref resolves without infinite loop. + expect(result).toBeDefined() + expect(result.type).toBeUndefined() + }) + + test('non-local $ref is preserved (not resolved)', () => { + const spec = { + a: { $ref: 'https://example.com/schema.json#/Foo' }, + b: { $ref: './other.yaml#/Bar' }, + c: { $ref: 'relative.json' }, + } + const result = dereference(spec) as any + expect(result.a.$ref).toBe('https://example.com/schema.json#/Foo') + expect(result.b.$ref).toBe('./other.yaml#/Bar') + expect(result.c.$ref).toBe('relative.json') + }) + + test('$ref target contains a non-local $ref (preserved after deref)', () => { + const spec = { + components: { + schemas: { + External: { type: 'object', nested: { $ref: 'https://example.com/other.json' } }, + }, + }, + root: { $ref: '#/components/schemas/External' }, + } + const result = dereference(spec) as any + expect(result.root.type).toBe('object') + expect(result.root.nested.$ref).toBe('https://example.com/other.json') + }) + + test('forward reference (A uses B, B defined after A)', () => { + const spec = { + components: { + schemas: { + A: { type: 'object', child: { $ref: '#/components/schemas/B' } }, + B: { type: 'string' }, + }, + }, + root: { $ref: '#/components/schemas/A' }, + } + const result = dereference(spec) as any + expect(result.root.child).toEqual({ type: 'string' }) + }) + + test('deep chain of refs (A -> B -> C -> D -> E -> value)', () => { + const spec = { + a: { $ref: '#/b' }, + b: { $ref: '#/c' }, + c: { $ref: '#/d' }, + d: { $ref: '#/e' }, + e: { value: 'deep' }, + } + const result = dereference(spec) as any + expect(result.a).toEqual({ value: 'deep' }) + }) + + test('deep chain of refs to array', () => { + const spec = { + a: { $ref: '#/b' }, + b: { $ref: '#/c' }, + c: [1, 2, 3], + } + const result = dereference(spec) as any + expect(result.a).toEqual([1, 2, 3]) + }) + + test('combined ~0 and ~1 escaping in same pointer segment', () => { + const spec = { + 'a~/b': { value: 'complex' }, + ref: { $ref: '#/a~0~1b' }, + } + const result = dereference(spec) as any + expect(result.ref).toEqual({ value: 'complex' }) + }) + + test('$ref to nested value inside a ref target', () => { + const spec = { + components: { + schemas: { + User: { + type: 'object', + properties: { name: { type: 'string', maxLength: 100 } }, + }, + }, + }, + nameSchema: { $ref: '#/components/schemas/User/properties/name' }, + } + const result = dereference(spec) as any + expect(result.nameSchema).toEqual({ type: 'string', maxLength: 100 }) + }) + + test('multiple independent circular cycles', () => { + const spec = { + components: { + schemas: { + X: { type: 'X', self: { $ref: '#/components/schemas/X' } }, + Y: { type: 'Y', self: { $ref: '#/components/schemas/Y' } }, + }, + }, + refX: { $ref: '#/components/schemas/X' }, + refY: { $ref: '#/components/schemas/Y' }, + } + const result = dereference(spec) as any + expect(result.refX.type).toBe('X') + expect(result.refX.self).toBe(result.refX) + expect(result.refY.type).toBe('Y') + expect(result.refY.self).toBe(result.refY) + // X and Y are distinct + expect(result.refX).not.toBe(result.refY) + }) + + test('object with constructor/toString keys (no prototype issues)', () => { + const spec = { + vals: { constructor: { value: 1 }, toString: { value: 2 } }, + a: { $ref: '#/vals/constructor' }, + b: { $ref: '#/vals/toString' }, + } + const result = dereference(spec) as any + expect(result.a).toEqual({ value: 1 }) + expect(result.b).toEqual({ value: 2 }) + }) + + test('ref to boolean nested inside object', () => { + const spec = { + config: { features: { enabled: true, disabled: false } }, + a: { $ref: '#/config/features/enabled' }, + b: { $ref: '#/config/features/disabled' }, + } + const result = dereference(spec) as any + expect(result.a).toBe(true) + expect(result.b).toBe(false) + }) + + test('array of $refs to different types', () => { + const spec = { + vals: { str: 'hello', num: 42, obj: { x: 1 }, arr: [1, 2] }, + refs: [ + { $ref: '#/vals/str' }, + { $ref: '#/vals/num' }, + { $ref: '#/vals/obj' }, + { $ref: '#/vals/arr' }, + ], + } + const result = dereference(spec) as any + expect(result.refs[0]).toBe('hello') + expect(result.refs[1]).toBe(42) + expect(result.refs[2]).toEqual({ x: 1 }) + expect(result.refs[3]).toEqual([1, 2]) + }) + + test('circular ref where first encounter is NOT via $ref', () => { + // Schema defines Node inline (not behind a $ref), but Node's child uses $ref + const spec = { + components: { + schemas: { + Node: { + type: 'object', + properties: { + child: { $ref: '#/components/schemas/Node' }, + }, + }, + }, + }, + // Access Node directly through the tree walk, not via $ref + direct: { + schema: { + type: 'wrapper', + inner: { $ref: '#/components/schemas/Node' }, + }, + }, + } + const result = dereference(spec) as any + expect(result.direct.schema.inner.type).toBe('object') + expect(result.direct.schema.inner.properties.child).toBe(result.direct.schema.inner) + }) +}) diff --git a/src/internal/dereference.ts b/src/internal/dereference.ts index 2e2177f..7be0c39 100644 --- a/src/internal/dereference.ts +++ b/src/internal/dereference.ts @@ -2,11 +2,11 @@ * Dereferences all local `$ref` pointers in a JSON object (e.g. `{"$ref": "#/components/schemas/User"}`), * replacing them inline with the resolved values. Only handles local (`#/...`) references. * - * Handles circular references by reusing already-resolved objects. + * Handles circular references by caching a mutable placeholder before recursing. * - * Based on the dereferencing behavior of `@apidevtools/json-schema-ref-parser` - * (https://github.com/APIDevTools/json-schema-ref-parser), vendored here to avoid - * pulling in the full dependency tree. + * Minimal reimplementation of the dereferencing behavior from `@apidevtools/json-schema-ref-parser` + * (https://github.com/APIDevTools/json-schema-ref-parser). Only supports in-memory, local-pointer + * resolution — no file/URL resolution, no `$id` scoping. */ export function dereference(root: value): value { const cache = new Map() @@ -26,11 +26,26 @@ function walk(node: unknown, root: unknown, cache: Map): unknow if (cache.has(ref)) return cache.get(ref) const resolved = resolvePointer(root, ref) - // Cache before recursing to handle circular refs - cache.set(ref, resolved) + + // Non-object targets (primitives, arrays) can't be circular — resolve directly + if (typeof resolved !== 'object' || resolved === null || Array.isArray(resolved)) { + const dereferenced = walk(resolved, root, cache) + cache.set(ref, dereferenced) + return dereferenced + } + + // Use a mutable placeholder so circular refs resolve to the same object. + // If the walked result is not a plain object (e.g. chained ref to primitive/array), + // skip the placeholder and cache directly. + const placeholder: Record = {} + cache.set(ref, placeholder) const dereferenced = walk(resolved, root, cache) - cache.set(ref, dereferenced) - return dereferenced + if (typeof dereferenced !== 'object' || dereferenced === null || Array.isArray(dereferenced)) { + cache.set(ref, dereferenced) + return dereferenced + } + Object.assign(placeholder, dereferenced) + return placeholder } const result: Record = {}