Skip to content

Commit 82f1ab2

Browse files
authored
Merge pull request #296 from Azure/copilot/bugfix-selective-api-operation-filter
Honor operation extraction filters in OpenAPI specifications
2 parents b33c1ea + 1ac0498 commit 82f1ab2

3 files changed

Lines changed: 512 additions & 4 deletions

File tree

‎docs/guides/filtering-resources.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,13 @@ apis:
249249
- If a sub-resource key is an **empty array** (`[]`), all sub-resources of that type are excluded
250250
- If a sub-resource key lists **names**, only those sub-resources are included
251251

252+
During extraction, the `operations` filter also applies to the API's OpenAPI specification
253+
(`specification.yaml` or `specification.json`, including Swagger 2.0). Excluded operations
254+
are removed from the specification, and paths with no remaining operations are removed.
255+
Shared definitions and metadata on retained paths are preserved. Omitting `operations`
256+
leaves the specification unchanged; `operations: []` removes all operations from it.
257+
GraphQL, WSDL, and WADL specifications are not filtered.
258+
252259
### Workspace sub-resource filters
253260

254261
The configuration format supports specifying which workspace-scoped resources to extract:

‎src/services/api-extractor.ts‎

Lines changed: 209 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { IArtifactStore } from '../clients/iartifact-store.js';
1212
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
1313
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
1414
import { FilterConfig } from '../models/config.js';
15-
import { shouldIncludeResource } from './filter-service.js';
15+
import * as yaml from 'js-yaml';
16+
import { extractRootApiName, shouldIncludeResource } from './filter-service.js';
1617
import { extractResourceType, ExtractedResource } from './resource-extractor.js';
1718
import { redactAndWarnPolicySecrets } from './secret-redactor.js';
1819
import { logger } from '../lib/logger.js';
@@ -21,6 +22,22 @@ import { getNamePart } from '../lib/resource-path.js';
2122
import { normalizeWsdl } from '../lib/wsdl-normalizer.js';
2223
import { isWorkspaceScope, extractNameFromLink } from '../lib/workspace-link.js';
2324

