Skip to content

Commit 0ad63e8

Browse files
authored
feat(reference): avoid re-resolving Arazzo source descriptions during dereferencing (#77)
1 parent 448a099 commit 0ad63e8

File tree

6 files changed

+286
-268
lines changed

6 files changed

+286
-268
lines changed

packages/apidom-reference/README.md

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,10 @@ if (parsedDoc.errors.length > 0) {
383383
##### Low-level API
384384

385385
For advanced use cases where you need to parse source descriptions from an already-parsed Arazzo document
386-
(e.g., when using naked parser adapters directly), the `parseSourceDescriptions` function is exported:
386+
(e.g., when using naked parser adapters directly), the `parseSourceDescriptions` function is exported.
387+
388+
When called directly, this function always parses source descriptions (no need to pass `sourceDescriptions: true`).
389+
Use an array to filter by name:
387390

388391
```js
389392
import { parse } from '@speclynx/apidom-parser-adapter-arazzo-json-1';
@@ -393,11 +396,18 @@ import { options, mergeOptions } from '@speclynx/apidom-reference';
393396
// Parse using naked parser adapter
394397
const parseResult = await parse(arazzoJsonString);
395398

396-
// Parse source descriptions separately
399+
// Parse all source descriptions
397400
const sourceDescriptions = await parseSourceDescriptions(
398401
parseResult,
399402
'/path/to/arazzo.json',
400-
mergeOptions(options, { parse: { parserOpts: { sourceDescriptions: true } } }),
403+
options,
404+
);
405+
406+
// Or filter by name
407+
const filtered = await parseSourceDescriptions(
408+
parseResult,
409+
'/path/to/arazzo.json',
410+
mergeOptions(options, { parse: { parserOpts: { sourceDescriptions: ['petStore'] } } }),
401411
);
402412

403413
// Access parsed document from source description element
@@ -456,7 +466,10 @@ See [arazzo-json-1 Accessing parsed documents](#accessing-parsed-documents-via-s
456466
##### Low-level API
457467

458468
For advanced use cases where you need to parse source descriptions from an already-parsed Arazzo document
459-
(e.g., when using naked parser adapters directly), the `parseSourceDescriptions` function is exported:
469+
(e.g., when using naked parser adapters directly), the `parseSourceDescriptions` function is exported.
470+
471+
When called directly, this function always parses source descriptions (no need to pass `sourceDescriptions: true`).
472+
Use an array to filter by name:
460473

461474
```js
462475
import { parse } from '@speclynx/apidom-parser-adapter-arazzo-yaml-1';
@@ -466,11 +479,18 @@ import { options, mergeOptions } from '@speclynx/apidom-reference';
466479
// Parse using naked parser adapter
467480
const parseResult = await parse(arazzoYamlString);
468481

469-
// Parse source descriptions separately
482+
// Parse all source descriptions
470483
const sourceDescriptions = await parseSourceDescriptions(
471484
parseResult,
472485
'/path/to/arazzo.yaml',
473-
mergeOptions(options, { parse: { parserOpts: { sourceDescriptions: true } } }),
486+
options,
487+
);
488+
489+
// Or filter by name
490+
const filtered = await parseSourceDescriptions(
491+
parseResult,
492+
'/path/to/arazzo.yaml',
493+
mergeOptions(options, { parse: { parserOpts: { sourceDescriptions: ['petStore'] } } }),
474494
);
475495

476496
// Access parsed document from source description element
@@ -1812,7 +1832,10 @@ if (dereferencedDoc.errors.length > 0) {
18121832
###### Low-level API
18131833

18141834
For advanced use cases where you need to dereference source descriptions from an already-parsed (optionally dereferenced)
1815-
Arazzo document (e.g., when using naked parser adapters directly), the `dereferenceSourceDescriptions` function is exported:
1835+
Arazzo document (e.g., when using naked parser adapters directly), the `dereferenceSourceDescriptions` function is exported.
1836+
1837+
When called directly, this function always dereferences source descriptions (no need to pass `sourceDescriptions: true`).
1838+
Use an array to filter by name:
18161839

18171840
```js
18181841
import { parse } from '@speclynx/apidom-parser-adapter-arazzo-json-1';
@@ -1822,11 +1845,18 @@ import { options, mergeOptions } from '@speclynx/apidom-reference';
18221845
// Parse using naked parser adapter
18231846
const parseResult = await parse(arazzoJsonString);
18241847

1825-
// Dereference source descriptions separately
1848+
// Dereference all source descriptions
18261849
const sourceDescriptions = await dereferenceSourceDescriptions(
18271850
parseResult,
18281851
'/path/to/arazzo.json',
1829-
mergeOptions(options, { dereference: { strategyOpts: { sourceDescriptions: true } } }),
1852+
options,
1853+
);
1854+
1855+
// Or filter by name
1856+
const filtered = await dereferenceSourceDescriptions(
1857+
parseResult,
1858+
'/path/to/arazzo.json',
1859+
mergeOptions(options, { dereference: { strategyOpts: { sourceDescriptions: ['petStore'] } } }),
18301860
);
18311861

18321862
// Access dereferenced document from source description element

packages/apidom-reference/src/dereference/strategies/arazzo-1/source-descriptions.ts

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AnnotationElement,
55
isArrayElement,
66
isStringElement,
7+
isParseResultElement,
78
cloneDeep,
89
} from '@speclynx/apidom-datamodel';
910
import {
@@ -18,14 +19,12 @@ import { toValue } from '@speclynx/apidom-core';
1819
import * as url from '../../../util/url.ts';
1920
import type { ReferenceOptions } from '../../../options/index.ts';
2021
import { merge as mergeOptions } from '../../../options/util.ts';
21-
import dereference from '../../index.ts';
22-
23-
// shared key for recursion state (works across JSON/YAML documents)
24-
const ARAZZO_DEREFERENCE_RECURSION_KEY = 'arazzo-1';
22+
import dereference, { dereferenceApiDOM } from '../../index.ts';
2523

2624
interface DereferenceSourceDescriptionContext {
2725
baseURI: string;
2826
options: ReferenceOptions;
27+
strategyName: string;
2928
currentDepth: number;
3029
visitedUrls: Set<string>;
3130
}
@@ -66,7 +65,8 @@ async function dereferenceSourceDescription(
6665
return parseResult;
6766
}
6867

69-
const retrievalURI = url.resolve(ctx.baseURI, sourceDescriptionURI);
68+
// normalize URI for consistent cycle detection and refSet cache key matching
69+
const retrievalURI = url.sanitize(url.stripHash(url.resolve(ctx.baseURI, sourceDescriptionURI)));
7070

7171
// skip if already visited (cycle detection)
7272
if (ctx.visitedUrls.has(retrievalURI)) {
@@ -79,23 +79,59 @@ async function dereferenceSourceDescription(
7979
}
8080
ctx.visitedUrls.add(retrievalURI);
8181

82+
// check if source description was already parsed (e.g., during parse phase with sourceDescriptions: true)
83+
const existingParseResult = sourceDescription.meta.get('parseResult');
84+
8285
try {
83-
const sourceDescriptionDereferenced = await dereference(
84-
retrievalURI,
85-
mergeOptions(ctx.options, {
86-
parse: {
87-
mediaType: 'text/plain', // allow parser plugin detection
88-
},
89-
dereference: {
90-
strategyOpts: {
91-
[ARAZZO_DEREFERENCE_RECURSION_KEY]: {
92-
sourceDescriptionsDepth: ctx.currentDepth + 1,
93-
sourceDescriptionsVisitedUrls: ctx.visitedUrls,
86+
let sourceDescriptionDereferenced: ParseResultElement;
87+
88+
if (isParseResultElement(existingParseResult)) {
89+
// use existing parsed result - just dereference it (no re-fetch/re-parse)
90+
sourceDescriptionDereferenced = await dereferenceApiDOM(
91+
existingParseResult,
92+
mergeOptions(ctx.options, {
93+
parse: {
94+
mediaType: 'text/plain', // allow dereference strategy detection via ApiDOM inspection
95+
},
96+
resolve: { baseURI: retrievalURI },
97+
dereference: {
98+
strategyOpts: {
99+
// nested documents should dereference all their source descriptions
100+
// (parent's name filter doesn't apply to nested documents)
101+
// set at strategy-specific level to override any inherited filters
102+
[ctx.strategyName]: {
103+
sourceDescriptions: true,
104+
sourceDescriptionsDepth: ctx.currentDepth + 1,
105+
sourceDescriptionsVisitedUrls: ctx.visitedUrls,
106+
},
94107
},
95108
},
96-
},
97-
}),
98-
);
109+
}),
110+
);
111+
} else {
112+
// no existing parse result - fetch, parse, and dereference
113+
sourceDescriptionDereferenced = await dereference(
114+
retrievalURI,
115+
mergeOptions(ctx.options, {
116+
parse: {
117+
mediaType: 'text/plain', // allow parser plugin detection
118+
},
119+
dereference: {
120+
strategyOpts: {
121+
// nested documents should dereference all their source descriptions
122+
// (parent's name filter doesn't apply to nested documents)
123+
// set at strategy-specific level to override any inherited filters
124+
[ctx.strategyName]: {
125+
sourceDescriptions: true,
126+
sourceDescriptionsDepth: ctx.currentDepth + 1,
127+
sourceDescriptionsVisitedUrls: ctx.visitedUrls,
128+
},
129+
},
130+
},
131+
}),
132+
);
133+
}
134+
99135
// merge dereferenced result into our parse result
100136
for (const item of sourceDescriptionDereferenced) {
101137
parseResult.push(item);
@@ -159,18 +195,27 @@ async function dereferenceSourceDescription(
159195
*
160196
* @param parseResult - ParseResult containing a parsed (optionally dereferenced) Arazzo specification
161197
* @param parseResultRetrievalURI - URI from which the parseResult was retrieved
162-
* @param options - Full ReferenceOptions (caller responsibility to construct)
198+
* @param options - Full ReferenceOptions. Pass `sourceDescriptions` as an array of names
199+
* in `dereference.strategyOpts` to filter which source descriptions to process.
163200
* @param strategyName - Strategy name for options lookup (defaults to 'arazzo-1')
164-
* @returns Array of ParseResultElements. On success, returns one ParseResultElement per
165-
* source description (each with class 'source-description' and name/type metadata).
201+
* @returns Array of ParseResultElements. Returns one ParseResultElement per source description
202+
* (each with class 'source-description' and name/type metadata).
166203
* May return early with a single-element array containing a warning annotation when:
167204
* - The API is not an Arazzo specification
168205
* - The sourceDescriptions field is missing or not an array
169206
* - Maximum dereference depth is exceeded (error annotation)
170-
* Returns an empty array when sourceDescriptions option is disabled or no names match.
207+
* Returns an empty array when no source description names match the filter.
171208
*
172209
* @example
173210
* ```typescript
211+
* // Dereference all source descriptions
212+
* await dereferenceSourceDescriptions(parseResult, uri, options);
213+
*
214+
* // Filter by name
215+
* await dereferenceSourceDescriptions(parseResult, uri, mergeOptions(options, {
216+
* dereference: { strategyOpts: { sourceDescriptions: ['petStore'] } },
217+
* }));
218+
*
174219
* // Access dereferenced document from source description element
175220
* const sourceDesc = parseResult.api.sourceDescriptions.get(0);
176221
* const dereferencedDoc = sourceDesc.meta.get('parseResult');
@@ -215,8 +260,8 @@ export async function dereferenceSourceDescriptions(
215260
options?.dereference?.strategyOpts?.sourceDescriptionsMaxDepth ??
216261
+Infinity;
217262

218-
// recursion state comes from shared key (works across JSON/YAML)
219-
const sharedOpts = options?.dereference?.strategyOpts?.[ARAZZO_DEREFERENCE_RECURSION_KEY] ?? {};
263+
// recursion state comes from strategy-specific options
264+
const sharedOpts = options?.dereference?.strategyOpts?.[strategyName] ?? {};
220265
const currentDepth = sharedOpts.sourceDescriptionsDepth ?? 0;
221266
const visitedUrls: Set<string> = sharedOpts.sourceDescriptionsVisitedUrls ?? new Set();
222267

@@ -236,20 +281,16 @@ export async function dereferenceSourceDescriptions(
236281
const ctx: DereferenceSourceDescriptionContext = {
237282
baseURI,
238283
options,
284+
strategyName,
239285
currentDepth,
240286
visitedUrls,
241287
};
242288

243-
// determine which source descriptions to dereference
289+
// determine which source descriptions to dereference (array filters by name)
244290
const sourceDescriptionsOption =
245291
options?.dereference?.strategyOpts?.[strategyName]?.sourceDescriptions ??
246292
options?.dereference?.strategyOpts?.sourceDescriptions;
247293

248-
// handle false or other falsy values - no source descriptions should be dereferenced
249-
if (!sourceDescriptionsOption) {
250-
return results;
251-
}
252-
253294
const sourceDescriptions = Array.isArray(sourceDescriptionsOption)
254295
? api.sourceDescriptions.filter((sd) => {
255296
if (!isSourceDescriptionElement(sd)) return false;

packages/apidom-reference/src/parse/parsers/arazzo-json-1/source-descriptions.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ async function parseSourceDescription(
6767
return parseResult;
6868
}
6969

70-
const retrievalURI = url.resolve(ctx.baseURI, sourceDescriptionURI);
70+
// normalize URI for consistent cycle detection and cache key matching
71+
const retrievalURI = url.sanitize(url.stripHash(url.resolve(ctx.baseURI, sourceDescriptionURI)));
7172

7273
// skip if already visited (cycle detection)
7374
if (ctx.visitedUrls.has(retrievalURI)) {
@@ -87,6 +88,9 @@ async function parseSourceDescription(
8788
parse: {
8889
mediaType: 'text/plain', // allow parser plugin detection
8990
parserOpts: {
91+
// nested documents should parse all their source descriptions
92+
// (parent's name filter doesn't apply to nested documents)
93+
sourceDescriptions: true,
9094
[ARAZZO_RECURSION_KEY]: {
9195
sourceDescriptionsDepth: ctx.currentDepth + 1,
9296
sourceDescriptionsVisitedUrls: ctx.visitedUrls,
@@ -158,25 +162,29 @@ async function parseSourceDescription(
158162
*
159163
* @param parseResult - ParseResult containing an Arazzo specification
160164
* @param parseResultRetrievalURI - URI from which the parseResult was retrieved
161-
* @param options - Full ReferenceOptions (caller responsibility to construct)
165+
* @param options - Full ReferenceOptions. Pass `sourceDescriptions` as an array of names
166+
* in `parse.parserOpts` to filter which source descriptions to process.
162167
* @param parserName - Parser name for options lookup (defaults to 'arazzo-json-1')
163-
* @returns Array of ParseResultElements. On success, returns one ParseResultElement per
164-
* source description (each with class 'source-description' and name/type metadata).
168+
* @returns Array of ParseResultElements. Returns one ParseResultElement per source description
169+
* (each with class 'source-description' and name/type metadata).
165170
* May return early with a single-element array containing a warning annotation when:
166171
* - The API is not an Arazzo specification
167172
* - The sourceDescriptions field is missing or not an array
168173
* - Maximum parse depth is exceeded (error annotation)
169-
* Returns an empty array when sourceDescriptions option is disabled or no names match.
174+
* Returns an empty array when no source description names match the filter.
170175
*
171176
* @example
172177
* ```typescript
173178
* import { options, mergeOptions } from '@speclynx/apidom-reference';
174179
* import { parseSourceDescriptions } from '@speclynx/apidom-reference/parse/parsers/arazzo-json-1';
175180
*
176-
* const fullOptions = mergeOptions(options, {
177-
* parse: { parserOpts: { sourceDescriptions: true } }
178-
* });
179-
* const results = await parseSourceDescriptions(parseResult, uri, fullOptions);
181+
* // Parse all source descriptions
182+
* const results = await parseSourceDescriptions(parseResult, uri, options);
183+
*
184+
* // Filter by name
185+
* const filtered = await parseSourceDescriptions(parseResult, uri, mergeOptions(options, {
186+
* parse: { parserOpts: { sourceDescriptions: ['petStore'] } }
187+
* }));
180188
*
181189
* // Access parsed document from source description element
182190
* const sourceDesc = parseResult.api.sourceDescriptions.get(0);
@@ -245,16 +253,11 @@ export async function parseSourceDescriptions(
245253
visitedUrls,
246254
};
247255

248-
// determine which source descriptions to parse
256+
// determine which source descriptions to parse (array filters by name)
249257
const sourceDescriptionsOption =
250258
options?.parse?.parserOpts?.[parserName]?.sourceDescriptions ??
251259
options?.parse?.parserOpts?.sourceDescriptions;
252260

253-
// handle false or other falsy values - no source descriptions should be parsed
254-
if (!sourceDescriptionsOption) {
255-
return results;
256-
}
257-
258261
const sourceDescriptions = Array.isArray(sourceDescriptionsOption)
259262
? api.sourceDescriptions.filter((sd) => {
260263
if (!isSourceDescriptionElement(sd)) return false;

0 commit comments

Comments
 (0)