@@ -8,13 +8,24 @@ export async function loadOpenApiDocument(specPath: string): Promise<Record<stri
88 const raw = await readFile ( absPath , "utf8" ) ;
99 const parsed = parseByExtension ( absPath , raw ) ;
1010
11- if ( ! parsed || typeof parsed !== "object" ) {
11+ if ( ! isRecord ( parsed ) ) {
1212 throw new Error ( `Spec at ${ absPath } is not a JSON object` ) ;
1313 }
1414
15- const dereferenced = await $RefParser . dereference ( absPath , parsed as object ) ;
16- validateShape ( dereferenced as Record < string , unknown > , absPath ) ;
17- return dereferenced as Record < string , unknown > ;
15+ const sanitized = sanitizeBrokenInternalRefs ( parsed , parsed ) ;
16+ try {
17+ const dereferenced = await $RefParser . dereference ( absPath , sanitized as object ) ;
18+ validateShape ( dereferenced as Record < string , unknown > , absPath ) ;
19+ return dereferenced as Record < string , unknown > ;
20+ } catch ( error ) {
21+ if ( ! isMissingPointerError ( error ) ) {
22+ throw error ;
23+ }
24+
25+ const resolved = resolveInternalRefs ( sanitized , sanitized ) ;
26+ validateShape ( resolved , absPath ) ;
27+ return resolved ;
28+ }
1829}
1930
2031function parseByExtension ( path : string , raw : string ) : unknown {
@@ -45,3 +56,143 @@ function validateShape(doc: Record<string, unknown>, source: string): void {
4556 throw new Error ( `Missing or invalid \"paths\" in ${ source } ` ) ;
4657 }
4758}
59+
60+ function sanitizeBrokenInternalRefs < T > ( value : T , root : Record < string , unknown > ) : T {
61+ if ( Array . isArray ( value ) ) {
62+ return value . map ( ( entry ) => sanitizeBrokenInternalRefs ( entry , root ) ) as T ;
63+ }
64+
65+ if ( ! isRecord ( value ) ) {
66+ return value ;
67+ }
68+
69+ const ref = typeof value . $ref === "string" ? value . $ref : undefined ;
70+ if ( ref && isBrokenInternalRef ( ref , root ) ) {
71+ const withoutRef : Record < string , unknown > = { } ;
72+ for ( const [ key , child ] of Object . entries ( value ) ) {
73+ if ( key === "$ref" ) {
74+ continue ;
75+ }
76+ withoutRef [ key ] = sanitizeBrokenInternalRefs ( child , root ) ;
77+ }
78+ return withoutRef as T ;
79+ }
80+
81+ const out : Record < string , unknown > = { } ;
82+ for ( const [ key , child ] of Object . entries ( value ) ) {
83+ out [ key ] = sanitizeBrokenInternalRefs ( child , root ) ;
84+ }
85+ return out as T ;
86+ }
87+
88+ function resolveInternalRefs < T > ( value : T , root : Record < string , unknown > , stack : string [ ] = [ ] ) : T {
89+ if ( Array . isArray ( value ) ) {
90+ return value . map ( ( entry ) => resolveInternalRefs ( entry , root , stack ) ) as T ;
91+ }
92+
93+ if ( ! isRecord ( value ) ) {
94+ return value ;
95+ }
96+
97+ const ref = typeof value . $ref === "string" ? value . $ref : undefined ;
98+ if ( ref && ref . startsWith ( "#" ) ) {
99+ if ( stack . includes ( ref ) ) {
100+ return { } as T ;
101+ }
102+
103+ const resolved = resolveJsonPointer ( root , ref ) ;
104+ const siblings = { ...value } ;
105+ delete siblings . $ref ;
106+
107+ if ( resolved === undefined ) {
108+ return resolveInternalRefs ( siblings as T , root , stack ) ;
109+ }
110+
111+ const resolvedClone = structuredClone ( resolved ) ;
112+ const resolvedValue = resolveInternalRefs ( resolvedClone , root , [ ...stack , ref ] ) ;
113+ if ( isRecord ( resolvedValue ) && Object . keys ( siblings ) . length > 0 ) {
114+ return resolveInternalRefs ( mergeObjects ( resolvedValue , siblings ) as T , root , [ ...stack , ref ] ) ;
115+ }
116+
117+ return resolvedValue as T ;
118+ }
119+
120+ const out : Record < string , unknown > = { } ;
121+ for ( const [ key , child ] of Object . entries ( value ) ) {
122+ out [ key ] = resolveInternalRefs ( child , root , stack ) ;
123+ }
124+ return out as T ;
125+ }
126+
127+ function isBrokenInternalRef ( ref : string , root : Record < string , unknown > ) : boolean {
128+ if ( ref === "#" ) {
129+ return false ;
130+ }
131+
132+ if ( ! ref . startsWith ( "#/" ) ) {
133+ return false ;
134+ }
135+
136+ return resolveJsonPointer ( root , ref ) === undefined ;
137+ }
138+
139+ function resolveJsonPointer ( root : unknown , ref : string ) : unknown {
140+ if ( ref === "#" ) {
141+ return root ;
142+ }
143+
144+ const tokens = ref
145+ . slice ( 2 )
146+ . split ( "/" )
147+ . map ( ( token ) => decodePointerToken ( token ) ) ;
148+
149+ let current : unknown = root ;
150+ for ( const token of tokens ) {
151+ if ( Array . isArray ( current ) ) {
152+ const index = Number . parseInt ( token , 10 ) ;
153+ if ( ! Number . isFinite ( index ) ) {
154+ return undefined ;
155+ }
156+ current = current [ index ] ;
157+ continue ;
158+ }
159+
160+ if ( ! isRecord ( current ) ) {
161+ return undefined ;
162+ }
163+
164+ current = current [ token ] ;
165+ }
166+
167+ return current ;
168+ }
169+
170+ function decodePointerToken ( token : string ) : string {
171+ return token . replace ( / ~ 1 / g, "/" ) . replace ( / ~ 0 / g, "~" ) ;
172+ }
173+
174+ function mergeObjects ( base : Record < string , unknown > , overlay : Record < string , unknown > ) : Record < string , unknown > {
175+ const merged : Record < string , unknown > = { ...base } ;
176+ for ( const [ key , value ] of Object . entries ( overlay ) ) {
177+ const existing = merged [ key ] ;
178+ if ( isRecord ( existing ) && isRecord ( value ) ) {
179+ merged [ key ] = mergeObjects ( existing , value ) ;
180+ continue ;
181+ }
182+ merged [ key ] = value ;
183+ }
184+ return merged ;
185+ }
186+
187+ function isMissingPointerError ( error : unknown ) : boolean {
188+ if ( ! error || typeof error !== "object" ) {
189+ return false ;
190+ }
191+
192+ const candidate = error as { code ?: unknown ; name ?: unknown } ;
193+ return candidate . code === "EMISSINGPOINTER" || candidate . name === "MissingPointerError" ;
194+ }
195+
196+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
197+ return value !== null && typeof value === "object" && ! Array . isArray ( value ) ;
198+ }
0 commit comments