25+
/**
26+
* `JSON.parse`'s reviver `context` argument and `JSON.rawJSON` are ES2024
27+
* runtime additions (supported by the project's Node.js target) not yet
28+
* reflected in the configured TS `lib`. Augment the global `JSON` interface
29+
* rather than casting through `unknown` at each call site.
30+
*/
31+
declare global {
32+
interface JSON {
33+
parse(
34+
text: string,
35+
reviver: (this: unknown, key: string, value: unknown, context: { source?: string }) => unknown
36+
): unknown;
37+
rawJSON(text: string): unknown;
38+
}
39+
}
40+
2441
/**
2542
* Result of API-specific extraction for a single API.
2643
*/
@@ -97,7 +114,7 @@ export async function extractApiResources(
97114
// Extract API specification (uses already-extracted schemas to detect
98115
// synthetic GraphQL without a second list call).
99116
const specificationResult = await extractApiSpecification(
100-
client, store, context, apiDescriptor, apiJson, outputDir, result.schemas
117+
client, store, context, apiDescriptor, apiJson, outputDir, result.schemas, filter
101118
);
102119
result.specification = specificationResult.extracted;
103120
result.errorCount += specificationResult.errorCount;
@@ -274,7 +291,8 @@ async function extractApiSpecification(
274291
apiDescriptor: ResourceDescriptor,
275292
apiJson: Record<string, unknown>,
276293
outputDir: string,
277-
extractedSchemas: ExtractedResource[]
294+
extractedSchemas: ExtractedResource[],
295+
filter?: FilterConfig
278296
): Promise<{ extracted: boolean; errorCount: number }> {
279297
const properties = apiJson.properties as Record<string, unknown> | undefined;
280298
const apiType = properties?.type as string | undefined;
@@ -319,9 +337,12 @@ async function extractApiSpecification(
319337
// APIM's WSDL export can emit wsdl:part references qualified with the wrong
320338
// namespace prefix and multiple service ports, which its own importer then
321339
// rejects. Normalize so the extracted artifact round-trips through publish.
322-
const content = spec.format === 'wsdl'
340+
let content = spec.format === 'wsdl'
323341
? normalizeWsdl(spec.content)
324342
: spec.content;
343+
if (spec.format === 'yaml' || spec.format === 'json') {
344+
content = filterOpenApiOperations(content, spec.format, apiDescriptor, filter);
345+
}
325346

326347
await store.writeContent(
327348
outputDir,
@@ -340,6 +361,190 @@ async function extractApiSpecification(
340361
}
341362
}
342363

364+
/**
365+
* Apply the same operation filter to the specification as to operation artifacts.
366+
* Keep shared definitions and path metadata, but remove paths without selected methods.
367+
*
368+
* Parses and reserializes the document, taking care to avoid the precision/type
369+
* loss that `JSON.parse`/js-yaml's defaults would otherwise introduce for scalars
370+
* unrelated to the operations being removed (e.g. int64 examples round to the
371+
* nearest double, bare YAML date-like strings are coerced to `Date`). This is not
372+
* a full byte-for-byte preserving editor: YAML comments are not retained, and
373+
* integers originally written in hex/octal/binary notation are re-emitted in
374+
* decimal form, since the reserialization still rebuilds the whole document.
375+
*/
376+
function filterOpenApiOperations(
377+
content: string,
378+
format: 'yaml' | 'json',
379+
apiDescriptor: ResourceDescriptor,
380+
filter?: FilterConfig
381+
): string {
382+
const apiName = getNamePart(apiDescriptor.nameParts, 0);
383+
const rootName = extractRootApiName(apiName).toLowerCase();
384+
const subFilterKey = Object.keys(filter?.apiSubFilters ?? {}).find(
385+
key => key.toLowerCase() === rootName
386+
);
387+
if (!subFilterKey || filter?.apiSubFilters?.[subFilterKey].operations === undefined) {
388+
return content;
389+
}
390+
391+
const spec = parseOpenApiDocument(content, format);
392+
const methods = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
393+
let modified = false;
394+
395+
for (const field of ['paths', 'x-ms-paths']) {
396+
const paths = spec[field];
397+
if (!paths || typeof paths !== 'object' || Array.isArray(paths)) continue;
398+
399+
for (const [path, value] of Object.entries(paths)) {
400+
if (!path.startsWith('/') || !value || typeof value !== 'object' || Array.isArray(value)) continue;
401+
const pathItem = value as Record<string, unknown>;
402+
let hasSelectedOperation = false;
403+
404+
for (const method of Object.keys(pathItem)) {
405+
if (!methods.has(method)) continue;
406+
const operation = pathItem[method] as Record<string, unknown> | null;
407+
const operationId = operation?.operationId;
408+
if (typeof operationId === 'string' && shouldIncludeResource({
409+
...apiDescriptor,
410+
type: ResourceType.ApiOperation,
411+
nameParts: [apiName, operationId],
412+
}, filter)) {
413+
hasSelectedOperation = true;
414+
} else {
415+
delete pathItem[method];
416+
modified = true;
417+
}
418+
}
419+
420+
if (!hasSelectedOperation) {
421+
delete (paths as Record<string, unknown>)[path];
422+
modified = true;
423+
}
424+
}
425+
}
426+
427+
if (!modified) return content;
428+
return stringifyOpenApiDocument(spec, format);
429+
}
430+
431+
/**
432+
* Parse a JSON/YAML OpenAPI document without losing precision on unrelated
433+
* scalars. `JSON.parse`/`js-yaml` normally round very large integers (e.g.
434+
* int64 examples) to the nearest representable double and, for YAML, coerce
435+
* bare date-like scalars to `Date` objects. Both are avoided here so that
436+
* only the operations targeted by the filter are actually changed.
437+
*/
438+
function parseOpenApiDocument(content: string, format: 'yaml' | 'json'): Record<string, unknown> {
439+
if (format === 'json') {
440+
const reviver = (_key: string, value: unknown, context: { source?: string }): unknown => {
441+
const source = context?.source;
442+
if (typeof value === 'number' && source !== undefined) {
443+
return JSON.rawJSON(source);
444+
}
445+
return value;
446+
};
447+
return JSON.parse(content, reviver) as Record<string, unknown>;
448+
}
449+
return yaml.load(content, { schema: PRECISE_YAML_SCHEMA }) as Record<string, unknown>;
450+
}
451+
452+
/**
453+
* Reserialize a document previously parsed by {@link parseOpenApiDocument},
454+
* emitting the preserved raw/precise scalars back in their original form.
455+
*/
456+
function stringifyOpenApiDocument(spec: Record<string, unknown>, format: 'yaml' | 'json'): string {
457+
return format === 'json'
458+
? JSON.stringify(spec, null, 2)
459+
: yaml.dump(spec, { lineWidth: -1, schema: PRECISE_YAML_SCHEMA });
460+
}
461+
462+
/**
463+
* YAML `int` type that preserves full precision for integers outside the safe
464+
* integer range (e.g. int64 examples) by representing them as `BigInt`
465+
* instead of a lossy JS `number`. Built on the standard Core schema, which
466+
* (unlike the Default schema) does not auto-convert bare date-like scalars to
467+
* `Date` objects.
468+
*/
469+
const PRECISE_YAML_SCHEMA = yaml.CORE_SCHEMA.extend({
470+
implicit: [
471+
new yaml.Type('tag:yaml.org,2002:int', {
472+
kind: 'scalar',
473+
resolve: resolveYamlInteger,
474+
construct: (data: string) => parsePreciseYamlInteger(data),
475+
predicate: (data: unknown) => isPreciseYamlInteger(data),
476+
// Always represented in decimal: the original literal style (e.g. hex,
477+
// octal, binary) isn't retained on the plain number/BigInt values produced
478+
// by `construct`, so a multi-style representer couldn't be selected from
479+
// them. Non-decimal integers unrelated to the filtered operations are
480+
// therefore re-emitted in decimal form; see the module-level docstring.
481+
represent: (data: unknown) => castPreciseYamlValue(data).toString(10),
482+
}),
483+
],
484+
});
485+
486+
function castPreciseYamlValue(data: unknown): number | bigint {
487+
return data as number | bigint;
488+
}
489+
490+
/** Ported from js-yaml's built-in int type resolver (not exported publicly). */
491+
function resolveYamlInteger(data: string | null): boolean {
492+
if (data === null || data.length === 0) return false;
493+
494+
let index = 0;
495+
const max = data.length;
496+
let ch = data[index];
497+
498+
if (ch === '-' || ch === '+') {
499+
ch = data[++index];
500+
}
501+
502+
if (ch === '0') {
503+
if (index + 1 === max) return true;
504+
ch = data[++index];
505+
506+
if (ch === 'b') return /^[01]+$/.test(data.slice(index + 1));
507+
if (ch === 'x') return /^[0-9a-fA-F]+$/.test(data.slice(index + 1));
508+
if (ch === 'o') return /^[0-7]+$/.test(data.slice(index + 1));
509+
}
510+
511+
return /^[0-9]+$/.test(data.slice(index));
512+
}
513+
514+
/**
515+
* Parses a resolved YAML integer scalar, promoting to `BigInt` only when the
516+
* value would otherwise lose precision as a JS `number`.
517+
*/
518+
function parsePreciseYamlInteger(data: string): number | bigint {
519+
let value = data;
520+
let sign = 1;
521+
const ch0 = value[0];
522+
if (ch0 === '-' || ch0 === '+') {
523+
if (ch0 === '-') sign = -1;
524+
value = value.slice(1);
525+
}
526+
if (value === '0') return 0;
527+
528+
let magnitude: number;
529+
if (value[0] === '0' && value[1] === 'b') magnitude = parseInt(value.slice(2), 2);
530+
else if (value[0] === '0' && value[1] === 'x') magnitude = parseInt(value.slice(2), 16);
531+
else if (value[0] === '0' && value[1] === 'o') magnitude = parseInt(value.slice(2), 8);
532+
else magnitude = parseInt(value, 10);
533+
534+
const result = sign * magnitude;
535+
if (Number.isSafeInteger(result)) return result;
536+
537+
const preciseMagnitude = BigInt(value);
538+
return sign === -1 ? -preciseMagnitude : preciseMagnitude;
539+
}
540+
541+
function isPreciseYamlInteger(data: unknown): boolean {
542+
if (typeof data === 'bigint') return true;
543+
return Object.prototype.toString.call(data) === '[object Number]' &&
544+
(data as number) % 1 === 0 &&
545+
!Object.is(data, -0);
546+
}
547+
343548
/**
344549
* Returns true if any already-extracted ApiSchema resource has a contentType
345550
* indicating a GraphQL schema. Used to distinguish synthetic GraphQL APIs

0 commit comments

Comments
 (0)