diff --git a/README.md b/README.md
index c51f9565..64a28544 100644
--- a/README.md
+++ b/README.md
@@ -124,8 +124,10 @@ apiops publish \
```
The publish filter uses the same YAML file and matching rules as `apiops extract --filter`.
-Referenced named values, backends (including backend pool members), policy fragments, and version
-sets are included automatically. Add `--no-transitive` to publish only the exact filter matches.
+The resources the selected ones depend on, such as referenced named values, backends (including
+backend pool members), policy fragments, version sets, tags, groups and loggers, are included
+automatically; see [Transitive Dependency Behavior](docs/guides/filtering-resources.md#transitive-dependency-behavior).
+Add `--no-transitive` to publish only the exact filter matches.
### `apiops init`
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 5e6f31f1..3a6232bd 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -124,7 +124,7 @@ flowchart TD
### Key Extract Behaviors
- **Topological ordering** — Resources are extracted in dependency order across 4 tiers, ensuring parent resources are written before children.
-- **Transitive dependency resolution** — Policy XML is scanned for references to named values, backends, and policy fragments. Referenced resources are included even if not explicitly listed in the filter.
+- **Transitive dependency resolution** — Policy XML is scanned for references to named values, backends, policy fragments, loggers and schemas; APIs, diagnostics, loggers, backends and products are scanned for their version set, logger, credential named values, tags and groups. Referenced resources are included even if not explicitly listed in the filter.
- **Dual output format** — Resource metadata is written as JSON (`*.info.json`), while policies are written as raw XML (`policy.xml`).
- **LIST then GET** — Some APIM list endpoints omit fields. When `listOmitsFields` is set for a resource type, individual GET calls fetch the full representation.
diff --git a/docs/commands/extract.md b/docs/commands/extract.md
index c4cce83e..c05479b8 100644
--- a/docs/commands/extract.md
+++ b/docs/commands/extract.md
@@ -66,7 +66,7 @@ apiops extract \
| `--output
` | string | `./apim-artifacts` | No | Output directory path |
| `--filter ` | string | — | No | Filter configuration YAML file |
| `--no-transitive` | boolean | false (transitive ON) | No | Disable transitive dependency inclusion |
-| `--remove-stale` | boolean | false | No | Remove stale managed artifacts in the selected filter and workspace scope after a fully successful extraction |
+| `--remove-stale` | boolean | false | No | Remove stale managed artifacts in the selected filter and workspace scope, and pulled-in dependencies nothing references any more, after a fully successful extraction |
### Global flags
@@ -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
@@ -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
@@ -197,12 +201,18 @@ 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.
+When extracting with a filter, transitive dependencies are **included automatically**. For example, extracting `echo-api` also extracts the backends, named values and policy fragments its policies reference, its version set, its tags, and the loggers its diagnostics use. See [Transitive Dependency Behavior](../guides/filtering-resources.md#transitive-dependency-behavior) for the full list.
Use `--no-transitive` to disable this behavior and extract only the explicitly listed resources.
+With `--remove-stale`, a pulled-in dependency the filter otherwise leaves out (for example a tag when `tags: []`) is removed once the selected resources no longer reference it and no other artifact in the output does. A dependency another filtered extract into the same output still uses is kept.
+
## Output format
Extracted artifacts are organized as JSON info files and XML policy files in a directory tree:
diff --git a/docs/commands/publish.md b/docs/commands/publish.md
index 8d14dc32..d19bc000 100644
--- a/docs/commands/publish.md
+++ b/docs/commands/publish.md
@@ -53,10 +53,12 @@ apiops publish \
--filter ./configuration.extractor.yaml
```
-The filter uses the same YAML format as `apiops extract --filter`. Referenced named values,
-backends (including backend pool members), policy fragments, and version sets are included by
-default; use `--no-transitive` to publish only exact matches. Product, gateway, and subscription
-links do not pull their API or Product targets into the publish set.
+The filter uses the same YAML format as `apiops extract --filter`. The resources the selected ones
+depend on — such as referenced named values, backends (including backend pool members), policy
+fragments, version sets, tags, groups and loggers — are included by default; see
+[Transitive Dependency Behavior](../guides/filtering-resources.md#transitive-dependency-behavior).
+Use `--no-transitive` to publish only exact matches. Product, gateway, and subscription links do
+not pull their API or Product targets into the publish set.
### Delete resources not in source
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 1e4c9cd6..a9b12fbd 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -75,7 +75,7 @@ apiops extract \
--filter filter.yaml
```
-Referenced backends, named values, and policy fragments are included automatically (transitive dependencies). Use `--no-transitive` to disable this.
+Referenced backends, named values, policy fragments and other dependencies are included automatically ([transitive dependencies](guides/filtering-resources.md#transitive-dependency-behavior)). Use `--no-transitive` to disable this.
## 4. Publish to a Target Environment
diff --git a/docs/guides/filtering-resources.md b/docs/guides/filtering-resources.md
index 4b08c976..b5bdf039 100644
--- a/docs/guides/filtering-resources.md
+++ b/docs/guides/filtering-resources.md
@@ -76,7 +76,7 @@ documentations: []
workspaces: []
```
-With transitive resolution enabled (the default), any version set, backend, named value, policy fragment, or tag directly referenced by `my-own-api` or its policies is still pulled in automatically — even though those keys are set to `[]`. Use `--no-transitive` to disable that behavior.
+With transitive resolution enabled (the default), the resources `my-own-api` depends on — such as the backends, named values and policy fragments its policies reference, its version set, its tags, and its diagnostics' loggers — are still pulled in automatically, even though those keys are set to `[]`. See [Transitive Dependency Behavior](#transitive-dependency-behavior) for the full list. Use `--no-transitive` to disable that behavior.
The three states for every key are:
@@ -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:
@@ -242,6 +242,7 @@ apis:
schemas: [] # Empty = exclude ALL schemas
releases:
- v1-release
+ tagDescriptions: [] # By name, as under apis//tagDescriptions/
```
**Sub-filter rules:**
@@ -249,6 +250,20 @@ apis:
- 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; 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.
@@ -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
@@ -303,20 +322,28 @@ Supported workspace sub-filter keys: `apis`, `backends`, `diagnostics`, `groups`
## Transitive Dependency Behavior
-When you filter by API name, `apiops extract` automatically includes resources referenced by those APIs. This is **transitive dependency resolution** — it ensures the extracted artifacts are self-contained and publishable.
+When you filter resources, `apiops extract` and `apiops publish` automatically include the resources the selected ones depend on. This is **transitive dependency resolution** — it keeps the extracted artifacts self-contained and publishable. Dependencies are followed until nothing new is found, so a pulled-in resource's own dependencies are included too.
### What Gets Auto-Included
-```mermaid
-flowchart TD
- A[Filtered API] --> B[Backends referenced in policies]
- A --> C[Named Values referenced in policies]
- A --> D[Policy Fragments included in policies]
- A --> E[Tags attached to the API]
- A --> F[Products containing the API]
- A --> G[Diagnostics configured on the API]
- A --> H[Version Set the API belongs to]
-```
+| Selected resource | Includes |
+|-------------------|----------|
+| Any policy (service, API, operation, GraphQL resolver, product) and policy fragment | Named values referenced as `{{displayName}}`; backends named by ``, `` and `embeddings-backend-id` on the semantic cache lookup policies; fragments named by ``; loggers named by ``; schemas named by validate-content's `` |
+| API | Its version set, and the tags it is linked to |
+| Diagnostic (service-level or on an API) | The logger it sends data to (`loggerId`) |
+| Logger, backend | Named values referenced in their credentials |
+| Backend pool | Its member backends |
+| Product | The groups and tags it is linked to |
+
+A reference written as a policy expression (`backend-id="@(...)"`) is not followed, because its value is only known at run time. One set to a named value (`backend-id="{{backend-name}}"`) pulls in the named value and the resource its value names, in the same scope (except ``, where APIM doesn't substitute named values); publish reads the value with your `--overrides` applied, so each environment gets the resource its own value names. A named value that is published also brings the resource its value names whenever any policy or fragment in the source uses it that way, even one the filter leaves out: APIM checks every policy on the target that uses a named value when its value changes. A resource the filter excludes by name, or that isn't in the source, isn't published, so it has to exist on the target already. When a policy uses a named value to name a backend, logger or schema that is published in the same run, publish creates that resource before it updates the named value, and publishes policy fragments, and any backend or logger using the named value in its credentials, after it, since APIM rejects a named value change that would leave a policy using it pointing at a resource that doesn't exist. A secret or Key Vault named value's value isn't known, so the resource it names isn't pulled in; add that resource to the filter.
+
+A `{{...}}` reference, in a policy or in credentials, names a named value by its display name, as APIM does; when no named value has that display name, it is read as a resource name. The `namedValues` list and its exclusions (`'!name'`) use the resource name, which can differ from the display name. References inside XML comments are ignored. Inside a `` body, a `{{name}}` is followed only when a named value has that name: APIM substitutes those there and leaves every other `{{ ... }}` (`{{body.id}}`, `{{ name }}`, `{{x | upcase}}`) to Liquid, so those are skipped without an error.
+
+A resource the filter excludes by name (`'!name'`) is never pulled in, and links to it are not extracted: an API's or product's link to an excluded tag, or a product's link to an excluded group. Built-in groups (`administrators`, `developers`, `guests`) exist at service level in every APIM instance, so they are not pulled in either; a product's links to them are published only when the filter includes them, as for any group. The exception is the link to `administrators`, which APIM adds to every product itself: it is published whatever the `groups` filter says, unless the filter excludes `administrators` by name.
+
+**Not included:** the products, gateways and subscriptions an API is linked to, and the APIs a product contains. These are independent resources, not dependencies; add them to the filter to include them. On publish, links to APIs or products outside the publish set are skipped.
+
+> **Logger keys:** APIM stores an Application Insights logger's key as an auto-generated secret named value, named with a 24-character hex ID (its display name is `Logger-Credentials--`). It is pulled in with the logger and extracted with its value redacted. Publish skips it unless you provide an override for it: an instance that already has the logger already has its key, but on a new instance the logger can't be published without one. See [Logger credentials with auto-generated named values](environment-overrides.md#logger-credentials-with-auto-generated-named-values).
### Example
@@ -325,6 +352,12 @@ Given this filter:
```yaml
apis:
- petstore-api
+backends: []
+namedValues: []
+policyFragments: []
+loggers: []
+tags: []
+products: []
```
If `petstore-api` has a policy that references:
@@ -332,11 +365,10 @@ If `petstore-api` has a policy that references:
- Named value `petstore-api-key` → **auto-included**
- Policy fragment `rate-limit-fragment` → **auto-included**
-And `petstore-api` is assigned to:
-- Product `starter` → **auto-included**
-- Tag `production` → **auto-included**
-
-The extract output includes all of these, even though only `apiNames` was specified in the filter.
+And `petstore-api`:
+- has a diagnostic that sends data to logger `appinsights` → **auto-included**
+- is tagged `production` → **auto-included**
+- is in product `starter` → **not included**; add `starter` to `products` to include it
### Why Transitive Matters
@@ -379,7 +411,7 @@ apis:
- orders-admin-api
```
-Transitive dependencies (backends, named values, policy fragments) are auto-included.
+Transitive dependencies (backends, named values, policy fragments, version sets, tags and loggers) are auto-included.
### Product-Focused Extract
diff --git a/docs/reference/dependency-graph.md b/docs/reference/dependency-graph.md
index bb45884a..73186517 100644
--- a/docs/reference/dependency-graph.md
+++ b/docs/reference/dependency-graph.md
@@ -15,7 +15,7 @@ apiops-cli solves this by organizing all 34 resource types into **4 tiers**. Eac
| Tier | Resources | Rule |
|------|-----------|------|
-| **1** | NamedValue, Tag, Gateway, VersionSet, Backend, Logger, Group, PolicyFragment, GlobalSchema, PolicyRestriction, Documentation | No dependencies — safe to process in any order |
+| **1** | NamedValue, Tag, Gateway, VersionSet, Backend, Logger, Group, PolicyFragment, GlobalSchema, PolicyRestriction, Documentation | No dependencies on other tiers. Within the tier, named values go first, since backends and loggers can use them; pool backends follow the backends they contain; and a named value a policy uses to name a backend, logger or schema published in the same run goes after that resource, with policy fragments after it in that case. A backend or logger in the run that uses such a named value in its credentials goes after the named value, and these are ordered so each comes after what it needs; only where that is impossible (a cycle, as when a backend uses in its credentials the named value that names it, or a pool containing it), the named values in it go first |
| **2** | Diagnostic, ServicePolicy, Product, Api | Depend only on Tier 1 resources |
| **3** | ProductPolicy, ProductGroup, ProductTag, ProductApi, ProductWiki, ApiPolicy, ApiTag, ApiDiagnostic, ApiOperation, ApiSchema, ApiRelease, ApiTagDescription, ApiWiki, McpServer, GraphQLResolver, GatewayApi, Subscription | Depend on Tier 1 and/or Tier 2 resources |
| **4** | ApiOperationPolicy, GraphQLResolverPolicy | Depend on Tier 3 resources |
@@ -163,9 +163,12 @@ If a Tier 2 resource fails to publish, its dependents in Tier 3 and 4 will also
When using `filter.yaml` to include specific resources, be aware of dependencies.
-**During publish**, apiops-cli does **not** auto-include dependencies at the
-artifact level — your filter (and hence your extracted artifact tree) must
-already contain every required parent. For example, publishing an **ApiPolicy**
+**During publish** with a filter, apiops-cli includes the resources the
+selected ones depend on (named values, backends, policy fragments, version
+sets, tags, groups, loggers and so on) unless `--no-transitive` is passed; see
+[Transitive Dependency Behavior](../guides/filtering-resources.md#transitive-dependency-behavior).
+It can only include what is in the artifact tree, so the tree must already
+contain every required resource. For example, publishing an **ApiPolicy**
without its parent **Api** present in the artifact tree will fail.
**During extract**, apiops-cli **does** auto-follow a limited set of transitive
diff --git a/schemas/v1/extractor-config.schema.json b/schemas/v1/extractor-config.schema.json
index 1ea17a9b..c34d7a59 100644
--- a/schemas/v1/extractor-config.schema.json
+++ b/schemas/v1/extractor-config.schema.json
@@ -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": {
@@ -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": {
@@ -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."
}
}
},
diff --git a/scripts/generate-schemas.mjs b/scripts/generate-schemas.mjs
index 524a563f..73284398 100644
--- a/scripts/generate-schemas.mjs
+++ b/scripts/generate-schemas.mjs
@@ -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: {
@@ -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: {
diff --git a/src/cli/extract-command.ts b/src/cli/extract-command.ts
index 4dec2332..d9fbcdf6 100644
--- a/src/cli/extract-command.ts
+++ b/src/cli/extract-command.ts
@@ -13,7 +13,8 @@ import * as path from 'node:path';
import { ExtractConfig, FilterConfig } from '../models/config.js';
import { ApimServiceContext } from '../models/types.js';
import { runExtraction, ExtractionResult } from '../services/extract-service.js';
-import { shouldReconcileResource } from '../services/filter-service.js';
+import { isInRemoveStaleScope } from '../services/filter-service.js';
+import { findStaleDependencies } from '../services/stale-dependency-finder.js';
import { loadFilterConfig } from '../lib/config-loader.js';
import { logger, parseLogLevel } from '../lib/logger.js';
import { ApimClient } from '../clients/apim-client.js';
@@ -24,7 +25,7 @@ import { EXIT_FATAL, EXIT_SUCCESS } from '../lib/exit-codes.js';
import { getResourceTier, TIER_LABELS } from '../lib/dependency-graph.js';
import { formatDuration } from '../lib/format-duration.js';
import { ResourceType } from '../models/resource-types.js';
-import { getNamePart } from '../lib/resource-path.js';
+import { getNamePart, getResourceDescriptorKey } from '../lib/resource-path.js';
/**
* Interface for extract command options (from CLI flags).
@@ -63,7 +64,7 @@ export function createExtractCommand(): Command {
.option('--output ', 'Output directory path', './apim-artifacts')
.option('--filter ', 'Filter configuration YAML file')
.option('--no-transitive', 'Disable transitive dependency inclusion')
- .option('--remove-stale', 'Remove stale managed artifacts in the selected scope')
+ .option('--remove-stale', 'Remove stale managed artifacts in the selected scope, and pulled-in dependencies nothing references any more')
.action(async (options: ExtractOptions, command: Command) => {
const globalOpts = command.optsWithGlobals<{
logLevel?: string;
@@ -153,11 +154,19 @@ export async function executeExtract(
result = await dependencies.runExtraction(client, store, extractConfig);
if (result.exitCode !== EXIT_FATAL) {
+ const removeStale = shouldRemoveStaleArtifacts(result, options.removeStale ?? false);
+ // Pulled-in dependencies are outside the filter's scope; remove the
+ // ones the selection no longer needs and nothing else references.
+ const staleDependencies = removeStale && filterConfig
+ ? await findStaleDependencies(store, outputDir, stagingDir, filterConfig)
+ : [];
+ const staleKeys = new Set(staleDependencies.map(getResourceDescriptorKey));
await store.commitStagedExtraction(
stagingDir,
outputDir,
- descriptor => shouldReconcileResource(descriptor, filterConfig),
- shouldRemoveStaleArtifacts(result, options.removeStale ?? false)
+ descriptor => isInRemoveStaleScope(descriptor, filterConfig) ||
+ staleKeys.has(getResourceDescriptorKey(descriptor)),
+ removeStale
);
}
} finally {
diff --git a/src/lib/arm-id.ts b/src/lib/arm-id.ts
new file mode 100644
index 00000000..91c217b4
--- /dev/null
+++ b/src/lib/arm-id.ts
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+/**
+ * Reading names and scope from APIM ARM resource IDs, such as
+ * `/subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.ApiManagement/service/{apim}/workspaces/{ws}/loggers/{name}`,
+ * or the relative form APIM also returns (`/loggers/{name}`).
+ */
+
+import type { AssociationScope } from '../models/types.js';
+import { logger } from './logger.js';
+
+const FULL_SERVICE_ID_PATTERN =
+ /\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.ApiManagement\/service\/[^/]+/i;
+
+/**
+ * Decode one ARM ID segment. A malformed escape leaves the segment as it is,
+ * with a warning.
+ */
+export function decodeArmSegment(value: string): string {
+ try {
+ return decodeURIComponent(value);
+ } catch (error) {
+ logger.warn(`Unable to decode ARM resource ID segment; using the raw value: ${String(error)}`);
+ return value;
+ }
+}
+
+/**
+ * The part of an ARM ID below the APIM service. Segment names are matched
+ * there only, so a resource group or service named `workspaces`, `products`
+ * and so on is not read as one.
+ */
+function pathBelowService(id: string): string {
+ return id.replace(FULL_SERVICE_ID_PATTERN, '');
+}
+
+/**
+ * The decoded name of the `{segment}` collection's resource in an ARM ID
+ * (case-insensitive), for example `armNameAfter(id, 'loggers')`. The path
+ * below the service is read as collection/name pairs, so a resource named
+ * like a collection (a workspace named `backends`) is not read as one.
+ */
+export function armNameAfter(id: string, segment: string): string | undefined {
+ const parts = pathBelowService(id).split('/').filter(Boolean);
+ for (let index = 0; index + 1 < parts.length; index += 2) {
+ if (parts[index].toLowerCase() === segment.toLowerCase()) {
+ return decodeArmSegment(parts[index + 1]);
+ }
+ }
+ return undefined;
+}
+
+/** The decoded last segment of an ARM ID, or undefined when it has none. */
+export function armLastName(id: string): string | undefined {
+ const segment = id.split('/').filter(Boolean).at(-1);
+ return segment ? decodeArmSegment(segment) : undefined;
+}
+
+/** `workspace` when the ARM ID is in a workspace, otherwise `service`. */
+export function armScope(id: string): AssociationScope {
+ return /\/workspaces\/[^/]+(?:\/|$)/i.test(pathBelowService(id)) ? 'workspace' : 'service';
+}
+
+/**
+ * The workspace an ARM reference points into: the one it names, none for a
+ * full service-level ID, and `fallback` (the referencing resource's
+ * workspace) for a relative reference such as `/loggers/{name}`.
+ */
+export function armReferenceWorkspace(id: string, fallback?: string): string | undefined {
+ const workspace = armNameAfter(id, 'workspaces');
+ if (workspace) {
+ return workspace;
+ }
+ return FULL_SERVICE_ID_PATTERN.test(id) ? undefined : fallback;
+}
diff --git a/src/lib/config-loader.ts b/src/lib/config-loader.ts
index 74669367..9b5416c9 100644
--- a/src/lib/config-loader.ts
+++ b/src/lib/config-loader.ts
@@ -163,8 +163,12 @@ export async function loadFilterConfig(filePath: string): Promise 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) {
@@ -188,6 +192,10 @@ export async function loadFilterConfig(filePath: string): Promise 0
- ? decodeURIComponent(lastSegment)
- : undefined;
+ return armLastName(armId);
}
/**
@@ -177,3 +174,50 @@ export function buildLinkPayload(
},
};
}
+
+/**
+ * In a workspace, API and product tags are links listed under each tag
+ * (`tags/{tag}/apiLinks`, `tags/{tag}/productLinks`). Yield each tag and the
+ * name of the API or product it links to. A link without a readable target,
+ * or a failed listing, throws.
+ *
+ * @param context - Workspace-scoped context (its baseUrl includes the workspace)
+ * @param tagNames - Tags to list links for
+ * @param linkType - ApiTag (apiLinks) or ProductTag (productLinks)
+ */
+export async function* listWorkspaceTagLinks(
+ client: IApimClient,
+ context: ApimServiceContext,
+ tagNames: Iterable,
+ linkType: ResourceType.ApiTag | ResourceType.ProductTag
+): AsyncGenerator<{ tagName: string; targetName: string }> {
+ const linkProperty = RESOURCE_TYPE_METADATA[linkType].workspaceLinkIdProperty;
+ if (!linkProperty) {
+ return;
+ }
+ const [targetNoun, linkName] = linkType === ResourceType.ApiTag
+ ? ['API', 'apiLink']
+ : ['product', 'productLink'];
+
+ for (const tagName of tagNames) {
+ // Do NOT set workspace on this descriptor — context.baseUrl already
+ // includes the workspace prefix and buildArmUri would double it.
+ const tagDescriptor: ResourceDescriptor = { type: ResourceType.Tag, nameParts: [tagName] };
+ const targets: string[] = [];
+ try {
+ for await (const linkJson of client.listResources(context, linkType, tagDescriptor)) {
+ const targetName = extractNameFromLink(linkJson, linkProperty);
+ if (!targetName) {
+ throw new Error(`Failed to extract ${targetNoun} name from tag "${tagName}" ${linkName} response`);
+ }
+ targets.push(targetName);
+ }
+ } catch (error) {
+ logger.warn(`Failed to list ${linkName}s for tag "${tagName}": ${(error as Error).message}`);
+ throw error;
+ }
+ for (const targetName of targets) {
+ yield { tagName, targetName };
+ }
+ }
+}
diff --git a/src/models/config.ts b/src/models/config.ts
index 1498eef3..1555fa1c 100644
--- a/src/models/config.ts
+++ b/src/models/config.ts
@@ -27,6 +27,7 @@ export interface ApiSubFilter {
diagnostics?: string[];
schemas?: string[];
releases?: string[];
+ tagDescriptions?: string[];
}
/**
diff --git a/src/models/resource-types.ts b/src/models/resource-types.ts
index 6686edb1..23b9752c 100644
--- a/src/models/resource-types.ts
+++ b/src/models/resource-types.ts
@@ -97,6 +97,27 @@ export interface ResourceTypeMetadata {
readonly workspaceLinkIdProperty?: string;
}
+/** Groups every APIM instance has; they are never created, changed or deleted. */
+export const BUILT_IN_GROUP_NAMES: readonly string[] = ['administrators', 'developers', 'guests'];
+
+/** Whether `name` is one of the built-in groups (case-insensitive). */
+export function isBuiltInGroupName(name: string): boolean {
+ return BUILT_IN_GROUP_NAMES.includes(name.toLowerCase());
+}
+
+/**
+ * Whether a descriptor is a built-in group. Built-in groups exist only at
+ * service level; a workspace group with the same name is a custom group.
+ */
+export function isBuiltInGroup(descriptor: { type: ResourceType; nameParts: string[]; workspace?: string }): boolean {
+ return (
+ descriptor.type === ResourceType.Group &&
+ !descriptor.workspace &&
+ descriptor.nameParts.length === 1 &&
+ isBuiltInGroupName(descriptor.nameParts[0] ?? '')
+ );
+}
+
export const RESOURCE_TYPE_METADATA: Record = {
[ResourceType.NamedValue]: {
armPathSuffix: 'namedValues/{0}',
diff --git a/src/services/api-extractor.ts b/src/services/api-extractor.ts
index 2e477385..3060f5da 100644
--- a/src/services/api-extractor.ts
+++ b/src/services/api-extractor.ts
@@ -10,17 +10,17 @@
import { IApimClient, ApiSpecDialect } from '../clients/iapim-client.js';
import { IArtifactStore } from '../clients/iartifact-store.js';
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
-import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
+import { ResourceType } 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';
import { buildResourceLabel } from '../lib/resource-uri.js';
import { getNamePart } from '../lib/resource-path.js';
import { normalizeWsdl } from '../lib/wsdl-normalizer.js';
-import { isWorkspaceScope, extractNameFromLink } from '../lib/workspace-link.js';
+import { isWorkspaceScope, listWorkspaceTagLinks } from '../lib/workspace-link.js';
/**
* `JSON.parse`'s reviver `context` argument and `JSON.rawJSON` are ES2024
@@ -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;
}
@@ -810,6 +807,7 @@ async function extractGraphQLResolvers(
* @param extractedApiNames - API names already extracted for this workspace
* @param outputDir - Output directory
* @param workspace - Workspace name
+ * @param onLink - Called with each ApiTag artifact written
* @returns Number of ApiTag artifacts written
*/
export async function extractWorkspaceApiTags(
@@ -819,59 +817,36 @@ export async function extractWorkspaceApiTags(
extractedTagNames: string[],
extractedApiNames: Set,
outputDir: string,
- workspace: string
+ workspace: string,
+ onLink?: (descriptor: ResourceDescriptor, json: Record) => void
): Promise {
- const linkProperty = RESOURCE_TYPE_METADATA[ResourceType.ApiTag].workspaceLinkIdProperty;
- if (!linkProperty) {
- return 0;
- }
-
let count = 0;
- for (const tagName of extractedTagNames) {
- // List apiLinks under this tag.
- // Do NOT set workspace on this descriptor — context.baseUrl already includes
- // the workspace prefix and buildArmUri would double it.
- const tagDescriptor: ResourceDescriptor = {
- type: ResourceType.Tag,
- nameParts: [tagName],
- };
-
- try {
- for await (const linkJson of client.listResources(context, ResourceType.ApiTag, tagDescriptor)) {
- const apiName = extractNameFromLink(linkJson, linkProperty);
- if (!apiName) {
- throw new Error(`Failed to extract API name from tag "${tagName}" apiLink response`);
- }
-
- // Only create ApiTag artifacts for APIs that were extracted
- if (!extractedApiNames.has(apiName)) {
- logger.debug(`Skipping apiLink for tag "${tagName}" → API "${apiName}" (API not extracted)`);
- continue;
- }
-
- // Write the ApiTag artifact
- const apiTagDescriptor: ResourceDescriptor = {
- type: ResourceType.ApiTag,
- nameParts: [apiName, tagName],
- workspace,
- };
+ for await (const { tagName, targetName: apiName } of listWorkspaceTagLinks(
+ client, context, extractedTagNames, ResourceType.ApiTag
+ )) {
+ // Only create ApiTag artifacts for APIs that were extracted
+ if (!extractedApiNames.has(apiName)) {
+ logger.debug(`Skipping apiLink for tag "${tagName}" → API "${apiName}" (API not extracted)`);
+ continue;
+ }
- // ApiTag artifacts store a minimal tag information JSON
- const tagJson: Record = {
- properties: {
- displayName: tagName,
- },
- };
+ const apiTagDescriptor: ResourceDescriptor = {
+ type: ResourceType.ApiTag,
+ nameParts: [apiName, tagName],
+ workspace,
+ };
+ // ApiTag artifacts store a minimal tag information JSON
+ const tagJson: Record = {
+ properties: {
+ displayName: tagName,
+ },
+ };
- await store.writeResource(outputDir, apiTagDescriptor, tagJson);
- logger.info(`Extracted workspace ApiTag: ${apiName}/tags/${tagName}`);
- count++;
- }
- } catch (error) {
- logger.warn(`Failed to list apiLinks for tag "${tagName}": ${(error as Error).message}`);
- throw error;
- }
+ await store.writeResource(outputDir, apiTagDescriptor, tagJson);
+ logger.info(`Extracted workspace ApiTag: ${apiName}/tags/${tagName}`);
+ onLink?.(apiTagDescriptor, tagJson);
+ count++;
}
return count;
diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts
index 4fbc7fa2..5b80e6af 100644
--- a/src/services/api-publisher.ts
+++ b/src/services/api-publisher.ts
@@ -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
@@ -185,7 +185,8 @@ export async function publishApi(
store,
context,
config,
- publicationPlan
+ publicationPlan,
+ allowedDescriptors
));
return {
@@ -225,9 +226,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;
@@ -718,7 +717,8 @@ async function publishApiChildren(
store: IArtifactStore,
context: ApimServiceContext,
config: PublishConfig,
- plan: ApiPublicationPlan
+ plan: ApiPublicationPlan,
+ allowedDescriptors?: ResourceDescriptor[]
): Promise {
const results: ResourcePublishResult[] = [];
// Group resources by publish tier for dependency ordering.
@@ -738,7 +738,7 @@ async function publishApiChildren(
const descriptors = tierMap.get(tier) ?? [];
if (descriptors.length > 0) {
const tasks = descriptors.map((descriptor) => async () => {
- return publishResource(client, store, context, descriptor, config);
+ return publishResource(client, store, context, descriptor, config, allowedDescriptors);
});
const taskResults = await runParallel(tasks, 5);
for (const [index, taskResult] of (taskResults ?? []).entries()) {
diff --git a/src/services/delete-unmatched-service.ts b/src/services/delete-unmatched-service.ts
index bbb77691..61798324 100644
--- a/src/services/delete-unmatched-service.ts
+++ b/src/services/delete-unmatched-service.ts
@@ -27,7 +27,7 @@ import type { IApimClient } from '../clients/iapim-client.js';
import type { IArtifactStore } from '../clients/iartifact-store.js';
import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
import type { PublishConfig } from '../models/config.js';
-import { ResourceType } from '../models/resource-types.js';
+import { isBuiltInGroupName, ResourceType } from '../models/resource-types.js';
import { getTopologicalOrder } from '../lib/dependency-graph.js';
import {
getNameFromNameParts,
@@ -77,11 +77,6 @@ export function filterRevisionDeletesHandledByBaseApi(
});
}
-/**
- * Built-in groups that should never be deleted
- */
-const BUILT_IN_GROUPS = ['administrators', 'developers', 'guests'];
-
/**
* System resources that should never be deleted
*/
@@ -393,7 +388,7 @@ function isSystemResource(descriptor: ResourceDescriptor): boolean {
// Check built-in groups
if (descriptor.type === ResourceType.Group) {
- if (BUILT_IN_GROUPS.includes(ownName)) {
+ if (isBuiltInGroupName(ownName)) {
return true;
}
}
diff --git a/src/services/dry-run-reporter.ts b/src/services/dry-run-reporter.ts
index 4431e810..745de3af 100644
--- a/src/services/dry-run-reporter.ts
+++ b/src/services/dry-run-reporter.ts
@@ -361,7 +361,9 @@ async function planDryRunPublications(
}
for (const child of apiPlan.childPuts) {
if (child.type === ResourceType.ApiTag) {
- const eligibility = await evaluateResourceEligibility(store, child, config);
+ const eligibility = await evaluateResourceEligibility(
+ store, child, config, undefined, targetDescriptors
+ );
if (
eligibility.eligible &&
!child.workspace &&
@@ -415,7 +417,9 @@ async function planDryRunPublications(
const eligibility = await evaluateResourceEligibility(
store,
descriptor,
- config
+ config,
+ undefined,
+ targetDescriptors
);
if (
eligibility.eligible &&
diff --git a/src/services/extract-service.ts b/src/services/extract-service.ts
index 8c1d87b4..17e46264 100644
--- a/src/services/extract-service.ts
+++ b/src/services/extract-service.ts
@@ -26,9 +26,14 @@ import { isSingletonType, isChildType } from '../lib/resource-path.js';
import { extractApiResources, ApiExtractionResult } from './api-extractor.js';
import { extractProductResources, ProductExtractionResult } from './product-extractor.js';
import { extractWorkspaces, WorkspaceExtractionResult } from './workspace-extractor.js';
-import { extractTransitiveDependencies } from './transitive-extractor.js';
+import {
+ apiDependencyResources,
+ collectPolicies,
+ extractTransitiveDependencies,
+} from './transitive-extractor.js';
+import { readProductLinkDependencies } from './transitive-resolver.js';
import { redactAndWarnPolicySecrets } from './secret-redactor.js';
-import { shouldIncludeResource } from './filter-service.js';
+import { isExplicitlyExcluded, shouldIncludeResource } from './filter-service.js';
import { logger } from '../lib/logger.js';
import { buildResourceLabel } from '../lib/resource-uri.js';
import { EXIT_SUCCESS, EXIT_PARTIAL, EXIT_FATAL } from '../lib/exit-codes.js';
@@ -125,8 +130,9 @@ export async function runExtraction(
await extractServicePolicy(client, store, service, outputDir, filter, result);
// Phase 6: Transitive dependency resolution (if enabled)
+ let failedTransitive: ResourceDescriptor[] = [];
if (config.includeTransitive && filter) {
- await resolveAndExtractTransitive(client, store, service, outputDir, result);
+ failedTransitive = await resolveAndExtractTransitive(client, store, service, outputDir, filter, result);
}
// Phase 7: Extract workspace-scoped resources
@@ -137,7 +143,8 @@ export async function runExtraction(
outputDir,
filter,
config.includeTransitive,
- result
+ result,
+ failedTransitive
);
// Compute exit code
@@ -285,13 +292,7 @@ async function extractApiSubResources(
result.totalExtracted += subCount;
// Collect policies for transitive resolution
- for (let pi = 0; pi < apiResult.policies.length; pi++) {
- const policyContent = apiResult.policies[pi];
- if (policyContent !== undefined) {
- const key = `api:${apiName}:policy:${pi}`;
- result.collectedPolicies.set(key, policyContent);
- }
- }
+ collectPolicies(result.collectedPolicies, 'api', apiName, apiResult.policies);
// Track sub-resource descriptors
for (const sub of [
@@ -382,13 +383,7 @@ async function extractProductSubResources(
result.totalExtracted += subCount;
// Collect policies for transitive resolution
- for (let pi = 0; pi < prodResult.policies.length; pi++) {
- const policyContent = prodResult.policies[pi];
- if (policyContent !== undefined) {
- const key = `product:${productName}:policy:${pi}`;
- result.collectedPolicies.set(key, policyContent);
- }
- }
+ collectPolicies(result.collectedPolicies, 'product', productName, prodResult.policies);
}
}
}
@@ -517,8 +512,9 @@ async function resolveAndExtractTransitive(
store: IArtifactStore,
context: ApimServiceContext,
outputDir: string,
+ filter: FilterConfig,
result: ExtractionResult
-): Promise {
+): Promise {
logger.info('Resolving transitive dependencies...');
// Build maps for transitive resolution
@@ -535,14 +531,16 @@ async function resolveAndExtractTransitive(
}
}
- const resources = result.typeResults.flatMap((typeResult) =>
- typeResult.extracted
+ // Top-level resources, plus the API children that reference other
+ // resources (diagnostics and tag links).
+ const resources = [
+ ...result.typeResults
+ .flatMap((typeResult) => typeResult.extracted)
.filter((extracted) => extracted.status === 'success')
- .map((extracted) => ({
- descriptor: extracted.descriptor,
- json: extracted.json,
- }))
- );
+ .map(({ descriptor, json }) => ({ descriptor, json })),
+ ...result.apiResults.flatMap(apiDependencyResources),
+ ];
+ const linkedDependencies = await findProductLinkDependencies(store, outputDir, result);
const transitiveResult = await extractTransitiveDependencies(
client,
store,
@@ -551,11 +549,34 @@ async function resolveAndExtractTransitive(
result.collectedPolicies,
apiJsonMap,
resources,
- result.extractedDescriptors
+ result.extractedDescriptors,
+ {
+ linkedDependencies,
+ skip: (dep) => isExplicitlyExcluded(dep, filter),
+ }
);
result.totalExtracted += transitiveResult.extractedDescriptors.length;
result.totalErrors += transitiveResult.errorCount;
result.extractedDescriptors.push(...transitiveResult.extractedDescriptors);
+ return transitiveResult.failedDescriptors;
+}
+
+/**
+ * The groups and tags that extracted products link to, read back from the
+ * groups.json and tags.json files written for them.
+ */
+async function findProductLinkDependencies(
+ store: IArtifactStore,
+ outputDir: string,
+ result: ExtractionResult
+): Promise {
+ const dependencies: ResourceDescriptor[] = [];
+ for (const typeResult of result.typeResults.filter((r) => r.type === ResourceType.Product)) {
+ for (const product of typeResult.extracted.filter((r) => r.status === 'success')) {
+ dependencies.push(...await readProductLinkDependencies(store, outputDir, product.descriptor));
+ }
+ }
+ return dependencies;
}
/**
@@ -568,10 +589,13 @@ async function extractWorkspaceResources(
outputDir: string,
filter: FilterConfig | undefined,
includeTransitive: boolean,
- result: ExtractionResult
+ result: ExtractionResult,
+ /** Dependencies the service-level transitive pass tried and failed to extract. */
+ failedTransitive: ResourceDescriptor[] = []
): Promise {
const wsResults = await extractWorkspaces(
- client, store, context, outputDir, filter, includeTransitive
+ client, store, context, outputDir, filter, includeTransitive,
+ [...result.extractedDescriptors, ...failedTransitive]
);
result.workspaceResults = wsResults;
diff --git a/src/services/filter-service.ts b/src/services/filter-service.ts
index a73ca28b..64f81ade 100644
--- a/src/services/filter-service.ts
+++ b/src/services/filter-service.ts
@@ -68,6 +68,7 @@ const API_SUB_FILTER_KEY_MAP: Partial>
[ResourceType.ApiDiagnostic]: 'diagnostics',
[ResourceType.ApiSchema]: 'schemas',
[ResourceType.ApiRelease]: 'releases',
+ [ResourceType.ApiTagDescription]: 'tagDescriptions',
};
/**
@@ -110,6 +111,15 @@ export function shouldIncludeResource(
return false;
}
+ // An API's link to a tag the filter excludes by name is left out with
+ // the tag, rather than extracted and then skipped on every publish.
+ if (
+ descriptor.type === ResourceType.ApiTag &&
+ isExcludedByName(ResourceType.Tag, getNamePart(descriptor.nameParts, 1), filter)
+ ) {
+ return false;
+ }
+
// Second check: does the parent have sub-resource filters?
if (parentField === 'apis' && filter.apiSubFilters) {
return matchesApiSubFilter(descriptor, parentName, filter.apiSubFilters);
@@ -139,37 +149,24 @@ export function shouldReconcileResource(
return false;
}
- const workspaceFilter = getWorkspaceFilter(descriptor.workspace, filter);
- return shouldIncludeResource(descriptor, workspaceFilter);
+ return shouldIncludeResource(descriptor, resolveWorkspaceFilter(descriptor.workspace, filter));
}
-function getWorkspaceFilter(
- workspaceName: string,
- filter?: FilterConfig
-): FilterConfig | undefined {
- const matchingKey = Object.keys(filter?.workspaceSubFilters ?? {}).find(
- key => key.toLowerCase() === workspaceName.toLowerCase()
- );
- if (!matchingKey) {
- return undefined;
+/**
+ * Whether `extract --remove-stale` manages this artifact, removing it when
+ * the extract does not write it again. As shouldReconcileResource, except
+ * that an API's tag links belong to the API even when the filter excludes
+ * the tag: extract doesn't write a link to an excluded tag, so one left by
+ * an earlier extract is removed with the API's other stale children.
+ */
+export function isInRemoveStaleScope(descriptor: ResourceDescriptor, filter?: FilterConfig): boolean {
+ if (descriptor.type === ResourceType.ApiTag) {
+ return shouldReconcileResource(
+ { type: ResourceType.Api, nameParts: [getNamePart(descriptor.nameParts, 0)], workspace: descriptor.workspace },
+ filter
+ );
}
-
- 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 shouldReconcileResource(descriptor, filter);
}
/**
@@ -180,11 +177,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;
}
@@ -225,18 +220,12 @@ function matchesApiSubFilter(
parentApiName: string,
apiSubFilters: Record
): 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) {
@@ -295,6 +284,77 @@ export function extractRootApiName(name: string): string {
return semiIndex >= 0 ? name.substring(0, semiIndex) : name;
}
+/**
+ * Whether the filter excludes this resource by name: its type's list has a
+ * `!pattern` entry that matches it. Dependency resolution respects these
+ * exclusions, while it still pulls in resources whose type is `[]` or that a
+ * list simply does not name. A workspace resource is checked against its
+ * workspace's sub-filter.
+ */
+export function isExplicitlyExcluded(descriptor: ResourceDescriptor, filter?: FilterConfig): boolean {
+ if (descriptor.nameParts.length === 0) {
+ return false;
+ }
+ const effectiveFilter = descriptor.workspace ? cachedWorkspaceFilter(descriptor.workspace, filter) : filter;
+ return isExcludedByName(descriptor.type, getNamePart(descriptor.nameParts, 0), effectiveFilter);
+}
+
+/** Whether `filter` (already resolved for the resource's scope) excludes `name` of `type` by name. */
+function isExcludedByName(type: ResourceType, name: string, filter: FilterConfig | undefined): boolean {
+ const field = FILTER_FIELD_MAP[type];
+ const list = field && filter ? (filter[field] as string[] | undefined) : undefined;
+ const exclusions = list?.filter((entry) => entry.startsWith('!')) ?? [];
+ return exclusions.length > 0 && !matchesFilter(name, exclusions);
+}
+
+/**
+ * Resolved workspace filters, cached per filter object and workspace: the
+ * exclusion check runs for every reference during publish expansion.
+ */
+const workspaceFilterCache = new WeakMap>();
+
+function cachedWorkspaceFilter(workspace: string, filter: FilterConfig | undefined): FilterConfig | undefined {
+ if (!filter) {
+ return undefined;
+ }
+ let byWorkspace = workspaceFilterCache.get(filter);
+ if (!byWorkspace) {
+ byWorkspace = new Map();
+ workspaceFilterCache.set(filter, byWorkspace);
+ }
+ const key = workspace.toLowerCase();
+ if (!byWorkspace.has(key)) {
+ byWorkspace.set(key, resolveWorkspaceFilter(workspace, filter));
+ }
+ return byWorkspace.get(key);
+}
+
+/**
+ * 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(name: string, subFilters: Record | 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]));
+ return key === undefined ? undefined : subFilters[key];
+}
+
+/** The sub-filter that applies to an API (see {@link findSubFilter}). */
+export function findApiSubFilter(
+ apiName: string,
+ apiSubFilters: Record | undefined
+): ApiSubFilter | undefined {
+ return findSubFilter(apiName, apiSubFilters);
+}
+
/**
* Check if a pattern contains wildcard characters (* or ?).
*/
diff --git a/src/services/product-extractor.ts b/src/services/product-extractor.ts
index 97118722..fb7a098e 100644
--- a/src/services/product-extractor.ts
+++ b/src/services/product-extractor.ts
@@ -14,7 +14,9 @@ import { extractResourceName } from './resource-extractor.js';
import { redactAndWarnPolicySecrets } from './secret-redactor.js';
import { logger } from '../lib/logger.js';
import { getNamePart } from '../lib/resource-path.js';
-import { isWorkspaceScope, extractNameFromLink, extractLinkTarget } from '../lib/workspace-link.js';
+import { isWorkspaceScope, extractLinkTarget, listWorkspaceTagLinks } from '../lib/workspace-link.js';
+import { isExplicitlyExcluded } from './filter-service.js';
+import { associationTarget } from './transitive-resolver.js';
/**
* Result of product-specific extraction for a single product.
@@ -40,7 +42,11 @@ export async function extractProductResources(
context: ApimServiceContext,
productDescriptor: ResourceDescriptor,
outputDir: string,
- _filter?: FilterConfig,
+ /**
+ * The top-level filter (for a workspace product too): links to groups and
+ * tags it excludes by name are left out with them.
+ */
+ filter?: FilterConfig,
_workspace?: string
): Promise {
const productName = getNamePart(productDescriptor.nameParts, 0);
@@ -62,7 +68,7 @@ export async function extractProductResources(
// Extract product group associations
result.groups = await extractProductAssociations(
- client, store, context, productDescriptor, outputDir, 'groups', () => result.errorCount++
+ client, store, context, productDescriptor, outputDir, 'groups', () => result.errorCount++, filter
);
// Extract product policy
@@ -84,7 +90,7 @@ export async function extractProductResources(
// in the workspace extractor.
if (!isWorkspaceScope(context)) {
result.tags = await extractProductTags(
- client, store, context, productDescriptor, outputDir, () => result.errorCount++
+ client, store, context, productDescriptor, outputDir, () => result.errorCount++, filter
);
}
@@ -101,9 +107,10 @@ async function extractProductAssociations(
productDescriptor: ResourceDescriptor,
outputDir: string,
associationType: 'apis' | 'groups',
- onError: () => void
+ onError: () => void,
+ filter?: FilterConfig
): Promise {
- const entries: AssociationEntry[] = [];
+ let entries: AssociationEntry[] = [];
const resourceType = associationType === 'apis'
? ResourceType.ProductApi
: ResourceType.ProductGroup;
@@ -137,6 +144,10 @@ async function extractProductAssociations(
}
}
+ if (associationType === 'groups') {
+ entries = withoutExcludedTargets(productDescriptor, 'groups', entries, filter);
+ }
+
// Write association file
if (entries.length > 0) {
await store.writeAssociation(outputDir, productDescriptor, associationType, entries);
@@ -150,6 +161,20 @@ async function extractProductAssociations(
return entries.map(entry => entry.name);
}
+/**
+ * A product's link entries without the ones to groups or tags the filter
+ * excludes by name: those are not extracted, so the links would only be
+ * skipped on every publish (as an API's links to excluded tags are left out).
+ */
+function withoutExcludedTargets(
+ product: ResourceDescriptor,
+ associationType: 'groups' | 'tags',
+ entries: AssociationEntry[],
+ filter: FilterConfig | undefined
+): AssociationEntry[] {
+ return entries.filter((entry) => !isExplicitlyExcluded(associationTarget(product, associationType, entry), filter));
+}
+
/**
* Extract product tags and write to artifact store as tags.json.
* Only used in service scope; workspace scope is handled by extractWorkspaceProductTags().
@@ -160,9 +185,10 @@ async function extractProductTags(
context: ApimServiceContext,
productDescriptor: ResourceDescriptor,
outputDir: string,
- onError: () => void
+ onError: () => void,
+ filter?: FilterConfig
): Promise {
- const entries: AssociationEntry[] = [];
+ let entries: AssociationEntry[] = [];
const workspaceScoped = isWorkspaceScope(context);
const linkProperty = RESOURCE_TYPE_METADATA[ResourceType.ProductTag].workspaceLinkIdProperty;
@@ -188,6 +214,8 @@ async function extractProductTags(
}
}
+ entries = withoutExcludedTargets(productDescriptor, 'tags', entries, filter);
+
// Write tags association file
if (entries.length > 0) {
await store.writeAssociation(outputDir, productDescriptor, 'tags', entries);
@@ -294,38 +322,15 @@ export async function extractWorkspaceProductTags(
outputDir: string,
_workspace: string
): Promise {
- const linkProperty = RESOURCE_TYPE_METADATA[ResourceType.ProductTag].workspaceLinkIdProperty;
- if (!linkProperty) {
- return 0;
- }
-
// Build a map of product name → tags for that product
const productTagsMap = new Map();
-
- for (const tagName of extractedTagNames) {
- // Do NOT set workspace on this descriptor — context.baseUrl already includes
- // the workspace prefix and buildArmUri would double it.
- const tagDescriptor: ResourceDescriptor = {
- type: ResourceType.Tag,
- nameParts: [tagName],
- };
-
- try {
- for await (const linkJson of client.listResources(context, ResourceType.ProductTag, tagDescriptor)) {
- const productName = extractNameFromLink(linkJson, linkProperty);
- if (!productName) {
- throw new Error(`Failed to extract product name from tag "${tagName}" productLink response`);
- }
-
- if (!productTagsMap.has(productName)) {
- productTagsMap.set(productName, []);
- }
- productTagsMap.get(productName)!.push(tagName);
- }
- } catch (error) {
- logger.warn(`Failed to list productLinks for tag "${tagName}": ${(error as Error).message}`);
- throw error;
+ for await (const { tagName, targetName: productName } of listWorkspaceTagLinks(
+ client, context, extractedTagNames, ResourceType.ProductTag
+ )) {
+ if (!productTagsMap.has(productName)) {
+ productTagsMap.set(productName, []);
}
+ productTagsMap.get(productName)!.push(tagName);
}
// Write tags.json for each product that has tag associations
diff --git a/src/services/product-publisher.ts b/src/services/product-publisher.ts
index 87b0e371..66a20352 100644
--- a/src/services/product-publisher.ts
+++ b/src/services/product-publisher.ts
@@ -23,6 +23,7 @@ import { parseArmUri } from '../lib/resource-uri.js';
import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js';
import { isLinkAlreadyExistsError } from '../clients/apim-client.js';
import { mapDescriptor, toDeployedName } from './env-mapper.js';
+import { armNameAfter } from '../lib/arm-id.js';
export type ProductAssociationPublicationPlan = AssociationPublicationPlan;
@@ -147,7 +148,7 @@ async function cleanupProductSubscriptions(
if (
typeof name !== 'string' ||
typeof scope !== 'string' ||
- getScopedProductName(scope)?.toLowerCase() !== productNameLower
+ armNameAfter(scope, 'products')?.toLowerCase() !== productNameLower
) {
continue;
}
@@ -170,16 +171,6 @@ async function cleanupProductSubscriptions(
}
}
-function getScopedProductName(scope: string): string | undefined {
- const segment = scope.match(/\/products\/([^/]+)\/?$/i)?.[1];
- if (!segment) return undefined;
- try {
- return decodeURIComponent(segment);
- } catch {
- return segment;
- }
-}
-
async function cleanupProductGroups(
client: IApimClient,
context: ApimServiceContext,
@@ -291,7 +282,7 @@ export async function planProductAssociationPublications(
store,
targetDescriptor,
config,
- allowedDescriptors
+ { allowedDescriptors }
);
const deployedDescriptor = config.envMapping
? mapDescriptor(descriptor, config.envMapping)
diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts
index 6d1e966a..0f7847ff 100644
--- a/src/services/publish-service.ts
+++ b/src/services/publish-service.ts
@@ -29,6 +29,7 @@ import {
} from '../lib/resource-path.js';
import {
extractRootApiName,
+ isExplicitlyExcluded,
resolveWorkspaceFilter,
shouldIncludeResource,
shouldReconcileResource,
@@ -50,9 +51,17 @@ import {
} from './delete-unmatched-service.js';
import { computeGitDiff } from './git-diff-service.js';
import { scanForRedactionMarkers } from './secret-redaction-guard.js';
-import { hasNamedValueOverride } from './override-merger.js';
+import { armNameAfter } from '../lib/arm-id.js';
+import { applyOverrides, hasNamedValueOverride } from './override-merger.js';
import { REDACTION_MARKER } from './secret-redactor.js';
-import { scanArtifactReferences } from './transitive-resolver.js';
+import {
+ createArtifactReferenceResolver,
+ createNamedValueReader,
+ findTransitiveDependencies,
+ indexNamedValueTargetTypes,
+ namedValueTargetName,
+ scanArtifactReferences,
+} from './transitive-resolver.js';
import { validateAndBuildEnvMapping } from './env-mapping-validator.js';
import { mapDescriptor } from './env-mapper.js';
@@ -124,10 +133,15 @@ async function runPublishPipeline(
// Step 0: Pre-flight validation — ensure the resource group and APIM service exist
await client.validatePreFlight(config.service);
+ // The named values policies use as resource names, read from the source
+ // once for this run when the expansion or the Tier 1 order first needs it.
+ const namedValueTargets = createNamedValueTargetIndex(store, config);
+
// Step 1: Determine target descriptors based on incremental mode
const { targetDescriptors, deletedDescriptors } = await determinePublishTargets(
store,
- config
+ config,
+ namedValueTargets
);
// Step 1a: For env-mapping validation and policy ref rewriting we need
@@ -220,7 +234,8 @@ async function runPublishPipeline(
store,
config.service,
config,
- targetDescriptors
+ targetDescriptors,
+ namedValueTargets
);
// Step 4: Execute DELETEs in reverse dependency order (tier 4 → tier 1) if requested
@@ -279,7 +294,8 @@ async function runPublishPipeline(
*/
async function determinePublishTargets(
store: IArtifactStore,
- config: PublishConfig
+ config: PublishConfig,
+ namedValueTargets: NamedValueTargetIndex
): Promise {
let targetDescriptors: ResourceDescriptor[];
let deletedDescriptors: ResourceDescriptor[];
@@ -309,23 +325,87 @@ async function determinePublishTargets(
config.sourceDir,
targetDescriptors,
availableDescriptors,
- config.filter
+ config.filter,
+ config.overrides,
+ namedValueTargets
);
}
return { targetDescriptors, deletedDescriptors };
}
+/**
+ * The resource types each named value in the source is used for as a resource
+ * name (indexNamedValueTargetTypes over every policy and policy fragment):
+ * built on first use and then shared, so a publish run reads the policies once
+ * for both the transitive expansion and the Tier 1 order.
+ */
+type NamedValueTargetIndex = () => Promise