Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/commands/extract.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ For every key, the value semantics are:
### API sub-filters

To restrict which child resources of an API are extracted (operations,
diagnostics, schemas, releases), use the **nested object entry** form under
diagnostics, schemas, releases, tag descriptions), use the **nested object entry** form under
`apis:`. Each entry becomes a `name: { subKey: [...] }` map:

```yaml
Expand All @@ -175,9 +175,13 @@ apis:
releases: []
```

Supported API sub-filter keys: `operations`, `diagnostics`, `schemas`, `releases`.
Supported API sub-filter keys: `operations`, `diagnostics`, `schemas`, `releases`, `tagDescriptions`.
Same value semantics as top-level keys (omitted = include all, `[]` = include none, list = allowlist).

The key of a nested entry can be a wildcard pattern (`'*'`, `'v2-*'`), so one
sub-filter applies to every matching API. An entry keyed by an API's name takes
precedence over a pattern. See [One sub-filter for many APIs](../guides/filtering-resources.md#one-sub-filter-for-many-apis).

### Workspace sub-filters

Workspaces accept the same nested object entry form. Each workspace can have
Expand All @@ -197,6 +201,10 @@ Supported workspace sub-filter keys: `apis`, `backends`, `diagnostics`,
`groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `schemas`,
`subscriptions`, `tags`, `versionSets`.

As for APIs, the key can be a wildcard pattern (`'team-*'`), and an entry keyed
by the workspace's name takes precedence over a pattern. See
[Workspace sub-resource filters](../guides/filtering-resources.md#workspace-sub-resource-filters).

### Transitive dependencies

When extracting with a filter, transitive dependencies are **included automatically**. For example, extracting `echo-api` also extracts any backends, named values, and policy fragments that `echo-api` references in its policies.
Expand Down
21 changes: 20 additions & 1 deletion docs/guides/filtering-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ apis:

### API sub-resource filters

For APIs, you can control which sub-resources (operations, diagnostics, schemas, releases) are extracted. Use an object entry instead of a plain string:
For APIs, you can control which sub-resources (operations, diagnostics, schemas, releases, tag descriptions) are extracted. Use an object entry instead of a plain string:

```yaml
apis:
Expand All @@ -242,13 +242,28 @@ apis:
schemas: [] # Empty = exclude ALL schemas
releases:
- v1-release
tagDescriptions: [] # By name, as under apis/<api>/tagDescriptions/
```

**Sub-filter rules:**
- If a sub-resource key is **omitted**, all sub-resources of that type are included
- If a sub-resource key is an **empty array** (`[]`), all sub-resources of that type are excluded
- If a sub-resource key lists **names**, only those sub-resources are included

#### One sub-filter for many APIs

The key of a nested entry can be a wildcard pattern (`*`, `?`) instead of an API name. Like a plain pattern, it selects the matching APIs, and its sub-filter applies to each of them. For example, to extract every API without its operations, schemas or tag descriptions:

```yaml
apis:
- '*':
operations: []
schemas: []
tagDescriptions: []
```

An entry keyed by an API's name takes precedence over a pattern, even one with no sub-resource lists (`- orders: {}` keeps all of `orders`' operations); otherwise the first matching pattern applies. Quote patterns that start with `*` so YAML reads them as strings.

During extraction, the `operations` filter also applies to the API's OpenAPI specification
(`specification.yaml` or `specification.json`, including Swagger 2.0). Excluded operations
are removed from the specification, and paths with no remaining operations are removed.
Expand All @@ -275,6 +290,10 @@ workspaces:

Supported workspace sub-filter keys: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `schemas`, `subscriptions`, `tags`, `versionSets`.

As for APIs, the key can be a wildcard pattern: `- 'team-*': { apis: [orders] }` selects every matching workspace and applies the sub-filter to each. An entry keyed by the workspace's name takes precedence over a pattern; otherwise the first matching pattern applies.

A sub-filter can't be attached to an exclusion (`- '!team-c': {...}`), for APIs or workspaces: the exclusion still applies, but its sub-filter is ignored with a warning.

---

## All 17 Filterable Resource Types
Expand Down
8 changes: 6 additions & 2 deletions schemas/v1/extractor-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
},
{
"type": "object",
"description": "A single API name mapped to sub-resource filters for that API.",
"description": "A single API name, or a wildcard pattern such as *, mapped to sub-resource filters for the matching APIs. A name takes precedence over a pattern.",
"minProperties": 1,
"maxProperties": 1,
"patternProperties": {
Expand All @@ -124,7 +124,7 @@
},
{
"type": "object",
"description": "A single workspace name mapped to sub-resource filters for that workspace.",
"description": "A single workspace name, or a wildcard pattern such as team-*, mapped to sub-resource filters for the matching workspaces. A name takes precedence over a pattern.",
"minProperties": 1,
"maxProperties": 1,
"patternProperties": {
Expand Down Expand Up @@ -156,6 +156,10 @@
"releases": {
"$ref": "#/definitions/resourcePatternArray",
"description": "Releases to extract for this API. Matching is case-insensitive and supports * and ? wildcards."
},
"tagDescriptions": {
"$ref": "#/definitions/resourcePatternArray",
"description": "TagDescriptions to extract for this API. Matching is case-insensitive and supports * and ? wildcards."
}
}
},
Expand Down
4 changes: 2 additions & 2 deletions scripts/generate-schemas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ const extractorSchema = {
{
type: 'object',
description:
'A single API name mapped to sub-resource filters for that API.',
'A single API name, or a wildcard pattern such as *, mapped to sub-resource filters for the matching APIs. A name takes precedence over a pattern.',
minProperties: 1,
maxProperties: 1,
patternProperties: {
Expand All @@ -208,7 +208,7 @@ const extractorSchema = {
{
type: 'object',
description:
'A single workspace name mapped to sub-resource filters for that workspace.',
'A single workspace name, or a wildcard pattern such as team-*, mapped to sub-resource filters for the matching workspaces. A name takes precedence over a pattern.',
minProperties: 1,
maxProperties: 1,
patternProperties: {
Expand Down
23 changes: 17 additions & 6 deletions src/lib/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,19 @@ function parseFilterArrayWithNested(
names.push(name);

const subConfig = item[name];
if (isPlainObject(subConfig)) {
// An entry with no sub-resource lists (`- orders: {}`, `- orders:`) is
// kept as an empty sub-filter: it includes everything, and as an entry
// keyed by the name it takes precedence over a wildcard pattern's.
if (subConfig === undefined || subConfig === null) {
subFilters[name] = {};
} else if (isPlainObject(subConfig)) {
const subFilter: Record<string, string[]> = {};
for (const [subKey, subValue] of Object.entries(subConfig)) {
if (subValue === undefined || subValue === null) continue;
subFilter[subKey] = assertStringArray(subValue, `${fieldName}[${i}].${name}.${subKey}`);
}
if (Object.keys(subFilter).length > 0) {
subFilters[name] = subFilter;
}
} else if (subConfig !== undefined && subConfig !== null) {
subFilters[name] = subFilter;
} else {
logger.warn(
`${fieldName}[${i}]: nested value for '${name}' should be an object with sub-resource arrays; ignoring.`
);
Expand Down Expand Up @@ -163,8 +166,12 @@ export async function loadFilterConfig(filePath: string): Promise<FilterConfig |
config.apis = names;
if (Object.keys(subFilters).length > 0) {
config.apiSubFilters = {};
const validApiSubKeys = ['operations', 'diagnostics', 'schemas', 'releases'] as const;
const validApiSubKeys = ['operations', 'diagnostics', 'schemas', 'releases', 'tagDescriptions'] as const;
for (const [apiName, sf] of Object.entries(subFilters)) {
if (apiName.startsWith('!')) {
logger.warn(`Sub-filter for exclusion '${apiName}' in '${sourceKey}' has no effect; ignoring.`);
continue;
}
const apiSub: ApiSubFilter = {};
for (const subKey of validApiSubKeys) {
if (subKey in sf) {
Expand All @@ -188,6 +195,10 @@ export async function loadFilterConfig(filePath: string): Promise<FilterConfig |
const validWsSubKeys = ['apis', 'backends', 'diagnostics', 'groups', 'loggers',
'namedValues', 'policyFragments', 'products', 'schemas', 'subscriptions', 'tags', 'versionSets'] as const;
for (const [wsName, sf] of Object.entries(subFilters)) {
if (wsName.startsWith('!')) {
logger.warn(`Sub-filter for exclusion '${wsName}' in '${sourceKey}' has no effect; ignoring.`);
continue;
}
const wsSub: WorkspaceSubFilter = {};
for (const wsField of validWsSubKeys) {
if (wsField in sf) {
Expand Down
1 change: 1 addition & 0 deletions src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface ApiSubFilter {
diagnostics?: string[];
schemas?: string[];
releases?: string[];
tagDescriptions?: string[];
}

/**
Expand Down
7 changes: 2 additions & 5 deletions src/services/api-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
import { FilterConfig } from '../models/config.js';
import * as yaml from 'js-yaml';
import { extractRootApiName, shouldIncludeResource } from './filter-service.js';
import { extractRootApiName, findApiSubFilter, shouldIncludeResource } from './filter-service.js';
import { extractResourceType, ExtractedResource } from './resource-extractor.js';
import { redactAndWarnPolicySecrets } from './secret-redactor.js';
import { logger } from '../lib/logger.js';
Expand Down Expand Up @@ -381,10 +381,7 @@ function filterOpenApiOperations(
): string {
const apiName = getNamePart(apiDescriptor.nameParts, 0);
const rootName = extractRootApiName(apiName).toLowerCase();
const subFilterKey = Object.keys(filter?.apiSubFilters ?? {}).find(
key => key.toLowerCase() === rootName
);
if (!subFilterKey || filter?.apiSubFilters?.[subFilterKey].operations === undefined) {
if (findApiSubFilter(rootName, filter?.apiSubFilters)?.operations === undefined) {
return content;
}

Expand Down
6 changes: 2 additions & 4 deletions src/services/api-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
} from '../lib/resource-path.js';
import { isAutoGeneratedId, isPortalGeneratedSchemaId } from '../lib/auto-generated.js';
import { normalizeWsdlXsdImportLocations } from '../lib/wsdl-normalizer.js';
import { resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js';
import { findApiSubFilter, resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js';

/**
* API child resource types that should be published after the API itself
Expand Down Expand Up @@ -225,9 +225,7 @@ export async function planApiPublication(
const effectiveFilter = apiDescriptor.workspace
? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter)
: config.filter;
const subFilter = Object.entries(effectiveFilter?.apiSubFilters ?? {}).find(
([name]) => name.toLowerCase() === apiName.toLowerCase()
)?.[1];
const subFilter = findApiSubFilter(apiName, effectiveFilter?.apiSubFilters);
const allowed = allowedDescriptors
? new Set(allowedDescriptors.map(getResourceDescriptorKey))
: undefined;
Expand Down
77 changes: 33 additions & 44 deletions src/services/filter-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const API_SUB_FILTER_KEY_MAP: Partial<Record<ResourceType, keyof ApiSubFilter>>
[ResourceType.ApiDiagnostic]: 'diagnostics',
[ResourceType.ApiSchema]: 'schemas',
[ResourceType.ApiRelease]: 'releases',
[ResourceType.ApiTagDescription]: 'tagDescriptions',
};

/**
Expand Down Expand Up @@ -139,37 +140,7 @@ export function shouldReconcileResource(
return false;
}

const workspaceFilter = getWorkspaceFilter(descriptor.workspace, filter);
return shouldIncludeResource(descriptor, workspaceFilter);
}

function getWorkspaceFilter(
workspaceName: string,
filter?: FilterConfig
): FilterConfig | undefined {
const matchingKey = Object.keys(filter?.workspaceSubFilters ?? {}).find(
key => key.toLowerCase() === workspaceName.toLowerCase()
);
if (!matchingKey) {
return undefined;
}

const subFilter = filter?.workspaceSubFilters?.[matchingKey];
return subFilter ? {
apis: subFilter.apis,
apiSubFilters: subFilter.apiSubFilters,
backends: subFilter.backends,
diagnostics: subFilter.diagnostics,
groups: subFilter.groups,
loggers: subFilter.loggers,
namedValues: subFilter.namedValues,
policyFragments: subFilter.policyFragments,
products: subFilter.products,
schemas: subFilter.schemas,
subscriptions: subFilter.subscriptions,
tags: subFilter.tags,
versionSets: subFilter.versionSets,
} : undefined;
return shouldIncludeResource(descriptor, resolveWorkspaceFilter(descriptor.workspace, filter));
}

/**
Expand All @@ -180,11 +151,9 @@ export function resolveWorkspaceFilter(
workspaceName: string,
filter?: FilterConfig
): FilterConfig | undefined {
const lowerName = workspaceName.toLowerCase();
const matchingKey = Object.keys(filter?.workspaceSubFilters ?? {}).find(
(key) => key.toLowerCase() === lowerName
);
const subFilter = matchingKey ? filter?.workspaceSubFilters?.[matchingKey] : undefined;
// A sub-filter keyed by a pattern (for example `team-*`) applies to every
// workspace it matches; one keyed by the workspace's name takes precedence.
const subFilter = findSubFilter(workspaceName, filter?.workspaceSubFilters);
return subFilter ? workspaceSubFilterToFilterConfig(subFilter) : undefined;
}

Expand Down Expand Up @@ -225,18 +194,12 @@ function matchesApiSubFilter(
parentApiName: string,
apiSubFilters: Record<string, ApiSubFilter>
): boolean {
// Find matching sub-filter using case-insensitive API name
const lowerParent = parentApiName.toLowerCase();
const matchingKey = Object.keys(apiSubFilters).find(
(k) => k.toLowerCase() === lowerParent
);

if (!matchingKey) {
const subFilter = findApiSubFilter(parentApiName, apiSubFilters);
if (!subFilter) {
// No sub-filter for this API — include all children
return true;
}

const subFilter = apiSubFilters[matchingKey];
const subFilterKey = API_SUB_FILTER_KEY_MAP[descriptor.type];

if (!subFilterKey) {
Expand Down Expand Up @@ -295,6 +258,32 @@ export function extractRootApiName(name: string): string {
return semiIndex >= 0 ? name.substring(0, semiIndex) : name;
}

/**
* The sub-filter that applies to a named API or workspace: the one keyed by
* its name (case-insensitive), otherwise the first one keyed by a wildcard
* pattern that matches it (for example `*`). Exclusion keys (`!name`) never
* apply.
*/
function findSubFilter<T>(name: string, subFilters: Record<string, T> | undefined): T | undefined {
if (!subFilters) {
return undefined;
}
const lowerName = name.toLowerCase();
const keys = Object.keys(subFilters).filter((key) => !key.startsWith('!'));
const key =
keys.find((k) => k.toLowerCase() === lowerName) ??
keys.find((k) => isWildcardPattern(k) && matchesFilter(name, [k]));
Comment thread
cdayne marked this conversation as resolved.
Resolved
return key === undefined ? undefined : subFilters[key];
}

/** The sub-filter that applies to an API (see {@link findSubFilter}). */
export function findApiSubFilter(
apiName: string,
apiSubFilters: Record<string, ApiSubFilter> | undefined
): ApiSubFilter | undefined {
return findSubFilter(apiName, apiSubFilters);
}

/**
* Check if a pattern contains wildcard characters (* or ?).
*/
Expand Down
Loading