diff --git a/src/cli/apic-command.ts b/src/cli/apic-command.ts new file mode 100644 index 00000000..30601411 --- /dev/null +++ b/src/cli/apic-command.ts @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * API Center (`apic`) command group. + * + * Provides `apic extract` and `apic publish` for disaster-recovery style + * backup/restore of `Microsoft.ApiCenter/services` control-plane state. This is + * an additive provider module; the APIM command path is unchanged. + */ + +import { Command } from 'commander'; +import { logger } from '../lib/logger.js'; +import { getCloudConfig } from '../lib/cloud-config.js'; +import { buildApicBaseUrl } from '../lib/apic-uri.js'; +import { ApicServiceContext } from '../models/apic-types.js'; +import { ApicClient } from '../clients/apic-client.js'; +import { ApicArtifactStore } from '../services/apic-artifact-store.js'; +import { + runApicExtraction, + ApicExtractResult, +} from '../services/apic-extract-service.js'; +import { + runApicPublish, + ApicPublishResult, +} from '../services/apic-publish-service.js'; + +/** Default API Center ARM api-version (covers agents/skills/models/mcp/eval). */ +const DEFAULT_APIC_API_VERSION = '2024-06-01-preview'; + +interface GlobalOptions { + logLevel?: string; + subscriptionId?: string; + cloud?: string; + format?: string; + apiVersion?: string; +} + +interface ApicExtractOptions { + resourceGroup: string; + serviceName: string; + workspace?: string; + output: string; + specifications: boolean; +} + +interface ApicPublishOptions { + resourceGroup: string; + serviceName: string; + source: string; + dryRun: boolean; + specifications: boolean; +} + +/** Create the `apic` command group. */ +export function createApicCommand(): Command { + const apic = new Command('apic').description( + 'Backup and restore Azure API Center (Microsoft.ApiCenter) services', + ); + + apic + .command('extract') + .description('Extract API Center configuration to local artifact files') + .requiredOption('--resource-group ', 'Azure resource group name') + .requiredOption('--service-name ', 'API Center service instance name') + .option('--workspace ', 'Restrict extraction to a single workspace') + .option('--output ', 'Output directory path', './apic-artifacts') + .option('--no-specifications', 'Skip exporting API definition specifications') + .action(async (options: ApicExtractOptions, command: Command) => { + await executeExtract(options, command.optsWithGlobals()); + }); + + apic + .command('publish') + .description('Publish local API Center artifacts to an API Center service') + .requiredOption('--resource-group ', 'Azure resource group name') + .requiredOption('--service-name ', 'API Center service instance name') + .option('--source ', 'Source directory with artifacts', './apic-artifacts') + .option('--dry-run', 'Preview changes without applying them', false) + .option('--no-specifications', 'Skip importing API definition specifications') + .action(async (options: ApicPublishOptions, command: Command) => { + await executePublish(options, command.optsWithGlobals()); + }); + + return apic; +} + +/** Resolve the subscription id or exit with a clear error. */ +function resolveSubscriptionId(globalOpts: GlobalOptions): string { + const subscriptionId = + globalOpts.subscriptionId ?? process.env.AZURE_SUBSCRIPTION_ID; + if (!subscriptionId) { + logger.error( + 'Subscription ID required: use --subscription-id or set AZURE_SUBSCRIPTION_ID', + ); + process.exit(2); + } + return subscriptionId; +} + +/** Build the shared API Center service context and cloud auth scope. */ +function buildContext( + globalOpts: GlobalOptions, + resourceGroup: string, + serviceName: string, +): { context: ApicServiceContext; authScope: string } { + const subscriptionId = resolveSubscriptionId(globalOpts); + const apiVersion = + globalOpts.apiVersion ?? + process.env.AZURE_API_VERSION ?? + DEFAULT_APIC_API_VERSION; + const cloudName = globalOpts.cloud ?? 'public'; + const cloudConfig = getCloudConfig(cloudName); + const baseUrl = buildApicBaseUrl( + cloudName, + subscriptionId, + resourceGroup, + serviceName, + ); + return { + context: { + subscriptionId, + resourceGroup, + serviceName, + apiVersion, + baseUrl, + }, + authScope: cloudConfig.authScope, + }; +} + +async function executeExtract( + options: ApicExtractOptions, + globalOpts: GlobalOptions, +): Promise { + const { context, authScope } = buildContext( + globalOpts, + options.resourceGroup, + options.serviceName, + ); + const client = new ApicClient(authScope); + const store = new ApicArtifactStore(); + + const result = await runApicExtraction(client, store, { + context, + outputDir: options.output, + workspace: options.workspace, + includeSpecifications: options.specifications, + }); + + if (globalOpts.format === 'json') { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else { + outputExtractText(result, options.output); + } + process.exit(result.exitCode); +} + +async function executePublish( + options: ApicPublishOptions, + globalOpts: GlobalOptions, +): Promise { + const { context, authScope } = buildContext( + globalOpts, + options.resourceGroup, + options.serviceName, + ); + const client = new ApicClient(authScope); + const store = new ApicArtifactStore(); + + const result = await runApicPublish(client, store, { + context, + sourceDir: options.source, + dryRun: options.dryRun, + includeSpecifications: options.specifications, + }); + + if (globalOpts.format === 'json') { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else { + outputPublishText(result, options.dryRun); + } + process.exit(result.exitCode); +} + +function outputExtractText(result: ApicExtractResult, outputDir: string): void { + logger.info(`Extracted ${result.totalExtracted} resource(s) to ${outputDir}`); + if (result.totalSpecifications > 0) { + logger.info(`Exported ${result.totalSpecifications} specification(s)`); + } + for (const [type, count] of Object.entries(result.byType)) { + logger.info(` ${type}: ${count}`); + } + if (result.totalErrors > 0) { + logger.error(`Completed with ${result.totalErrors} error(s)`); + } +} + +function outputPublishText(result: ApicPublishResult, dryRun: boolean): void { + const verb = dryRun ? 'Would publish' : 'Published'; + logger.info(`${verb} ${result.totalPublished} resource(s)`); + if (result.totalSpecifications > 0) { + logger.info(`Imported ${result.totalSpecifications} specification(s)`); + } + for (const [type, count] of Object.entries(result.byType)) { + logger.info(` ${type}: ${count}`); + } + if (result.totalErrors > 0) { + logger.error(`Completed with ${result.totalErrors} error(s)`); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 17b00a4f..71f8a66a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,7 @@ import { logger, parseLogLevel } from '../lib/logger.js'; import { createExtractCommand } from './extract-command.js'; import { createPublishCommand } from './publish-command.js'; import { createInitCommand } from './init-command.js'; +import { createApicCommand } from './apic-command.js'; import packageJson from '../../package.json' with { type: 'json' }; const program = new Command(); @@ -68,6 +69,7 @@ program.hook('preAction', (thisCommand) => { program.addCommand(createExtractCommand()); program.addCommand(createPublishCommand()); program.addCommand(createInitCommand()); +program.addCommand(createApicCommand()); // Apply help configuration to all subcommands so global options are visible program.commands.forEach((cmd) => cmd.configureHelp({ showGlobalOptions: true })); diff --git a/src/clients/apic-client.ts b/src/clients/apic-client.ts new file mode 100644 index 00000000..e940b3d5 --- /dev/null +++ b/src/clients/apic-client.ts @@ -0,0 +1,464 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Azure API Center control-plane REST client. + * + * Implements {@link IApicClient} using `@azure/identity` DefaultAzureCredential. + * Handles pagination, retry/back-off, rate limiting, long-running operations + * (Azure-AsyncOperation/Location polling), and the API definition + * export/import specification actions. + */ + +import { DefaultAzureCredential } from '@azure/identity'; +import { IApicClient, ApicSpecification } from './iapic-client.js'; +import { ApicResourceType } from '../models/apic-resource-types.js'; +import { ApicResourceDescriptor, ApicServiceContext } from '../models/apic-types.js'; +import { + buildApicArmUri, + buildApicListUri, + buildApicLabel, +} from '../lib/apic-uri.js'; +import { logger } from '../lib/logger.js'; +import { USER_AGENT } from '../lib/user-agent.js'; + +/** HTTP error carrying the response status code and optional ARM error code. */ +export class ApicHttpError extends Error { + constructor( + public readonly status: number, + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'ApicHttpError'; + } +} + +/** Terminal states for an ARM long-running operation. */ +const TERMINAL_SUCCESS = 'Succeeded'; +const TERMINAL_FAILURES = new Set(['Failed', 'Canceled']); + +export class ApicClient implements IApicClient { + private readonly credential: DefaultAzureCredential; + private readonly authScope: string; + private tokenCache: { token: string; expiresOn: number } | null = null; + private tokenPromise: Promise | null = null; + + private static readonly TOKEN_CACHE_BUFFER_MS = 5 * 60 * 1000; + private static readonly MAX_RETRIES = 3; + private static readonly BASE_DELAY_MS = 1000; + private static readonly MAX_DELAY_MS = 30000; + /** Deadline for LRO polling — 10 minutes. */ + private static readonly ASYNC_POLL_TIMEOUT_MS = 10 * 60 * 1000; + /** Default interval between LRO polls when no Retry-After header is present. */ + private static readonly ASYNC_POLL_INTERVAL_MS = 5000; + private static readonly RESOURCE_GROUP_API_VERSION = '2021-04-01'; + + constructor(authScope = 'https://management.azure.com/.default') { + this.credential = new DefaultAzureCredential(); + this.authScope = authScope; + } + + private async getToken(): Promise { + if ( + this.tokenCache && + this.tokenCache.expiresOn > Date.now() + ApicClient.TOKEN_CACHE_BUFFER_MS + ) { + return this.tokenCache.token; + } + if (!this.tokenPromise) { + this.tokenPromise = this.fetchToken().finally(() => { + this.tokenPromise = null; + }); + } + return this.tokenPromise; + } + + private async fetchToken(): Promise { + const tokenResponse = await this.credential.getToken(this.authScope); + this.tokenCache = { + token: tokenResponse.token, + expiresOn: tokenResponse.expiresOnTimestamp, + }; + return tokenResponse.token; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private backoff(attempt: number): number { + const exp = Math.min( + ApicClient.BASE_DELAY_MS * Math.pow(2, attempt), + ApicClient.MAX_DELAY_MS, + ); + return exp + Math.random() * 0.3 * exp; + } + + /** + * Execute an HTTP request with auth, retry on 429/5xx, and graceful 404 for + * GET. `skipAuth` is used for self-authenticating SAS/link URLs. + */ + private async request( + url: string, + options: RequestInit = {}, + skipAuth = false, + ): Promise { + const headers = new Headers(options.headers); + if (skipAuth) { + headers.delete('Authorization'); + } else { + headers.set('Authorization', `Bearer ${await this.getToken()}`); + headers.set('Content-Type', 'application/json'); + } + headers.set('User-Agent', USER_AGENT); + + let attempt = 0; + const method = options.method ?? 'GET'; + // Strip SAS query before logging. + const logUrl = skipAuth ? url.split('?')[0] : url; + + while (attempt <= ApicClient.MAX_RETRIES) { + try { + logger.debug(`HTTP ${method} ${logUrl}`); + const response = await fetch(url, { ...options, headers }); + + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + const delaySeconds = retryAfter ? parseInt(retryAfter, 10) : Math.pow(2, attempt); + logger.warn(`Rate limited (429), retrying after ${delaySeconds}s`); + await this.delay(delaySeconds * 1000); + attempt++; + continue; + } + + if (response.status >= 500 && response.status < 600) { + if (attempt < ApicClient.MAX_RETRIES) { + const ms = this.backoff(attempt); + logger.warn(`Server error ${response.status}, retrying after ${Math.round(ms)}ms`); + await this.delay(ms); + attempt++; + continue; + } + } + + if (response.status === 404 && (method === 'GET' || method === 'DELETE')) { + return response; + } + + if (!response.ok) { + const errorText = await response.text(); + throw new ApicHttpError( + response.status, + `HTTP ${response.status}: ${errorText}`, + this.parseErrorCode(errorText), + ); + } + + return response; + } catch (error) { + if (error instanceof ApicHttpError && error.status >= 400 && error.status < 500) { + throw error; + } + if (attempt >= ApicClient.MAX_RETRIES) { + throw error; + } + const ms = this.backoff(attempt); + logger.warn(`Request failed: ${(error as Error).message}, retrying after ${Math.round(ms)}ms`); + await this.delay(ms); + attempt++; + } + } + throw new Error('Max retries exceeded'); + } + + private parseErrorCode(errorText: string): string | undefined { + try { + const body = JSON.parse(errorText) as { error?: { code?: unknown } }; + const code = body.error?.code; + return typeof code === 'string' ? code : undefined; + } catch { + return undefined; + } + } + + async *listResources( + context: ApicServiceContext, + type: ApicResourceType, + parentNameParts: string[], + workspace?: string, + ): AsyncIterable> { + let url = buildApicListUri(context, type, parentNameParts, workspace); + + while (url) { + let response: Response; + try { + response = await this.request(url); + } catch (error) { + // A missing collection (404) is an empty list, not a failure. + if (error instanceof ApicHttpError && error.status === 404) { + return; + } + throw error; + } + + if (response.status === 404) { + return; + } + + const data = (await response.json()) as { value?: unknown[]; nextLink?: string }; + if (Array.isArray(data.value)) { + for (const item of data.value) { + yield item as Record; + } + } + url = data.nextLink ?? ''; + } + } + + async getResource( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise | undefined> { + const url = buildApicArmUri(context, descriptor); + const response = await this.request(url, { method: 'GET' }); + if (response.status === 404) { + return undefined; + } + const text = await response.text(); + return text.trim() ? (JSON.parse(text) as Record) : {}; + } + + async putResource( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + payload: Record, + ): Promise> { + const url = buildApicArmUri(context, descriptor); + const response = await this.request(url, { + method: 'PUT', + body: JSON.stringify(payload), + }); + + if (response.status === 201 || response.status === 202) { + const asyncUrl = this.asyncOperationUrl(response); + if (asyncUrl) { + await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor)); + const settled = await this.getResource(context, descriptor); + return settled ?? {}; + } + } + + const text = await response.text(); + return text.trim() ? (JSON.parse(text) as Record) : {}; + } + + async deleteResource( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise { + const url = buildApicArmUri(context, descriptor); + const response = await this.request(url, { method: 'DELETE' }); + if (response.status === 404) { + return false; + } + if (response.status === 202) { + const asyncUrl = this.asyncOperationUrl(response); + if (asyncUrl) { + await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor), true); + } + } + return true; + } + + async exportSpecification( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise { + const actionUrl = this.actionUrl(context, descriptor, 'exportSpecification'); + let response: Response; + try { + response = await this.request(actionUrl, { method: 'POST' }); + } catch (error) { + if (error instanceof ApicHttpError && error.status === 404) { + return undefined; + } + logger.warn( + `Failed to export specification for ${buildApicLabel(descriptor)}: ${(error as Error).message}`, + ); + return undefined; + } + + if (response.status === 404) { + return undefined; + } + + const text = await response.text(); + if (!text.trim()) { + return undefined; + } + const body = JSON.parse(text) as { format?: string; value?: string }; + if (!body.value) { + return undefined; + } + + // `format: 'link'` returns a SAS URL; fetch the actual content. `inline` + // returns the content directly. + let content = body.value; + if (body.format === 'link') { + const blob = await this.request(body.value, {}, true); + if (!blob.ok) { + logger.warn(`Failed to fetch specification link for ${buildApicLabel(descriptor)}`); + return undefined; + } + content = await blob.text(); + } + + const specName = this.readSpecName(context, descriptor); + return { content, name: await specName.name, version: await specName.version }; + } + + async importSpecification( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + spec: ApicSpecification, + ): Promise { + const actionUrl = this.actionUrl(context, descriptor, 'importSpecification'); + const response = await this.request(actionUrl, { + method: 'POST', + body: JSON.stringify({ + value: spec.content, + format: 'inline', + specification: { name: spec.name, ...(spec.version ? { version: spec.version } : {}) }, + }), + }); + + if (response.status === 202) { + const asyncUrl = this.asyncOperationUrl(response); + if (asyncUrl) { + await this.pollAsyncOperation(asyncUrl, `${buildApicLabel(descriptor)} (spec import)`); + } + } + } + + async validatePreFlight(context: ApicServiceContext): Promise { + const cloudRoot = context.baseUrl.split('/subscriptions/')[0]; + const rgUrl = + `${cloudRoot}/subscriptions/${encodeURIComponent(context.subscriptionId)}` + + `/resourceGroups/${encodeURIComponent(context.resourceGroup)}` + + `?api-version=${ApicClient.RESOURCE_GROUP_API_VERSION}`; + const rgResponse = await this.request(rgUrl, { method: 'GET' }); + if (rgResponse.status === 404) { + throw new Error( + `Resource group "${context.resourceGroup}" not found in subscription ${context.subscriptionId}.`, + ); + } + + const serviceUrl = `${context.baseUrl}?api-version=${context.apiVersion}`; + const svcResponse = await this.request(serviceUrl, { method: 'GET' }); + if (svcResponse.status === 404) { + throw new Error( + `API Center service "${context.serviceName}" not found in resource group ` + + `"${context.resourceGroup}". Create it (Bicep/ARM) before publishing.`, + ); + } + } + + /** + * Read the specification name/version from the definition resource so that + * export produces a spec that can be re-imported with the correct format. + */ + private readSpecName( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): { name: Promise; version: Promise } { + const definition = this.getResource(context, descriptor); + const spec = definition.then((d) => { + const props = (d?.properties ?? {}) as { specification?: { name?: string; version?: string } }; + return props.specification ?? {}; + }); + return { + name: spec.then((s) => s.name ?? 'openapi'), + version: spec.then((s) => s.version), + }; + } + + /** Build an action URL (`.../{resource}/{action}?api-version=...`). */ + private actionUrl( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + action: string, + ): string { + const [base, query] = buildApicArmUri(context, descriptor).split('?'); + return `${base}/${action}?${query}`; + } + + /** Extract the LRO polling URL from response headers, if any. */ + private asyncOperationUrl(response: Response): string | undefined { + return ( + response.headers.get('Azure-AsyncOperation') ?? + response.headers.get('Location') ?? + undefined + ); + } + + /** + * Poll an ARM async-operation URL until it reaches a terminal state. + * @param treatMissingAsSuccess when true, a 404 during polling (resource gone) + * is treated as success — used for delete operations. + */ + private async pollAsyncOperation( + asyncUrl: string, + label: string, + treatMissingAsSuccess = false, + ): Promise { + const deadline = Date.now() + ApicClient.ASYNC_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + const response = await this.request(asyncUrl, { method: 'GET' }); + if (response.status === 404) { + if (treatMissingAsSuccess) { + return; + } + throw new Error(`Async operation for ${label} not found (404).`); + } + + const retryAfter = response.headers.get('Retry-After'); + const text = await response.text(); + const status = this.readOperationStatus(text, response.status); + + if (status === TERMINAL_SUCCESS) { + return; + } + if (TERMINAL_FAILURES.has(status)) { + throw new Error(`Async operation for ${label} ${status}: ${text.substring(0, 500)}`); + } + + const waitMs = retryAfter + ? parseInt(retryAfter, 10) * 1000 + : ApicClient.ASYNC_POLL_INTERVAL_MS; + await this.delay(waitMs); + } + throw new Error(`Async operation for ${label} timed out after ${ApicClient.ASYNC_POLL_TIMEOUT_MS}ms.`); + } + + /** + * Resolve the operation status from a polling response. ARM async operations + * report `{ status }`; provisioning-style responses report + * `{ properties: { provisioningState } }`. A bare 200 with no status is + * treated as success. + */ + private readOperationStatus(text: string, httpStatus: number): string { + if (!text.trim()) { + return httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'; + } + try { + const body = JSON.parse(text) as { + status?: string; + properties?: { provisioningState?: string }; + }; + const state = body.status ?? body.properties?.provisioningState; + return state ?? (httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'); + } catch { + return httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'; + } + } +} diff --git a/src/clients/iapic-client.ts b/src/clients/iapic-client.ts new file mode 100644 index 00000000..6da2632e --- /dev/null +++ b/src/clients/iapic-client.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * IApicClient — abstraction over the Azure API Center control-plane REST API. + */ + +import { ApicResourceType } from '../models/apic-resource-types.js'; +import { ApicResourceDescriptor, ApicServiceContext } from '../models/apic-types.js'; + +/** + * An API definition specification body plus the format name/version required to + * re-import it via `importSpecification`. + */ +export interface ApicSpecification { + /** Raw specification content (OpenAPI/AsyncAPI/WSDL/…). */ + content: string; + /** Specification format name, e.g. `openapi`, `wsdl`, `graphql`. */ + name: string; + /** Optional specification version, e.g. `3.0.1`. */ + version?: string; +} + +export interface IApicClient { + /** + * List all resources of a type under the given parent (empty parentNameParts + * for scope-root types). Handles ARM pagination via nextLink. + */ + listResources( + context: ApicServiceContext, + type: ApicResourceType, + parentNameParts: string[], + workspace?: string, + ): AsyncIterable>; + + /** GET a single resource; undefined on 404. */ + getResource( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise | undefined>; + + /** PUT (create/update) a resource, polling any long-running operation. */ + putResource( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + payload: Record, + ): Promise>; + + /** DELETE a resource, polling any long-running operation. Returns false on 404. */ + deleteResource( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise; + + /** + * Export an API definition's specification body via the + * `exportSpecification` action. Returns undefined when no spec is available. + */ + exportSpecification( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise; + + /** + * Import an API definition's specification body via the + * `importSpecification` action, polling the long-running operation. + */ + importSpecification( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + spec: ApicSpecification, + ): Promise; + + /** + * Validate that the resource group and API Center service exist. Throws a + * clear error otherwise. Must run before publish. + */ + validatePreFlight(context: ApicServiceContext): Promise; +} diff --git a/src/lib/apic-uri.ts b/src/lib/apic-uri.ts new file mode 100644 index 00000000..0bb563f6 --- /dev/null +++ b/src/lib/apic-uri.ts @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * APIC descriptor ↔ ARM URI / list-path / artifact-path mapping. + */ + +import * as path from 'node:path'; +import { + APIC_RESOURCE_TYPE_METADATA, + ApicResourceType, +} from '../models/apic-resource-types.js'; +import { ApicResourceDescriptor, ApicServiceContext } from '../models/apic-types.js'; +import { formatTemplatePath, countTemplatePlaceholders } from './resource-path.js'; +import { getCloudConfig } from './cloud-config.js'; + +/** + * Build the API Center ARM base URL for a service instance. + * `.../providers/Microsoft.ApiCenter/services/{name}`. + */ +export function buildApicBaseUrl( + cloudName: string, + subscriptionId: string, + resourceGroup: string, + serviceName: string, +): string { + const config = getCloudConfig(cloudName); + return ( + `${config.armBaseUrl}/subscriptions/${encodeURIComponent(subscriptionId)}` + + `/resourceGroups/${encodeURIComponent(resourceGroup)}` + + `/providers/Microsoft.ApiCenter/services/${encodeURIComponent(serviceName)}` + ); +} + +/** + * Base URL for a descriptor's scope (service root, or the workspace segment for + * workspace-scoped types). Does not include the resource path suffix. + */ +function scopeBaseUrl( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, +): string { + const meta = APIC_RESOURCE_TYPE_METADATA[descriptor.type]; + if (meta.scope === 'workspace') { + if (!descriptor.workspace) { + throw new Error( + `Workspace-scoped resource ${descriptor.type} is missing a workspace name`, + ); + } + return `${context.baseUrl}/workspaces/${encodeURIComponent(descriptor.workspace)}`; + } + return context.baseUrl; +} + +/** + * Build the full ARM URI (including api-version) for a descriptor. + */ +export function buildApicArmUri( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, +): string { + const meta = APIC_RESOURCE_TYPE_METADATA[descriptor.type]; + const placeholderCount = countTemplatePlaceholders(meta.armPathSuffix); + if (descriptor.nameParts.length < placeholderCount) { + throw new Error( + `Unresolved placeholder in ARM path for ${descriptor.type}: expected ` + + `${placeholderCount} name-parts, got ${descriptor.nameParts.length}`, + ); + } + const suffix = formatTemplatePath( + meta.armPathSuffix, + descriptor.nameParts.map(encodeURIComponent), + ); + return `${scopeBaseUrl(context, descriptor)}/${suffix}?api-version=${context.apiVersion}`; +} + +/** + * Build the collection (LIST) URL for a resource type, given the already-known + * name-parts of its parent (empty for scope-root types). Derived from the type's + * ARM path suffix by filling the leading placeholders and dropping the trailing + * `/{n}` segment. + */ +export function buildApicListUri( + context: ApicServiceContext, + type: ApicResourceType, + parentNameParts: string[], + workspace?: string, +): string { + const meta = APIC_RESOURCE_TYPE_METADATA[type]; + // Drop the final `/{n}` placeholder segment to get the collection path. + const collectionTemplate = meta.armPathSuffix.replace(/\/\{\d+\}$/, ''); + const filled = formatTemplatePath( + collectionTemplate, + parentNameParts.map(encodeURIComponent), + ); + const base = + meta.scope === 'workspace' + ? `${context.baseUrl}/workspaces/${encodeURIComponent(workspace ?? '')}` + : context.baseUrl; + return `${base}/${filled}?api-version=${context.apiVersion}`; +} + +/** + * Human-readable label for logs (no encoding). + */ +export function buildApicLabel(descriptor: ApicResourceDescriptor): string { + const meta = APIC_RESOURCE_TYPE_METADATA[descriptor.type]; + const suffix = formatTemplatePath(meta.armPathSuffix, descriptor.nameParts); + return descriptor.workspace + ? `workspaces/${descriptor.workspace}/${suffix}` + : suffix; +} + +/** + * Absolute artifact directory for a descriptor under `baseDir`. + * Workspace-scoped resources are nested under `workspaces/{ws}/`. + */ +export function apicArtifactDir( + baseDir: string, + descriptor: ApicResourceDescriptor, +): string { + const meta = APIC_RESOURCE_TYPE_METADATA[descriptor.type]; + const rel = formatTemplatePath(meta.artifactDirectory, descriptor.nameParts); + const scoped = + meta.scope === 'workspace' && descriptor.workspace + ? path.join('workspaces', descriptor.workspace, rel) + : rel; + return path.join(baseDir, scoped); +} + +/** + * Absolute info-file path for a descriptor under `baseDir`. + */ +export function apicInfoFilePath( + baseDir: string, + descriptor: ApicResourceDescriptor, +): string { + const meta = APIC_RESOURCE_TYPE_METADATA[descriptor.type]; + return path.join(apicArtifactDir(baseDir, descriptor), meta.infoFile); +} diff --git a/src/models/apic-resource-types.ts b/src/models/apic-resource-types.ts new file mode 100644 index 00000000..ed081d32 --- /dev/null +++ b/src/models/apic-resource-types.ts @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Azure API Center (APIC) resource-type model. + * + * Mirrors the metadata-driven design used for APIM (`resource-types.ts`) but + * describes the `Microsoft.ApiCenter/services` control-plane hierarchy. Each + * type is pure data: an ARM path suffix with positional `{0}`, `{1}`, … + * placeholders (filled from a descriptor's `nameParts`), the artifact directory + * template, the info-file name, its scope, and its parent in the resource tree. + * + * The generic extract/publish services walk this tree; no per-type code is + * required for the common ARM CRUD case. + */ + +export enum ApicResourceType { + // Service-scoped + MetadataSchema = 'MetadataSchema', + Workspace = 'Workspace', + + // Workspace-scoped + Environment = 'Environment', + Api = 'Api', + ApiVersion = 'ApiVersion', + ApiDefinition = 'ApiDefinition', + ApiDeployment = 'ApiDeployment', + ApiSource = 'ApiSource', + AnalyzerConfig = 'AnalyzerConfig', + AuthConfig = 'AuthConfig', + ResourceLink = 'ResourceLink', + Plugin = 'Plugin', + Model = 'Model', + Agent = 'Agent', + AgentVersion = 'AgentVersion', + Skill = 'Skill', + SkillVersion = 'SkillVersion', + SkillEvaluationConfiguration = 'SkillEvaluationConfiguration', + AgentEvaluationConfiguration = 'AgentEvaluationConfiguration', + McpRegistry = 'McpRegistry', +} + +/** + * Scope at which a resource type lives relative to the service base URL. + * - 'service' : directly under `.../services/{name}` (e.g. metadataSchemas). + * - 'workspace' : under `.../services/{name}/workspaces/{ws}` — the workspace + * segment is added from the descriptor, not the path suffix. + */ +export type ApicScope = 'service' | 'workspace'; + +/** + * Pure-data descriptor for a single APIC resource type. + */ +export interface ApicResourceTypeMetadata { + /** + * ARM path suffix relative to the (service or workspace) scope base, with + * positional placeholders. Never includes the `workspaces/{ws}` segment — + * that is prepended for workspace-scoped types from the descriptor. + * + * Examples: + * Api 'apis/{0}' nameParts: [api] + * ApiVersion 'apis/{0}/versions/{1}' nameParts: [api, version] + * ApiDefinition 'apis/{0}/versions/{1}/definitions/{2}' + */ + readonly armPathSuffix: string; + /** Artifact directory template (positional placeholders as above). */ + readonly artifactDirectory: string; + /** Info-file name written inside the artifact directory. */ + readonly infoFile: string; + /** Scope of the resource type. */ + readonly scope: ApicScope; + /** Parent type in the resource tree, or null for a scope root. */ + readonly parent: ApicResourceType | null; + /** + * True when this type carries a separately-exported specification body that + * must be fetched via `exportSpecification` and re-applied via + * `importSpecification` (currently only ApiDefinition). + */ + readonly hasSpecification?: boolean; +} + +export const APIC_RESOURCE_TYPE_METADATA: Record = { + [ApicResourceType.MetadataSchema]: { + armPathSuffix: 'metadataSchemas/{0}', + artifactDirectory: 'metadataSchemas/{0}', + infoFile: 'metadataSchemaInformation.json', + scope: 'service', + parent: null, + }, + [ApicResourceType.Workspace]: { + armPathSuffix: 'workspaces/{0}', + artifactDirectory: 'workspaces/{0}', + infoFile: 'workspaceInformation.json', + scope: 'service', + parent: null, + }, + [ApicResourceType.Environment]: { + armPathSuffix: 'environments/{0}', + artifactDirectory: 'environments/{0}', + infoFile: 'environmentInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.Api]: { + armPathSuffix: 'apis/{0}', + artifactDirectory: 'apis/{0}', + infoFile: 'apiInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.ApiVersion]: { + armPathSuffix: 'apis/{0}/versions/{1}', + artifactDirectory: 'apis/{0}/versions/{1}', + infoFile: 'apiVersionInformation.json', + scope: 'workspace', + parent: ApicResourceType.Api, + }, + [ApicResourceType.ApiDefinition]: { + armPathSuffix: 'apis/{0}/versions/{1}/definitions/{2}', + artifactDirectory: 'apis/{0}/versions/{1}/definitions/{2}', + infoFile: 'apiDefinitionInformation.json', + scope: 'workspace', + parent: ApicResourceType.ApiVersion, + hasSpecification: true, + }, + [ApicResourceType.ApiDeployment]: { + armPathSuffix: 'apis/{0}/deployments/{1}', + artifactDirectory: 'apis/{0}/deployments/{1}', + infoFile: 'apiDeploymentInformation.json', + scope: 'workspace', + parent: ApicResourceType.Api, + }, + [ApicResourceType.ApiSource]: { + armPathSuffix: 'apiSources/{0}', + artifactDirectory: 'apiSources/{0}', + infoFile: 'apiSourceInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.AnalyzerConfig]: { + armPathSuffix: 'analyzerConfigs/{0}', + artifactDirectory: 'analyzerConfigs/{0}', + infoFile: 'analyzerConfigInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.AuthConfig]: { + armPathSuffix: 'authConfigs/{0}', + artifactDirectory: 'authConfigs/{0}', + infoFile: 'authConfigInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.ResourceLink]: { + armPathSuffix: 'links/{0}', + artifactDirectory: 'links/{0}', + infoFile: 'resourceLinkInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.Plugin]: { + armPathSuffix: 'plugins/{0}', + artifactDirectory: 'plugins/{0}', + infoFile: 'pluginInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.Model]: { + armPathSuffix: 'models/{0}', + artifactDirectory: 'models/{0}', + infoFile: 'modelInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.Agent]: { + armPathSuffix: 'agents/{0}', + artifactDirectory: 'agents/{0}', + infoFile: 'agentInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.AgentVersion]: { + armPathSuffix: 'agents/{0}/versions/{1}', + artifactDirectory: 'agents/{0}/versions/{1}', + infoFile: 'agentVersionInformation.json', + scope: 'workspace', + parent: ApicResourceType.Agent, + }, + [ApicResourceType.Skill]: { + armPathSuffix: 'skills/{0}', + artifactDirectory: 'skills/{0}', + infoFile: 'skillInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.SkillVersion]: { + armPathSuffix: 'skills/{0}/versions/{1}', + artifactDirectory: 'skills/{0}/versions/{1}', + infoFile: 'skillVersionInformation.json', + scope: 'workspace', + parent: ApicResourceType.Skill, + }, + [ApicResourceType.SkillEvaluationConfiguration]: { + armPathSuffix: 'skillEvaluationConfigurations/{0}', + artifactDirectory: 'skillEvaluationConfigurations/{0}', + infoFile: 'skillEvaluationConfigurationInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.AgentEvaluationConfiguration]: { + armPathSuffix: 'agentEvaluationConfigurations/{0}', + artifactDirectory: 'agentEvaluationConfigurations/{0}', + infoFile: 'agentEvaluationConfigurationInformation.json', + scope: 'workspace', + parent: null, + }, + [ApicResourceType.McpRegistry]: { + armPathSuffix: 'mcpRegistries/{0}', + artifactDirectory: 'mcpRegistries/{0}', + infoFile: 'mcpRegistryInformation.json', + scope: 'workspace', + parent: null, + }, +}; + +/** + * Restore (publish) dependency ordering. Each inner array is a tier that may be + * published in any order internally; tiers are applied sequentially so that a + * resource's references always exist before it is created. See DR plan §6. + */ +export const APIC_DEPENDENCY_TIERS: readonly ApicResourceType[][] = [ + [ApicResourceType.MetadataSchema], + [ApicResourceType.Workspace], + [ApicResourceType.Environment, ApicResourceType.AuthConfig], + [ApicResourceType.ApiSource], + [ApicResourceType.Api], + [ApicResourceType.ApiVersion], + [ApicResourceType.ApiDefinition], + [ApicResourceType.ApiDeployment], + [ + ApicResourceType.AnalyzerConfig, + ApicResourceType.ResourceLink, + ApicResourceType.Plugin, + ApicResourceType.Model, + ], + [ApicResourceType.Agent], + [ApicResourceType.AgentVersion], + [ApicResourceType.Skill], + [ApicResourceType.SkillVersion], + [ + ApicResourceType.SkillEvaluationConfiguration, + ApicResourceType.AgentEvaluationConfiguration, + ApicResourceType.McpRegistry, + ], +]; + +/** + * Root resource types for a given scope (those with no parent). Used by the + * extractor to enumerate the top of each scope's tree. + */ +export function getApicRootTypes(scope: ApicScope): ApicResourceType[] { + return (Object.keys(APIC_RESOURCE_TYPE_METADATA) as ApicResourceType[]).filter( + (type) => { + const meta = APIC_RESOURCE_TYPE_METADATA[type]; + return meta.scope === scope && meta.parent === null; + }, + ); +} + +/** + * Direct child types of a given parent type. Used to recurse the resource tree + * during extraction. + */ +export function getApicChildTypes(parent: ApicResourceType): ApicResourceType[] { + return (Object.keys(APIC_RESOURCE_TYPE_METADATA) as ApicResourceType[]).filter( + (type) => APIC_RESOURCE_TYPE_METADATA[type].parent === parent, + ); +} diff --git a/src/models/apic-types.ts b/src/models/apic-types.ts new file mode 100644 index 00000000..0d657eef --- /dev/null +++ b/src/models/apic-types.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Core APIC types: service context and resource descriptor. + */ + +import { ApicResourceType } from './apic-resource-types.js'; + +/** + * Connection/target context for a single API Center service instance. + */ +export interface ApicServiceContext { + readonly subscriptionId: string; + readonly resourceGroup: string; + /** API Center service (catalog) name. */ + readonly serviceName: string; + /** ARM api-version applied to requests. */ + readonly apiVersion: string; + /** Fully-qualified base URL: `.../providers/Microsoft.ApiCenter/services/{name}`. */ + readonly baseUrl: string; +} + +/** + * Identifies a single APIC resource. + */ +export interface ApicResourceDescriptor { + readonly type: ApicResourceType; + /** + * Ordered name-parts that fill the positional `{0}`, `{1}`, … placeholders in + * both `armPathSuffix` and `artifactDirectory` for this resource type. + */ + readonly nameParts: string[]; + /** Workspace name for workspace-scoped resources; undefined for service scope. */ + readonly workspace?: string; +} + +/** + * A resource read from ARM (or an artifact file) together with its descriptor. + */ +export interface ApicResourcePayload { + readonly descriptor: ApicResourceDescriptor; + /** Raw ARM JSON (ARM envelope with `properties`). Never parsed into typed fields. */ + readonly json: Record; +} diff --git a/src/models/provider.ts b/src/models/provider.ts new file mode 100644 index 00000000..e8a218c5 --- /dev/null +++ b/src/models/provider.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Provider abstraction. + * + * apiops-cli was originally built for a single ARM resource provider + * (Azure API Management). This module introduces a small, additive provider + * concept so the same CLI can also serve Azure API Center without duplicating + * the whole tool. Each provider declares only the ARM-facing facts that differ + * between resource providers (namespace, service segment, api-versions). + * + * The existing APIM engine continues to use its own hardcoded constants; the + * APIC command group consumes {@link APIC_PROVIDER}. Over time the APIM path can + * be migrated onto this abstraction, but that refactor is intentionally out of + * scope here to avoid any behavioural change to the mature APIM flow. + */ + +export type ProviderId = 'apim' | 'apic'; + +/** + * ARM-facing description of a resource provider that the generic engine needs + * in order to build request URLs and select api-versions. + */ +export interface ProviderInfo { + /** Stable provider identifier used on the CLI and in the registry. */ + readonly id: ProviderId; + /** Human-readable name for log/help output. */ + readonly displayName: string; + /** ARM resource-provider namespace, e.g. `Microsoft.ApiCenter`. */ + readonly armNamespace: string; + /** + * The resource-type segment for the top-level service resource under the + * namespace, e.g. `service` (APIM) or `services` (APIC). Used to build the + * `.../providers/{namespace}/{serviceResourceType}/{name}` base URL. + */ + readonly serviceResourceType: string; + /** Default api-version applied to every request unless overridden per type. */ + readonly defaultApiVersion: string; + /** + * Per-resource-type api-version overrides. Keyed by the provider's own + * resource-type identifier (string) so this stays provider-agnostic. + */ + readonly apiVersionOverrides: Readonly>; +} + +/** + * Azure API Management. Declared for completeness and future migration; the + * current APIM command path does not yet consume it. + */ +export const APIM_PROVIDER: ProviderInfo = { + id: 'apim', + displayName: 'Azure API Management', + armNamespace: 'Microsoft.ApiManagement', + serviceResourceType: 'service', + defaultApiVersion: '2024-05-01', + apiVersionOverrides: {}, +}; + +/** + * Azure API Center. The default api-version is the newest preview so that + * preview resource types (agents, skills, models, MCP registries, evaluation + * configurations) are returned by ARM list endpoints. GA-only types are still + * served correctly by the preview api-version. + */ +export const APIC_PROVIDER: ProviderInfo = { + id: 'apic', + displayName: 'Azure API Center', + armNamespace: 'Microsoft.ApiCenter', + serviceResourceType: 'services', + defaultApiVersion: '2024-06-01-preview', + apiVersionOverrides: {}, +}; + +const REGISTRY: Readonly> = { + apim: APIM_PROVIDER, + apic: APIC_PROVIDER, +}; + +/** + * Resolve a provider by id. Throws for unknown ids so callers fail fast. + */ +export function getProvider(id: ProviderId): ProviderInfo { + const provider = REGISTRY[id]; + if (!provider) { + const valid = Object.keys(REGISTRY).join(', '); + throw new Error(`Unknown provider "${id}". Valid values: ${valid}`); + } + return provider; +} diff --git a/src/services/apic-artifact-store.ts b/src/services/apic-artifact-store.ts new file mode 100644 index 00000000..509322d9 --- /dev/null +++ b/src/services/apic-artifact-store.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Filesystem artifact store for API Center extract/publish. + * + * Writes one directory per resource with an `*Information.json` info file, and a + * `specification/` sidecar for API definition bodies. Mirrors the layout in the + * DR plan §5.3. Also walks the tree to reconstruct descriptors for publish. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + APIC_RESOURCE_TYPE_METADATA, + ApicResourceType, +} from '../models/apic-resource-types.js'; +import { ApicResourceDescriptor } from '../models/apic-types.js'; +import { apicArtifactDir, apicInfoFilePath } from '../lib/apic-uri.js'; + +/** Map every info-file name back to its resource type (names are unique). */ +const INFO_FILE_TO_TYPE: ReadonlyMap = new Map( + (Object.keys(APIC_RESOURCE_TYPE_METADATA) as ApicResourceType[]).map((type) => [ + APIC_RESOURCE_TYPE_METADATA[type].infoFile, + type, + ]), +); + +/** File extension used for an exported specification, by format name. */ +function specExtension(formatName: string, content: string): string { + switch (formatName.toLowerCase()) { + case 'wsdl': + case 'wadl': + return 'xml'; + case 'graphql': + return 'graphql'; + case 'grpc': + return 'proto'; + default: + // openapi / asyncapi / raml / other — JSON or YAML by sniffing. + return content.trimStart().startsWith('{') ? 'json' : 'yaml'; + } +} + +export class ApicArtifactStore { + /** Write a resource's ARM JSON to its info file. */ + async writeResource( + baseDir: string, + descriptor: ApicResourceDescriptor, + json: Record, + ): Promise { + const dir = apicArtifactDir(baseDir, descriptor); + await fs.mkdir(dir, { recursive: true }); + const file = apicInfoFilePath(baseDir, descriptor); + await fs.writeFile(file, JSON.stringify(json, null, 2) + '\n', 'utf8'); + } + + /** Write an exported specification body under the definition's sidecar dir. */ + async writeSpecification( + baseDir: string, + descriptor: ApicResourceDescriptor, + content: string, + formatName: string, + ): Promise { + const specDir = path.join(apicArtifactDir(baseDir, descriptor), 'specification'); + await fs.mkdir(specDir, { recursive: true }); + const file = path.join(specDir, `specification.${specExtension(formatName, content)}`); + await fs.writeFile(file, content, 'utf8'); + } + + /** Read a resource's ARM JSON, or undefined if absent. */ + async readResource( + baseDir: string, + descriptor: ApicResourceDescriptor, + ): Promise | undefined> { + const file = apicInfoFilePath(baseDir, descriptor); + try { + const text = await fs.readFile(file, 'utf8'); + return JSON.parse(text) as Record; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + } + + /** Read the single exported specification file, or undefined if none. */ + async readSpecification( + baseDir: string, + descriptor: ApicResourceDescriptor, + ): Promise { + const specDir = path.join(apicArtifactDir(baseDir, descriptor), 'specification'); + let entries: string[]; + try { + entries = await fs.readdir(specDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + const specFile = entries.find((e) => e.startsWith('specification.')); + if (!specFile) { + return undefined; + } + return fs.readFile(path.join(specDir, specFile), 'utf8'); + } + + /** + * Walk the artifact tree and reconstruct every resource descriptor from its + * info-file location. + */ + async listDescriptors(baseDir: string): Promise { + const descriptors: ApicResourceDescriptor[] = []; + const infoFiles = await this.findInfoFiles(baseDir); + for (const absFile of infoFiles) { + const fileName = path.basename(absFile); + const type = INFO_FILE_TO_TYPE.get(fileName); + if (!type) { + continue; + } + const relDir = path + .relative(baseDir, path.dirname(absFile)) + .split(path.sep) + .join('/'); + const descriptor = this.pathToDescriptor(type, relDir); + if (descriptor) { + descriptors.push(descriptor); + } + } + return descriptors; + } + + /** Recursively collect files whose name is a known info file. */ + private async findInfoFiles(dir: string): Promise { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + const results: string[] = []; + for (const entry of entries) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await this.findInfoFiles(abs))); + } else if (INFO_FILE_TO_TYPE.has(entry.name)) { + results.push(abs); + } + } + return results; + } + + /** + * Reverse a relative artifact directory into a descriptor. Handles the + * `workspaces/{ws}/` prefix for workspace-scoped types. + */ + private pathToDescriptor( + type: ApicResourceType, + relDir: string, + ): ApicResourceDescriptor | undefined { + const meta = APIC_RESOURCE_TYPE_METADATA[type]; + let remainder = relDir; + let workspace: string | undefined; + + if (meta.scope === 'workspace') { + const match = /^workspaces\/([^/]+)\/(.*)$/.exec(relDir); + if (!match) { + return undefined; + } + workspace = decodeURIComponent(match[1]); + remainder = match[2]; + } + + const nameParts = parseTemplateSegments(meta.artifactDirectory, remainder); + if (!nameParts) { + return undefined; + } + return { type, nameParts, workspace }; + } +} + +/** + * Parse an actual `/`-joined path against a positional-placeholder template, + * returning the captured name-parts (in placeholder order) or undefined if the + * literal segments do not match. + */ +export function parseTemplateSegments( + template: string, + actual: string, +): string[] | undefined { + const templateSegs = template.split('/'); + const actualSegs = actual.split('/'); + if (templateSegs.length !== actualSegs.length) { + return undefined; + } + const indexed: Array<{ index: number; value: string }> = []; + for (let i = 0; i < templateSegs.length; i++) { + const t = templateSegs[i]; + const a = actualSegs[i]; + const placeholder = /^\{(\d+)\}$/.exec(t); + if (placeholder) { + indexed.push({ index: +placeholder[1], value: a }); + } else if (t !== a) { + return undefined; + } + } + return indexed.sort((x, y) => x.index - y.index).map((p) => p.value); +} diff --git a/src/services/apic-extract-service.ts b/src/services/apic-extract-service.ts new file mode 100644 index 00000000..f22da20a --- /dev/null +++ b/src/services/apic-extract-service.ts @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * API Center extract orchestration. + * + * Walks the `Microsoft.ApiCenter/services` control-plane tree and writes each + * resource to the artifact store, exporting API definition specification bodies + * along the way. + */ + +import { IApicClient } from '../clients/iapic-client.js'; +import { ApicArtifactStore } from './apic-artifact-store.js'; +import { + APIC_RESOURCE_TYPE_METADATA, + ApicResourceType, + getApicChildTypes, + getApicRootTypes, +} from '../models/apic-resource-types.js'; +import { ApicResourceDescriptor, ApicServiceContext } from '../models/apic-types.js'; +import { buildApicLabel } from '../lib/apic-uri.js'; +import { logger } from '../lib/logger.js'; + +export interface ApicExtractConfig { + readonly context: ApicServiceContext; + readonly outputDir: string; + /** Restrict extraction to a single workspace (defaults to all). */ + readonly workspace?: string; + /** Also export API definition specification bodies (default true). */ + readonly includeSpecifications?: boolean; +} + +export interface ApicExtractResult { + totalExtracted: number; + totalSpecifications: number; + totalErrors: number; + byType: Record; + descriptors: ApicResourceDescriptor[]; + exitCode: number; +} + +export async function runApicExtraction( + client: IApicClient, + store: ApicArtifactStore, + config: ApicExtractConfig, +): Promise { + const result: ApicExtractResult = { + totalExtracted: 0, + totalSpecifications: 0, + totalErrors: 0, + byType: {}, + descriptors: [], + exitCode: 0, + }; + const includeSpecs = config.includeSpecifications ?? true; + + const record = (type: ApicResourceType): void => { + result.byType[type] = (result.byType[type] ?? 0) + 1; + result.totalExtracted++; + }; + + // 1. Service-scoped roots (metadataSchemas, workspaces). + for (const type of getApicRootTypes('service')) { + for await (const item of client.listResources(config.context, type, [])) { + const name = typeof item.name === 'string' ? item.name : ''; + if (!name) { + continue; + } + const descriptor: ApicResourceDescriptor = { type, nameParts: [name] }; + await store.writeResource(config.outputDir, descriptor, item); + result.descriptors.push(descriptor); + record(type); + + if (type === ApicResourceType.Workspace) { + if (config.workspace && name !== config.workspace) { + continue; + } + await extractWorkspace(client, store, config, name, includeSpecs, result, record); + } + } + } + + result.exitCode = result.totalErrors > 0 ? 1 : 0; + return result; +} + +/** Extract every workspace-scoped root type (and their children) for a workspace. */ +async function extractWorkspace( + client: IApicClient, + store: ApicArtifactStore, + config: ApicExtractConfig, + workspace: string, + includeSpecs: boolean, + result: ApicExtractResult, + record: (type: ApicResourceType) => void, +): Promise { + for (const type of getApicRootTypes('workspace')) { + await extractType(client, store, config, type, [], workspace, includeSpecs, result, record); + } +} + +/** Extract all resources of a type (under a parent) and recurse into children. */ +async function extractType( + client: IApicClient, + store: ApicArtifactStore, + config: ApicExtractConfig, + type: ApicResourceType, + parentNameParts: string[], + workspace: string, + includeSpecs: boolean, + result: ApicExtractResult, + record: (type: ApicResourceType) => void, +): Promise { + let items: AsyncIterable>; + try { + items = client.listResources(config.context, type, parentNameParts, workspace); + for await (const item of items) { + const name = typeof item.name === 'string' ? item.name : ''; + if (!name) { + continue; + } + const nameParts = [...parentNameParts, name]; + const descriptor: ApicResourceDescriptor = { type, nameParts, workspace }; + + try { + await store.writeResource(config.outputDir, descriptor, item); + result.descriptors.push(descriptor); + record(type); + + if (includeSpecs && APIC_RESOURCE_TYPE_METADATA[type].hasSpecification) { + await extractSpecification(client, store, config, descriptor, result); + } + + for (const childType of getApicChildTypes(type)) { + await extractType( + client, store, config, childType, nameParts, workspace, includeSpecs, result, record, + ); + } + } catch (error) { + result.totalErrors++; + logger.error(`Failed to extract ${buildApicLabel(descriptor)}: ${(error as Error).message}`); + } + } + } catch (error) { + result.totalErrors++; + logger.error(`Failed to list ${type} in workspace "${workspace}": ${(error as Error).message}`); + } +} + +/** Export and persist an API definition's specification body. */ +async function extractSpecification( + client: IApicClient, + store: ApicArtifactStore, + config: ApicExtractConfig, + descriptor: ApicResourceDescriptor, + result: ApicExtractResult, +): Promise { + const spec = await client.exportSpecification(config.context, descriptor); + if (!spec) { + return; + } + await store.writeSpecification(config.outputDir, descriptor, spec.content, spec.name); + result.totalSpecifications++; +} diff --git a/src/services/apic-publish-service.ts b/src/services/apic-publish-service.ts new file mode 100644 index 00000000..63c6979d --- /dev/null +++ b/src/services/apic-publish-service.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * API Center publish (restore) orchestration. + * + * Reads artifacts from disk and PUTs them to a target API Center in dependency + * tier order, re-importing API definition specification bodies. + */ + +import { IApicClient } from '../clients/iapic-client.js'; +import { ApicArtifactStore } from './apic-artifact-store.js'; +import { + APIC_DEPENDENCY_TIERS, + APIC_RESOURCE_TYPE_METADATA, + ApicResourceType, +} from '../models/apic-resource-types.js'; +import { ApicResourceDescriptor, ApicServiceContext } from '../models/apic-types.js'; +import { buildApicLabel } from '../lib/apic-uri.js'; +import { logger } from '../lib/logger.js'; + +export interface ApicPublishConfig { + readonly context: ApicServiceContext; + readonly sourceDir: string; + /** Preview actions without calling ARM. */ + readonly dryRun?: boolean; + /** Re-import API definition specification bodies (default true). */ + readonly includeSpecifications?: boolean; +} + +export interface ApicPublishResult { + totalPublished: number; + totalSpecifications: number; + totalErrors: number; + byType: Record; + exitCode: number; +} + +/** Server-assigned fields stripped before PUT. */ +const SYSTEM_TOP_LEVEL_FIELDS = ['id', 'name', 'type', 'systemData', 'etag']; + +/** + * Remove server-managed fields so the payload is a clean desired-state PUT. + */ +export function stripSystemFields( + json: Record, +): Record { + const clone = structuredClone(json); + for (const field of SYSTEM_TOP_LEVEL_FIELDS) { + delete clone[field]; + } + const props = clone.properties; + if (props && typeof props === 'object') { + delete (props as Record).provisioningState; + } + return clone; +} + +export async function runApicPublish( + client: IApicClient, + store: ApicArtifactStore, + config: ApicPublishConfig, +): Promise { + const result: ApicPublishResult = { + totalPublished: 0, + totalSpecifications: 0, + totalErrors: 0, + byType: {}, + exitCode: 0, + }; + const includeSpecs = config.includeSpecifications ?? true; + + if (!config.dryRun) { + await client.validatePreFlight(config.context); + } + + const descriptors = await store.listDescriptors(config.sourceDir); + const byType = groupByType(descriptors); + + for (const tier of APIC_DEPENDENCY_TIERS) { + for (const type of tier) { + for (const descriptor of byType.get(type) ?? []) { + await publishOne(client, store, config, descriptor, includeSpecs, result); + } + } + } + + result.exitCode = result.totalErrors > 0 ? 1 : 0; + return result; +} + +async function publishOne( + client: IApicClient, + store: ApicArtifactStore, + config: ApicPublishConfig, + descriptor: ApicResourceDescriptor, + includeSpecs: boolean, + result: ApicPublishResult, +): Promise { + const label = buildApicLabel(descriptor); + const json = await store.readResource(config.sourceDir, descriptor); + if (!json) { + return; + } + const payload = stripSystemFields(json); + + if (config.dryRun) { + logger.info(`[dry-run] PUT ${label}`); + result.byType[descriptor.type] = (result.byType[descriptor.type] ?? 0) + 1; + result.totalPublished++; + return; + } + + try { + await client.putResource(config.context, descriptor, payload); + result.byType[descriptor.type] = (result.byType[descriptor.type] ?? 0) + 1; + result.totalPublished++; + + if (includeSpecs && APIC_RESOURCE_TYPE_METADATA[descriptor.type].hasSpecification) { + await publishSpecification(client, store, config, descriptor, json, result); + } + } catch (error) { + result.totalErrors++; + logger.error(`Failed to publish ${label}: ${(error as Error).message}`); + } +} + +async function publishSpecification( + client: IApicClient, + store: ApicArtifactStore, + config: ApicPublishConfig, + descriptor: ApicResourceDescriptor, + definitionJson: Record, + result: ApicPublishResult, +): Promise { + const content = await store.readSpecification(config.sourceDir, descriptor); + if (content === undefined) { + return; + } + const props = (definitionJson.properties ?? {}) as Record; + const spec = (props.specification ?? {}) as { name?: string; version?: string }; + await client.importSpecification(config.context, descriptor, { + content, + name: spec.name ?? 'openapi', + version: spec.version, + }); + result.totalSpecifications++; +} + +function groupByType( + descriptors: ApicResourceDescriptor[], +): Map { + const map = new Map(); + for (const descriptor of descriptors) { + const list = map.get(descriptor.type) ?? []; + list.push(descriptor); + map.set(descriptor.type, list); + } + return map; +} diff --git a/tests/unit/lib/apic-uri.test.ts b/tests/unit/lib/apic-uri.test.ts new file mode 100644 index 00000000..540cb2df --- /dev/null +++ b/tests/unit/lib/apic-uri.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { describe, it, expect } from 'vitest'; +import { + buildApicBaseUrl, + buildApicArmUri, + buildApicListUri, + buildApicLabel, +} from '../../../src/lib/apic-uri.js'; +import { ApicResourceType } from '../../../src/models/apic-resource-types.js'; +import { ApicServiceContext } from '../../../src/models/apic-types.js'; + +const API_VERSION = '2024-06-01-preview'; + +function context(): ApicServiceContext { + const baseUrl = buildApicBaseUrl('public', 'sub-1', 'rg-1', 'svc-1'); + return { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'svc-1', + apiVersion: API_VERSION, + baseUrl, + }; +} + +describe('buildApicBaseUrl', () => { + it('builds a Microsoft.ApiCenter service URL', () => { + const url = buildApicBaseUrl('public', 'sub-1', 'rg-1', 'svc-1'); + expect(url).toContain('/subscriptions/sub-1/resourceGroups/rg-1'); + expect(url).toContain('/providers/Microsoft.ApiCenter/services/svc-1'); + }); +}); + +describe('buildApicArmUri', () => { + it('builds a service-scoped resource URI', () => { + const uri = buildApicArmUri(context(), { + type: ApicResourceType.MetadataSchema, + nameParts: ['schema-a'], + }); + expect(uri).toContain('/metadataSchemas/schema-a?api-version=' + API_VERSION); + expect(uri).not.toContain('/workspaces/'); + }); + + it('prepends the workspace segment for workspace-scoped resources', () => { + const uri = buildApicArmUri(context(), { + type: ApicResourceType.ApiDefinition, + nameParts: ['api-a', 'v1', 'def-1'], + workspace: 'default', + }); + expect(uri).toContain( + '/workspaces/default/apis/api-a/versions/v1/definitions/def-1?api-version=' + API_VERSION, + ); + }); + + it('throws when a workspace-scoped descriptor lacks a workspace', () => { + expect(() => + buildApicArmUri(context(), { + type: ApicResourceType.Api, + nameParts: ['api-a'], + }), + ).toThrow(/workspace/i); + }); +}); + +describe('buildApicListUri', () => { + it('drops the trailing name placeholder to form a collection path', () => { + const uri = buildApicListUri( + context(), + ApicResourceType.ApiVersion, + ['api-a'], + 'default', + ); + expect(uri).toContain('/workspaces/default/apis/api-a/versions?api-version=' + API_VERSION); + expect(uri).not.toMatch(/versions\/[^?]/); + }); + + it('builds a service-scoped collection path', () => { + const uri = buildApicListUri(context(), ApicResourceType.Workspace, []); + expect(uri).toContain('/services/svc-1/workspaces?api-version=' + API_VERSION); + }); +}); + +describe('buildApicLabel', () => { + it('includes the workspace prefix for workspace-scoped resources', () => { + const label = buildApicLabel({ + type: ApicResourceType.ApiVersion, + nameParts: ['api-a', 'v1'], + workspace: 'default', + }); + expect(label).toBe('workspaces/default/apis/api-a/versions/v1'); + }); +}); diff --git a/tests/unit/models/apic-resource-types.test.ts b/tests/unit/models/apic-resource-types.test.ts new file mode 100644 index 00000000..81502439 --- /dev/null +++ b/tests/unit/models/apic-resource-types.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { describe, it, expect } from 'vitest'; +import { + ApicResourceType, + APIC_RESOURCE_TYPE_METADATA, + APIC_DEPENDENCY_TIERS, + getApicRootTypes, + getApicChildTypes, +} from '../../../src/models/apic-resource-types.js'; + +const ALL_TYPES = Object.values(ApicResourceType); + +describe('ApicResourceType metadata', () => { + it('has metadata for every enum value', () => { + for (const type of ALL_TYPES) { + expect(APIC_RESOURCE_TYPE_METADATA[type], type).toBeDefined(); + } + expect(Object.keys(APIC_RESOURCE_TYPE_METADATA)).toHaveLength(ALL_TYPES.length); + }); + + it('has unique info-file names (required for path reversal)', () => { + const names = ALL_TYPES.map((t) => APIC_RESOURCE_TYPE_METADATA[t].infoFile); + expect(new Set(names).size).toBe(names.length); + }); + + it('never encodes the workspaces/{ws} segment in a path suffix', () => { + for (const type of ALL_TYPES) { + const meta = APIC_RESOURCE_TYPE_METADATA[type]; + if (meta.scope === 'workspace') { + expect(meta.armPathSuffix.startsWith('workspaces/'), type).toBe(false); + } + } + }); + + it('only ApiDefinition carries a specification body', () => { + const withSpec = ALL_TYPES.filter((t) => APIC_RESOURCE_TYPE_METADATA[t].hasSpecification); + expect(withSpec).toEqual([ApicResourceType.ApiDefinition]); + }); + + it('every non-root parent reference resolves to a real type', () => { + for (const type of ALL_TYPES) { + const parent = APIC_RESOURCE_TYPE_METADATA[type].parent; + if (parent !== null) { + expect(APIC_RESOURCE_TYPE_METADATA[parent], `${type} parent`).toBeDefined(); + } + } + }); +}); + +describe('getApicRootTypes', () => { + it('returns the service-scoped roots', () => { + expect(getApicRootTypes('service').sort()).toEqual( + [ApicResourceType.MetadataSchema, ApicResourceType.Workspace].sort(), + ); + }); + + it('returns workspace-scoped roots without parents', () => { + const roots = getApicRootTypes('workspace'); + expect(roots).toContain(ApicResourceType.Api); + expect(roots).toContain(ApicResourceType.Environment); + expect(roots).not.toContain(ApicResourceType.ApiVersion); + expect(roots).not.toContain(ApicResourceType.ApiDefinition); + }); +}); + +describe('getApicChildTypes', () => { + it('returns both children of Api', () => { + expect(getApicChildTypes(ApicResourceType.Api).sort()).toEqual( + [ApicResourceType.ApiVersion, ApicResourceType.ApiDeployment].sort(), + ); + }); + + it('returns ApiDefinition as the child of ApiVersion', () => { + expect(getApicChildTypes(ApicResourceType.ApiVersion)).toEqual([ + ApicResourceType.ApiDefinition, + ]); + }); +}); + +describe('APIC_DEPENDENCY_TIERS', () => { + it('covers every resource type exactly once', () => { + const flat = APIC_DEPENDENCY_TIERS.flat(); + expect(new Set(flat).size).toBe(flat.length); + expect(flat.sort()).toEqual([...ALL_TYPES].sort()); + }); + + it('orders every child strictly after its parent', () => { + const tierIndex = new Map(); + APIC_DEPENDENCY_TIERS.forEach((tier, i) => tier.forEach((t) => tierIndex.set(t, i))); + for (const type of ALL_TYPES) { + const parent = APIC_RESOURCE_TYPE_METADATA[type].parent; + if (parent !== null) { + expect(tierIndex.get(type)!, `${type} after ${parent}`).toBeGreaterThan( + tierIndex.get(parent)!, + ); + } + } + }); +}); diff --git a/tests/unit/services/apic-artifact-store.test.ts b/tests/unit/services/apic-artifact-store.test.ts new file mode 100644 index 00000000..f9f6016e --- /dev/null +++ b/tests/unit/services/apic-artifact-store.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + ApicArtifactStore, + parseTemplateSegments, +} from '../../../src/services/apic-artifact-store.js'; +import { ApicResourceType } from '../../../src/models/apic-resource-types.js'; +import { ApicResourceDescriptor } from '../../../src/models/apic-types.js'; + +describe('parseTemplateSegments', () => { + it('reverses a multi-placeholder template', () => { + expect( + parseTemplateSegments('apis/{0}/versions/{1}/definitions/{2}', 'apis/a/versions/v/definitions/d'), + ).toEqual(['a', 'v', 'd']); + }); + + it('respects placeholder ordering', () => { + expect(parseTemplateSegments('apis/{0}/deployments/{1}', 'apis/a/deployments/dep')).toEqual([ + 'a', + 'dep', + ]); + }); + + it('returns undefined on a literal mismatch', () => { + expect(parseTemplateSegments('apis/{0}/versions/{1}', 'apis/a/deployments/d')).toBeUndefined(); + }); + + it('returns undefined on a segment-count mismatch', () => { + expect(parseTemplateSegments('apis/{0}', 'apis/a/versions/v')).toBeUndefined(); + }); +}); + +describe('ApicArtifactStore', () => { + let baseDir: string; + const store = new ApicArtifactStore(); + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apic-store-')); + }); + + afterEach(async () => { + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it('round-trips a resource payload', async () => { + const descriptor: ApicResourceDescriptor = { + type: ApicResourceType.Api, + nameParts: ['api-a'], + workspace: 'default', + }; + await store.writeResource(baseDir, descriptor, { name: 'api-a', properties: { title: 'A' } }); + const read = await store.readResource(baseDir, descriptor); + expect(read).toEqual({ name: 'api-a', properties: { title: 'A' } }); + }); + + it('returns undefined reading a missing resource', async () => { + const read = await store.readResource(baseDir, { + type: ApicResourceType.Api, + nameParts: ['nope'], + workspace: 'default', + }); + expect(read).toBeUndefined(); + }); + + it('round-trips a specification body', async () => { + const descriptor: ApicResourceDescriptor = { + type: ApicResourceType.ApiDefinition, + nameParts: ['api-a', 'v1', 'def'], + workspace: 'default', + }; + await store.writeSpecification(baseDir, descriptor, '{"openapi":"3.0.1"}', 'openapi'); + const content = await store.readSpecification(baseDir, descriptor); + expect(content).toBe('{"openapi":"3.0.1"}'); + }); + + it('reconstructs descriptors from the artifact tree', async () => { + const workspace: ApicResourceDescriptor = { + type: ApicResourceType.Workspace, + nameParts: ['default'], + }; + const definition: ApicResourceDescriptor = { + type: ApicResourceType.ApiDefinition, + nameParts: ['api-a', 'v1', 'def'], + workspace: 'default', + }; + await store.writeResource(baseDir, workspace, { name: 'default' }); + await store.writeResource(baseDir, definition, { name: 'def' }); + + const descriptors = await store.listDescriptors(baseDir); + expect(descriptors).toContainEqual(workspace); + expect(descriptors).toContainEqual(definition); + }); +}); diff --git a/tests/unit/services/apic-extract-publish.test.ts b/tests/unit/services/apic-extract-publish.test.ts new file mode 100644 index 00000000..c78fa00e --- /dev/null +++ b/tests/unit/services/apic-extract-publish.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { IApicClient, ApicSpecification } from '../../../src/clients/iapic-client.js'; +import { ApicResourceType } from '../../../src/models/apic-resource-types.js'; +import { ApicResourceDescriptor, ApicServiceContext } from '../../../src/models/apic-types.js'; +import { ApicArtifactStore } from '../../../src/services/apic-artifact-store.js'; +import { runApicExtraction } from '../../../src/services/apic-extract-service.js'; +import { runApicPublish } from '../../../src/services/apic-publish-service.js'; + +interface SeedEntry { + type: ApicResourceType; + nameParts: string[]; + workspace?: string; + json: Record; + spec?: ApicSpecification; +} + +function sameParent(entryParent: string[], parent: string[]): boolean { + return entryParent.length === parent.length && entryParent.every((v, i) => v === parent[i]); +} + +/** In-memory IApicClient for round-trip tests. */ +class FakeApicClient implements IApicClient { + readonly published: ApicResourceDescriptor[] = []; + readonly imported: { descriptor: ApicResourceDescriptor; spec: ApicSpecification }[] = []; + + constructor(private readonly seed: SeedEntry[] = []) {} + + async *listResources( + _context: ApicServiceContext, + type: ApicResourceType, + parentNameParts: string[], + workspace?: string, + ): AsyncIterable> { + for (const entry of this.seed) { + if ( + entry.type === type && + entry.workspace === workspace && + sameParent(entry.nameParts.slice(0, -1), parentNameParts) + ) { + yield entry.json; + } + } + } + + async getResource(): Promise | undefined> { + return undefined; + } + + async putResource( + _context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise> { + this.published.push(descriptor); + return {}; + } + + async deleteResource(): Promise { + return true; + } + + async exportSpecification( + _context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise { + const entry = this.seed.find( + (e) => + e.type === descriptor.type && + e.workspace === descriptor.workspace && + sameParent(e.nameParts, descriptor.nameParts), + ); + return entry?.spec; + } + + async importSpecification( + _context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + spec: ApicSpecification, + ): Promise { + this.imported.push({ descriptor, spec }); + } + + async validatePreFlight(): Promise { + // no-op + } +} + +const context: ApicServiceContext = { + subscriptionId: 'sub', + resourceGroup: 'rg', + serviceName: 'svc', + apiVersion: '2024-06-01-preview', + baseUrl: 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ApiCenter/services/svc', +}; + +function buildSeed(): SeedEntry[] { + return [ + { type: ApicResourceType.Workspace, nameParts: ['default'], json: { name: 'default' } }, + { + type: ApicResourceType.Api, + nameParts: ['api-a'], + workspace: 'default', + json: { name: 'api-a', properties: { title: 'API A' } }, + }, + { + type: ApicResourceType.ApiVersion, + nameParts: ['api-a', 'v1'], + workspace: 'default', + json: { name: 'v1', properties: { lifecycleStage: 'production' } }, + }, + { + type: ApicResourceType.ApiDefinition, + nameParts: ['api-a', 'v1', 'def'], + workspace: 'default', + json: { name: 'def', properties: { specification: { name: 'openapi', version: '3.0.1' } } }, + spec: { content: '{"openapi":"3.0.1"}', name: 'openapi', version: '3.0.1' }, + }, + ]; +} + +describe('APIC extract → publish round trip', () => { + let baseDir: string; + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apic-rt-')); + }); + + afterEach(async () => { + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it('extracts the resource tree and specifications to disk', async () => { + const client = new FakeApicClient(buildSeed()); + const store = new ApicArtifactStore(); + + const result = await runApicExtraction(client, store, { context, outputDir: baseDir }); + + expect(result.totalErrors).toBe(0); + expect(result.exitCode).toBe(0); + expect(result.totalExtracted).toBe(4); + expect(result.totalSpecifications).toBe(1); + expect(result.byType[ApicResourceType.ApiDefinition]).toBe(1); + }); + + it('publishes extracted artifacts in dependency-tier order', async () => { + const store = new ApicArtifactStore(); + await runApicExtraction(new FakeApicClient(buildSeed()), store, { + context, + outputDir: baseDir, + }); + + const target = new FakeApicClient(); + const result = await runApicPublish(target, store, { context, sourceDir: baseDir }); + + expect(result.totalErrors).toBe(0); + expect(result.totalPublished).toBe(4); + expect(result.totalSpecifications).toBe(1); + + const order = target.published.map((d) => d.type); + expect(order.indexOf(ApicResourceType.Workspace)).toBeLessThan( + order.indexOf(ApicResourceType.Api), + ); + expect(order.indexOf(ApicResourceType.Api)).toBeLessThan( + order.indexOf(ApicResourceType.ApiVersion), + ); + expect(order.indexOf(ApicResourceType.ApiVersion)).toBeLessThan( + order.indexOf(ApicResourceType.ApiDefinition), + ); + expect(target.imported).toHaveLength(1); + expect(target.imported[0].spec.content).toBe('{"openapi":"3.0.1"}'); + }); + + it('dry-run publishes nothing to the client', async () => { + const store = new ApicArtifactStore(); + await runApicExtraction(new FakeApicClient(buildSeed()), store, { + context, + outputDir: baseDir, + }); + + const target = new FakeApicClient(); + const result = await runApicPublish(target, store, { + context, + sourceDir: baseDir, + dryRun: true, + }); + + expect(result.totalPublished).toBe(4); + expect(target.published).toHaveLength(0); + expect(target.imported).toHaveLength(0); + }); +});