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
2 changes: 1 addition & 1 deletion docs/reference/apim-glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ XML-based middleware that runs on API requests and responses. Policies handle ra
A reusable snippet of policy XML that can be included in other policies via `<include-fragment>`. Useful for shared logic like standard rate limiting or CORS headers.

- **Microsoft Docs:** [Policy fragments](https://learn.microsoft.com/en-us/azure/api-management/policy-fragments)
- **In artifacts:** `policyFragments/{name}/policyFragmentInformation.json`
- **In artifacts:** `policyFragments/{name}/policy.xml`, optionally with metadata in `policyFragmentInformation.json`; legacy JSON-only fragments are also supported

---

Expand Down
8 changes: 6 additions & 2 deletions docs/reference/artifact-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ apim-artifacts/
│ └── tagInformation.json
├── policyFragments/
│ └── rate-limit/
│ └── policyFragmentInformation.json
│ ├── policyFragmentInformation.json # Optional metadata
│ └── policy.xml
├── loggers/
│ └── appinsights/
│ └── loggerInformation.json
Expand Down Expand Up @@ -116,7 +117,7 @@ All 34 APIM resource types and their artifact mappings:
| Logger | `loggers/{name}` | `loggerInformation.json` |
| Group | `groups/{name}` | `groupInformation.json` |
| Diagnostic | `diagnostics/{name}` | `diagnosticInformation.json` |
| PolicyFragment | `policyFragments/{name}` | `policyFragmentInformation.json` |
| PolicyFragment | `policyFragments/{name}` | `policy.xml` and optional `policyFragmentInformation.json` |
| ServicePolicy | _(root directory)_ | `policy.xml` |
| Product | `products/{name}` | `productInformation.json` |
| Api | `apis/{name}` | `apiInformation.json` |
Expand All @@ -125,6 +126,9 @@ All 34 APIM resource types and their artifact mappings:
| PolicyRestriction | `policyRestrictions/{name}` | `policyRestrictionInformation.json` |
| Documentation | `documentations/{name}` | `documentationInformation.json` |

Policy fragments support `policy.xml`, the legacy JSON-only representation, or
both files. When both exist, XML supplies the policy value and `rawxml` format.
Comment thread
cdayne marked this conversation as resolved.

### Product Child Resources

| Resource Type | Artifact Directory | Info File |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/resource-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ These resources exist at the APIM service scope — they are not children of any
| Logger | `/loggers/{name}` | `loggers/{0}` | `loggerInformation.json` | Logging destinations (Application Insights, Event Hub) |
| Group | `/groups/{name}` | `groups/{0}` | `groupInformation.json` | User groups for access control |
| Diagnostic | `/diagnostics/{name}` | `diagnostics/{0}` | `diagnosticInformation.json` | Logging/diagnostic settings (references a Logger) |
| PolicyFragment | `/policyFragments/{name}` | `policyFragments/{0}` | `policyFragmentInformation.json` | Reusable policy XML snippets |
| PolicyFragment | `/policyFragments/{name}` | `policyFragments/{0}` | `policy.xml` and optional `policyFragmentInformation.json` | Reusable policy XML snippets |
| ServicePolicy | `/policies/policy` | *(root)* | `policy.xml` | Global policy applied to all APIs |
| GlobalSchema | `/schemas/{name}` | `schemas/{0}` | `schemaInformation.json` | Service-level schemas (shared across APIs) |
| PolicyRestriction | `/policyRestrictions/{name}` | `policyRestrictions/{0}` | `policyRestrictionInformation.json` | Rules restricting which policies can be used |
Expand Down
16 changes: 16 additions & 0 deletions src/lib/resource-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,20 @@ export function parseArtifactPath(
}
}

if (fileName === 'policy.xml') {
const policyFragmentParts = parseTemplatePath(
RESOURCE_TYPE_METADATA[ResourceType.PolicyFragment].artifactDirectory,
parts.slice(startIndex, -1).join('/')
);
if (policyFragmentParts !== undefined) {
return {
type: ResourceType.PolicyFragment,
nameParts: policyFragmentParts,
workspace,
};
}
}

// Try to match against each resource type's pattern
for (const [typeKey, metadata] of Object.entries(RESOURCE_TYPE_METADATA)) {
const type = typeKey as ResourceType;
Expand Down Expand Up @@ -494,6 +508,8 @@ function parseWorkspaceContainerDescriptor(
* files that belong to a resource but are not the primary info file.
*
* Currently supports:
* - Policy fragment content (`policyFragments/{fragment}/policy.xml`)
* - Workspace-scoped policy fragment content
* - API specification files (`apis/{api}/specification.{ext}`)
* - Workspace-scoped API specification files
* (`workspaces/{workspace}/apis/{api}/specification.{ext}`)
Expand Down
24 changes: 24 additions & 0 deletions src/services/dry-run-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {
} from './product-publisher.js';
import { API_CHILD_TYPES, planApiPublication } from './api-publisher.js';
import { mapDescriptor } from './env-mapper.js';
import {
hasPolicyFragmentValue,
readPolicyFragmentArtifact,
} from './policy-fragment-artifact.js';

export interface DryRunAction {
operation: 'PUT' | 'PATCH' | 'DELETE' | 'SKIP';
Expand Down Expand Up @@ -443,6 +447,26 @@ async function planDryRunPublications(
continue;
}

if (descriptor.type === ResourceType.PolicyFragment) {
const artifact = await readPolicyFragmentArtifact(
store,
config.sourceDir,
descriptor
);
const mergedArtifact = artifact
? applyOverrides(descriptor, artifact, config.overrides)
: undefined;
const hasValue = hasPolicyFragmentValue(mergedArtifact);
addPlan({
descriptor,
eligible: hasValue,
reason: hasValue
? undefined
: 'no policy value was found in policy.xml, policyFragmentInformation.json, or overrides',
});
continue;
}

addPlan({ descriptor, eligible: true });
}

Expand Down
126 changes: 126 additions & 0 deletions src/services/policy-fragment-artifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { IArtifactStore } from '../clients/iartifact-store.js';
import type { ResourceDescriptor } from '../models/types.js';
import { ResourceType } from '../models/resource-types.js';
import { redactAndWarnPolicySecrets } from './secret-redactor.js';

export type PolicyFragmentValueSource =
| 'policy.xml'
| 'policyFragmentInformation.json';

export interface PolicyFragmentArtifactDetails {
payload: Record<string, unknown>;
valueSource?: PolicyFragmentValueSource;
}

function getProperties(
json: Record<string, unknown> | undefined
): Record<string, unknown> {
const properties = json?.properties;
return properties !== null && typeof properties === 'object' && !Array.isArray(properties)
? properties as Record<string, unknown>
: {};
}

export function hasPolicyFragmentValue(
artifact: Record<string, unknown> | undefined
): boolean {
return typeof getProperties(artifact).value === 'string';
}

/**
* Read a policy fragment using the Azure APIops artifact contract.
*
* Either policyFragmentInformation.json or policy.xml may represent the
* fragment. When both exist, JSON metadata is retained and policy.xml supplies
* the authoritative value and format.
*/
export async function readPolicyFragmentArtifact(
store: IArtifactStore,
baseDir: string,
descriptor: ResourceDescriptor
): Promise<Record<string, unknown> | undefined> {
return (await readPolicyFragmentArtifactDetails(store, baseDir, descriptor))
?.payload;
}

export async function readPolicyFragmentArtifactDetails(
store: IArtifactStore,
baseDir: string,
descriptor: ResourceDescriptor
): Promise<PolicyFragmentArtifactDetails | undefined> {
if (descriptor.type !== ResourceType.PolicyFragment) {
throw new Error(`Expected PolicyFragment descriptor, got ${descriptor.type}`);
}

const [information, policyContent] = await Promise.all([
store.readResource(baseDir, descriptor),
store.readContent(baseDir, descriptor, 'policy'),
]);

if (!information && !policyContent) {
return undefined;
}

if (!policyContent) {
return {
payload: information!,
valueSource: hasPolicyFragmentValue(information)
? 'policyFragmentInformation.json'
: undefined,
};
}

return {
payload: {
...(information ?? {}),
properties: {
...getProperties(information),
value: policyContent.content,
format: 'rawxml',
},
},
valueSource: 'policy.xml',
};
}

/**
* Write an extracted policy fragment using the Azure APIops split layout:
* metadata in policyFragmentInformation.json and content in policy.xml.
*/
export async function writePolicyFragmentArtifact(
store: IArtifactStore,
baseDir: string,
descriptor: ResourceDescriptor,
json: Record<string, unknown>
): Promise<Record<string, unknown>> {
if (descriptor.type !== ResourceType.PolicyFragment) {
throw new Error(`Expected PolicyFragment descriptor, got ${descriptor.type}`);
}

const properties = getProperties(json);
const { value, format: _format, ...metadataProperties } = properties;
const information = {
...json,
properties: metadataProperties,
};

await store.writeResource(baseDir, descriptor, information);

if (typeof value !== 'string') {
return json;
}

const redactedContent = redactAndWarnPolicySecrets(descriptor, value);
await store.writeContent(baseDir, descriptor, redactedContent, 'policy');

return {
...json,
properties: {
...properties,
value: redactedContent,
},
};
}
24 changes: 24 additions & 0 deletions src/services/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,30 @@ async function determinePublishTargets(
const diffResult = await computeGitDiff(config.sourceDir, config.commitId);
targetDescriptors = diffResult.changedDescriptors;
deletedDescriptors = diffResult.deletedDescriptors;
const currentDescriptors = await store.listResources(config.sourceDir);
const currentPolicyFragments = new Set(
currentDescriptors
.filter((descriptor) => descriptor.type === ResourceType.PolicyFragment)
.map(getResourceDescriptorKey)
);
const targetKeys = new Set(targetDescriptors.map(getResourceDescriptorKey));
const actualDeletedDescriptors: ResourceDescriptor[] = [];

for (const descriptor of deletedDescriptors) {
const key = getResourceDescriptorKey(descriptor);
if (
descriptor.type === ResourceType.PolicyFragment &&
currentPolicyFragments.has(key)
) {
if (!targetKeys.has(key)) {
targetDescriptors.push(descriptor);
targetKeys.add(key);
}
} else {
actualDeletedDescriptors.push(descriptor);
}
}
deletedDescriptors = actualDeletedDescriptors;
} else {
// Full mode: publish all artifacts
logger.debug('Using full publish mode (all artifacts)');
Expand Down
34 changes: 27 additions & 7 deletions src/services/resource-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { shouldIncludeResource } from './filter-service.js';
import { FilterConfig } from '../models/config.js';
import { logger } from '../lib/logger.js';
import { buildResourceLabel } from '../lib/resource-uri.js';
import { writePolicyFragmentArtifact } from './policy-fragment-artifact.js';

/**
* Check if a resource type's LIST endpoint returns shallow data that omits
Expand Down Expand Up @@ -121,10 +122,21 @@ export async function extractResourceType(
}

// Apply secret redaction
const safeJson = redactSecrets(descriptor, json);

// Write to artifact store (preserves opaque JSON per FR-009)
await store.writeResource(outputDir, descriptor, safeJson);
let safeJson = redactSecrets(descriptor, json);

// Policy fragments follow the Toolkit split layout: metadata remains
// JSON while the policy value is stored as sibling policy.xml.
if (descriptor.type === ResourceType.PolicyFragment) {
safeJson = await writePolicyFragmentArtifact(
store,
outputDir,
descriptor,
safeJson
);
} else {
// Write to artifact store (preserves opaque JSON per FR-009)
await store.writeResource(outputDir, descriptor, safeJson);
}

result.extracted.push({
descriptor,
Expand Down Expand Up @@ -181,10 +193,18 @@ export async function extractSingleResource(
}

// Apply secret redaction
const safeJson = redactSecrets(descriptor, json);
let safeJson = redactSecrets(descriptor, json);

// Write to artifact store
await store.writeResource(outputDir, descriptor, safeJson);
if (descriptor.type === ResourceType.PolicyFragment) {
safeJson = await writePolicyFragmentArtifact(
store,
outputDir,
descriptor,
safeJson
);
} else {
await store.writeResource(outputDir, descriptor, safeJson);
}

logger.info(`Extracted ${buildResourceLabel(descriptor)}`);

Expand Down
Loading