// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.IO.Hashing;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Foundry;
using Aspire.Hosting.Pipelines;
using Azure.Provisioning;
using Azure.Provisioning.ApiManagement;
using Azure.Provisioning.ApplicationInsights;
using Azure.Provisioning.Authorization;
using Azure.Provisioning.CognitiveServices;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.KeyVault;
using Azure.Provisioning.Network;
using Azure.Provisioning.Primitives;
using Azure.Provisioning.Resources;
using Azure.Provisioning.Roles;
using Azure.Provisioning.Storage;
using Microsoft.Extensions.DependencyInjection;
using SubscriptionState = Azure.Provisioning.ApiManagement.SubscriptionState;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Azure API Management resources to the application model.
/// </summary>
public static class AzureApiManagementExtensions
{
private const string ProxyOperationName = "proxy";
private const string PremiumV2SubnetDelegation = "Microsoft.Web/hostingEnvironments";
private static readonly char[] s_invalidApiIdentifierCharacters = ['*', '#', '&', '+', ':', '<', '>', '?'];
private static readonly string[] s_proxyOperationMethods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"];
/// <summary>
/// Adds an Azure API Management service.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The name of the Aspire resource.</param>
/// <param name="options">The API Management service options.</param>
/// <returns>A builder for the Azure API Management resource.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="options"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when required option values are empty or capacity is invalid for the selected SKU.</exception>
/// <example>
/// <code>
/// var apim = builder.AddAzureApiManagement("apim", new()
/// {
/// PublisherEmail = "api-owners@example.com",
/// PublisherName = "Contoso APIs",
/// Sku = AzureApiManagementSku.StandardV2,
/// });
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<AzureApiManagementResource> AddAzureApiManagement(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
AzureApiManagementOptions options)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrEmpty(options.PublisherEmail);
ArgumentException.ThrowIfNullOrEmpty(options.PublisherName);
ValidateMaximumLength(options.PublisherEmail, 100, "The publisher email address", nameof(options));
ValidateMaximumLength(options.PublisherName, 100, "The publisher name", nameof(options));
ValidateCapacity(options.Sku, options.Capacity);
var resource = new AzureApiManagementResource(name, options, ConfigureInfrastructure);
resource.Annotations.Add(new PipelineConfigurationAnnotation(context =>
{
var provisionSteps = context.GetSteps(resource, WellKnownPipelineTags.ProvisionInfrastructure);
var openApiDeploymentTargets = resource.Apis
.Where(api => api.OpenApiSource is AzureApiManagementOpenApiEndpoint)
.Select(api => api.Target)
.OfType<IResource>()
.Distinct();
foreach (var target in openApiDeploymentTargets)
{
if (!ComputeEnvironmentEndpointResolver.TryGetEffectiveComputeEnvironment(target, out var computeEnvironment) ||
target.GetDeploymentTargetAnnotation(computeEnvironment)?.DeploymentTarget is not { } deploymentTarget)
{
// Deployment targets are materialized by the compute environment's BeforeStart step.
// The callback runs once before that step and again when the deploy pipeline is resolved.
continue;
}
if (deploymentTarget is not AzureProvisioningResource azureDeploymentTarget)
{
throw new InvalidOperationException(
$"Resource '{target.Name}' uses a compute publisher that cannot be ordered before API Management OpenAPI import. " +
"Use WithOpenApiDocument instead.");
}
// Azure provisioning resources use this stable step name. Referencing it directly also works
// when the compute environment expands deployment-target steps after this callback executes.
provisionSteps.DependsOn($"provision-{azureDeploymentTarget.Name}");
}
}));
if (builder.ExecutionContext.IsRunMode)
{
return builder.CreateResourceBuilder(resource);
}
builder.AddAzureProvisioning();
builder.Services.Configure<AzureProvisioningOptions>(provisioning => provisioning.SupportsTargetedRoleAssignments = true);
builder.AddResource(resource.Identity).WithParentRelationship(resource);
resource.References.Add(resource.Identity);
return builder.AddResource(resource)
.WithIconName("GlobeShield");
}
/// <summary>
/// Configures the user-assigned managed identity used by API Management.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="identityResourceBuilder">The user-assigned managed identity.</param>
/// <returns>The Azure API Management resource builder.</returns>
/// <remarks>
/// New services attach the supplied identity automatically instead of creating a default identity.
/// For an existing service, the supplied identity must be configured as an existing resource and
/// already attached to the service outside Aspire. Aspire does not change or verify the existing
/// service's identity attachments. Backend and Key Vault permissions use the supplied identity.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="identityResourceBuilder"/> is null.</exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementResource> WithAzureUserAssignedIdentity(
this IResourceBuilder<AzureApiManagementResource> builder,
IResourceBuilder<AzureUserAssignedIdentityResource> identityResourceBuilder)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(identityResourceBuilder);
var previousIdentity = builder.Resource.Identity;
if (ReferenceEquals(previousIdentity, identityResourceBuilder.Resource))
{
return builder;
}
// Only the automatically created identity belongs to APIM; never remove a caller-owned identity.
if (builder.Resource.HasDefaultIdentity)
{
builder.ApplicationBuilder.Resources.Remove(previousIdentity);
}
builder.Resource.SetIdentity(identityResourceBuilder.Resource);
builder.WithAnnotation(new AppIdentityAnnotation(identityResourceBuilder.Resource), ResourceAnnotationMutationBehavior.Replace);
return builder;
}
/// <summary>
/// Adds an API that routes requests to an Aspire compute resource.
/// </summary>
/// <typeparam name="T">The backend compute resource type.</typeparam>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical API name.</param>
/// <param name="target">
/// The backend compute resource. It must expose an HTTP or HTTPS endpoint. Internal endpoints require the
/// API Management service to have virtual network connectivity to the target.
/// </param>
/// <param name="path">The public path beneath the API Management gateway hostname.</param>
/// <param name="displayName">The API display name. The resource name is used when omitted.</param>
/// <param name="subscriptionRequired">Whether callers must provide an API Management subscription key. The default is <see langword="true"/>.</param>
/// <param name="apiName">The physical API identifier in API Management. The Aspire resource name is used when omitted.</param>
/// <returns>A builder for the API resource.</returns>
/// <remarks>
/// The generated API contains catch-all operations for supported HTTP methods and paths. The generated policy routes
/// requests through an API Management backend entity whose URL is resolved from the target deployment environment.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="target"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when a required string is empty.</exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementApiResource> AddApi<T>(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
IResourceBuilder<T> target,
string path,
string? displayName = null,
bool subscriptionRequired = true,
string? apiName = null)
where T : IComputeResource, IResourceWithEndpoints
{
ArgumentNullException.ThrowIfNull(target);
return AddApiCore(builder, name, path, displayName, subscriptionRequired, apiName, target.Resource);
}
/// <summary>
/// Adds an API whose backend is configured separately.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical API name.</param>
/// <param name="path">The public path beneath the API Management gateway hostname.</param>
/// <param name="displayName">The API display name. The resource name is used when omitted.</param>
/// <param name="subscriptionRequired">Whether callers must provide an API Management subscription key. The default is <see langword="true"/>.</param>
/// <param name="apiName">The physical API identifier in API Management. The Aspire resource name is used when omitted.</param>
/// <returns>A builder for the API resource.</returns>
/// <remarks>
/// Configure the API backend with <see cref="WithBackend(IResourceBuilder{AzureApiManagementApiResource}, IResourceBuilder{AzureApiManagementBackendResource})"/>
/// or <see cref="WithBackend(IResourceBuilder{AzureApiManagementApiResource}, IResourceBuilder{AzureApiManagementBackendPoolResource})"/>.
/// </remarks>
[AspireExport("addApiWithoutTarget")]
public static IResourceBuilder<AzureApiManagementApiResource> AddApi(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
string path,
string? displayName = null,
bool subscriptionRequired = true,
string? apiName = null)
{
return AddApiCore(builder, name, path, displayName, subscriptionRequired, apiName);
}
/// <summary>
/// Imports an OpenAPI document from a file into an API.
/// </summary>
/// <param name="builder">The API Management API resource builder.</param>
/// <param name="documentPath">The document path, relative to the AppHost directory when not absolute.</param>
/// <param name="format">The document format. The file extension is used when omitted.</param>
/// <returns>The API Management API resource builder.</returns>
/// <remarks>
/// The document is read when generating infrastructure for publish or deploy, never during run mode.
/// It must exist and contain non-whitespace content by the time infrastructure is generated.
/// An API can have only one OpenAPI source: either a document file or an endpoint.
/// Repeating the same source configuration is allowed; configuring a different source throws.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="documentPath"/> is empty or its extension cannot determine the format.</exception>
/// <exception cref="InvalidOperationException">Thrown when a different OpenAPI source is already configured.</exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementApiResource> WithOpenApiDocument(
this IResourceBuilder<AzureApiManagementApiResource> builder,
string documentPath,
AzureApiManagementOpenApiFormat? format = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(documentPath);
var fullPath = Path.GetFullPath(documentPath, builder.ApplicationBuilder.AppHostDirectory);
builder.Resource.SetOpenApiSource(new AzureApiManagementOpenApiFile(
fullPath,
format ?? InferOpenApiFormat(documentPath)));
return builder;
}
/// <summary>
/// Imports an OpenAPI document exposed by the API's target compute resource.
/// </summary>
/// <param name="builder">The API Management API resource builder.</param>
/// <param name="documentPath">The absolute path of the OpenAPI endpoint, such as <c>/openapi/v1.json</c>.</param>
/// <param name="endpointName">The target endpoint name. An external HTTP or HTTPS endpoint is selected when omitted.</param>
/// <param name="format">The document format. The endpoint path extension is used when omitted.</param>
/// <returns>The API Management API resource builder.</returns>
/// <remarks>
/// Azure API Management retrieves linked documents through its control plane, so the selected endpoint must be
/// externally reachable during deployment. Use <see cref="WithOpenApiDocument"/> for private backends.
/// An API can have only one OpenAPI source: either a document file or an endpoint.
/// Repeating the same source configuration is allowed; configuring a different source throws.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="documentPath"/> is empty or is not an absolute path.</exception>
/// <exception cref="InvalidOperationException">Thrown when a different OpenAPI source is already configured, or the API has no target compute resource or no suitable external endpoint.</exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementApiResource> WithOpenApiEndpoint(
this IResourceBuilder<AzureApiManagementApiResource> builder,
string documentPath = "/openapi/v1.json",
string? endpointName = null,
AzureApiManagementOpenApiFormat? format = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(documentPath);
if (!documentPath.StartsWith("/", StringComparison.Ordinal))
{
throw new ArgumentException("The OpenAPI endpoint path must start with '/'.", nameof(documentPath));
}
if (builder.Resource.Target is null)
{
throw new InvalidOperationException(
$"API Management API '{builder.Resource.Name}' does not have a target compute resource.");
}
builder.Resource.SetOpenApiSource(new AzureApiManagementOpenApiEndpoint(
documentPath,
endpointName,
format ?? InferOpenApiFormat(documentPath)));
return builder;
}
/// <summary>
/// Adds an OpenAI-compatible API backed by an API Management load-balancing pool.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical API name.</param>
/// <param name="path">The public path beneath the API Management gateway hostname.</param>
/// <param name="displayName">The API display name. The resource name is used when omitted.</param>
/// <param name="subscriptionRequired">Whether callers must provide an API Management subscription key. The default is <see langword="true"/>.</param>
/// <param name="apiName">The physical API identifier in API Management. The Aspire resource name is used when omitted.</param>
/// <returns>A builder for the API resource.</returns>
/// <remarks>
/// Create backends with <see cref="AddAzureOpenAIBackend"/> or <see cref="AddFoundryBackend"/>,
/// compose them with <see cref="AddBackendPool"/>, and attach the pool with <see cref="WithBackend(IResourceBuilder{AzureApiManagementApiResource}, IResourceBuilder{AzureApiManagementBackendPoolResource})"/>.
/// Requests beneath <paramref name="path"/> are appended to the selected backend URI.
/// </remarks>
[AspireExport]
public static IResourceBuilder<AzureApiManagementApiResource> AddOpenAIApi(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
string path,
string? displayName = null,
bool subscriptionRequired = true,
string? apiName = null)
{
var apiBuilder = AddApiCore(builder, name, path, displayName, subscriptionRequired, apiName);
apiBuilder.AddOperation(
CreateBoundedIdentifier($"{name}-chat-completions", 64),
method: "POST",
urlTemplate: "/chat/completions",
displayName: "Create chat completion",
operationName: "chat-completions");
return apiBuilder;
}
/// <summary>
/// Adds a product to the API Management service.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical product name.</param>
/// <param name="displayName">The product display name.</param>
/// <param name="options">The product options.</param>
/// <param name="productName">The physical product identifier in API Management. The resource name is used when omitted.</param>
/// <returns>A builder for the product resource.</returns>
[AspireExport]
public static IResourceBuilder<AzureApiManagementProductResource> AddProduct(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
string displayName,
AzureApiManagementProductOptions? options = null,
string? productName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(displayName);
var resolvedProductName = productName ?? name;
ValidateGeneralIdentifier(resolvedProductName, 256, nameof(productName));
if (displayName.Length > 300)
{
throw new ArgumentException("The product display name cannot exceed 300 characters.", nameof(displayName));
}
if (builder.Resource.Products.Any(product =>
string.Equals(product.ProductName, resolvedProductName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"An API Management product with physical name '{resolvedProductName}' has already been added.");
}
options ??= new AzureApiManagementProductOptions();
if (options.Description?.Length > 1000)
{
throw new ArgumentException("The product description cannot exceed 1000 characters.", nameof(options));
}
if (options.ApprovalRequired && !options.SubscriptionRequired)
{
throw new ArgumentException("Product approval can only be required when subscriptions are required.", nameof(options));
}
if (options.SubscriptionsLimit is not null && !options.SubscriptionRequired)
{
throw new ArgumentException("A product subscription limit can only be configured when subscriptions are required.", nameof(options));
}
if (options.SubscriptionsLimit is <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(options),
options.SubscriptionsLimit,
"The product subscription limit must be greater than zero.");
}
var resource = new AzureApiManagementProductResource(
name,
resolvedProductName,
displayName,
options,
builder.Resource);
builder.Resource.Products.Add(resource);
var resourceBuilder = builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource);
return resourceBuilder
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName("BoxMultiple");
}
/// <summary>
/// Adds an API to an API Management product.
/// </summary>
/// <param name="builder">The API Management product resource builder.</param>
/// <param name="api">The API to add to the product.</param>
/// <returns>The product resource builder.</returns>
[AspireExport]
public static IResourceBuilder<AzureApiManagementProductResource> WithApi(
this IResourceBuilder<AzureApiManagementProductResource> builder,
IResourceBuilder<AzureApiManagementApiResource> api)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(api);
if (!ReferenceEquals(builder.Resource.Parent, api.Resource.Parent))
{
throw new InvalidOperationException("An API Management product can only contain APIs from the same API Management service.");
}
if (!builder.Resource.Apis.Contains(api.Resource))
{
builder.Resource.Apis.Add(api.Resource);
}
return builder.WithRelationship(api.Resource, "Includes");
}
/// <summary>
/// Adds a subscription scoped to an API Management product.
/// </summary>
/// <param name="builder">The API Management product resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical subscription name.</param>
/// <param name="displayName">The subscription display name.</param>
/// <param name="options">The subscription options.</param>
/// <param name="subscriptionName">The physical subscription identifier in API Management. The resource name is used when omitted.</param>
/// <returns>A builder for the subscription resource.</returns>
/// <remarks>API Management generates the primary and secondary subscription keys during deployment.</remarks>
[AspireExport]
public static IResourceBuilder<AzureApiManagementSubscriptionResource> AddSubscription(
this IResourceBuilder<AzureApiManagementProductResource> builder,
[ResourceName] string name,
string displayName,
AzureApiManagementSubscriptionOptions? options = null,
string? subscriptionName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(displayName);
var resolvedSubscriptionName = subscriptionName ?? name;
ValidateGeneralIdentifier(resolvedSubscriptionName, 256, nameof(subscriptionName));
if (displayName.Length > 100)
{
throw new ArgumentException("The subscription display name cannot exceed 100 characters.", nameof(displayName));
}
if (builder.Resource.Parent.Products
.SelectMany(product => product.Subscriptions)
.Any(subscription => string.Equals(
subscription.SubscriptionName,
resolvedSubscriptionName,
StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"An API Management subscription with physical name '{resolvedSubscriptionName}' has already been added.");
}
var resource = new AzureApiManagementSubscriptionResource(
name,
resolvedSubscriptionName,
displayName,
options ?? new AzureApiManagementSubscriptionOptions(),
builder.Resource);
builder.Resource.Subscriptions.Add(resource);
var resourceBuilder = builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource);
return resourceBuilder
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName("Key");
}
/// <summary>
/// Adds a non-secret named value to the API Management service.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical named-value name.</param>
/// <param name="value">The named value.</param>
/// <param name="displayName">The name used to reference the value in policies. The resource name is used when omitted.</param>
/// <param name="namedValueName">The physical named-value identifier in API Management. The resource name is used when omitted.</param>
/// <param name="tags">Tags used to organize the named value.</param>
/// <returns>A builder for the named-value resource.</returns>
[AspireExport("addApiManagementNamedValue", MethodName = "addNamedValue")]
public static IResourceBuilder<AzureApiManagementNamedValueResource> AddNamedValue(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
string value,
string? displayName = null,
string? namedValueName = null,
string[]? tags = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
ValidateMaximumLength(value, 4096, "The named value", nameof(value));
return AddNamedValueCore(builder, name, value, secret: false, displayName, namedValueName, tags);
}
/// <summary>
/// Adds a secret parameter as a named value in the API Management service.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical named-value name.</param>
/// <param name="value">The secret parameter containing the named value.</param>
/// <param name="displayName">The name used to reference the value in policies. The resource name is used when omitted.</param>
/// <param name="namedValueName">The physical named-value identifier in API Management. The resource name is used when omitted.</param>
/// <param name="tags">Tags used to organize the named value.</param>
/// <returns>A builder for the named-value resource.</returns>
[AspireExport("addApiManagementSecretNamedValue", MethodName = "addSecretNamedValue")]
public static IResourceBuilder<AzureApiManagementNamedValueResource> AddSecretNamedValue(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
IResourceBuilder<ParameterResource> value,
string? displayName = null,
string? namedValueName = null,
string[]? tags = null)
{
ArgumentNullException.ThrowIfNull(value);
if (!value.Resource.Secret)
{
throw new ArgumentException(
"The named-value parameter must be marked as secret. Use AddParameter with secret: true when creating the parameter.",
nameof(value));
}
return AddNamedValueCore(builder, name, value.Resource, secret: true, displayName, namedValueName, tags);
}
/// <summary>
/// Adds a Key Vault-backed named value to the API Management service.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical named-value name.</param>
/// <param name="value">The Key Vault secret reference.</param>
/// <param name="displayName">The name used to reference the value in policies. The resource name is used when omitted.</param>
/// <param name="namedValueName">The physical named-value identifier in API Management. The resource name is used when omitted.</param>
/// <param name="tags">Tags used to organize the named value.</param>
/// <returns>A builder for the named-value resource.</returns>
[AspireExportIgnore(Reason = "Polyglot AppHosts pass the Key Vault resource and secret name separately.")]
public static IResourceBuilder<AzureApiManagementNamedValueResource> AddKeyVaultNamedValue(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
IAzureKeyVaultSecretReference value,
string? displayName = null,
string? namedValueName = null,
string[]? tags = null)
{
ArgumentNullException.ThrowIfNull(value);
var namedValue = AddNamedValueCore(builder, name, value, secret: true, displayName, namedValueName, tags)
.WithRelationship(value.Resource, "Secret");
ConfigureKeyVaultAccess(builder.Resource, value, KeyVaultBuiltInRole.KeyVaultSecretsUser);
return namedValue;
}
/// <summary>
/// Adds a Key Vault-backed named value to the API Management service from a polyglot AppHost.
/// </summary>
[AspireExport("addApiManagementKeyVaultNamedValue", MethodName = "addKeyVaultNamedValue")]
internal static IResourceBuilder<AzureApiManagementNamedValueResource> AddKeyVaultNamedValueForPolyglot(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
IResourceBuilder<AzureKeyVaultResource> vault,
string secretName,
string? displayName = null,
string? namedValueName = null,
string[]? tags = null)
{
ArgumentNullException.ThrowIfNull(vault);
return builder.AddKeyVaultNamedValue(
name,
vault.GetSecret(secretName),
displayName,
namedValueName,
tags);
}
/// <summary>
/// Adds a reusable policy fragment to the API Management service.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical policy-fragment name.</param>
/// <param name="policyXml">One or more well-formed APIM policy elements without a <c>fragment</c> envelope.</param>
/// <param name="description">An optional description.</param>
/// <param name="fragmentName">The physical policy-fragment identifier in API Management. The resource name is used when omitted.</param>
/// <returns>A builder for the policy-fragment resource.</returns>
[AspireExport]
public static IResourceBuilder<AzureApiManagementPolicyFragmentResource> AddPolicyFragment(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
string policyXml,
string? description = null,
string? fragmentName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ValidatePolicyFragment(policyXml);
var resolvedFragmentName = fragmentName ?? name;
ValidatePolicyFragmentIdentifier(resolvedFragmentName, nameof(fragmentName));
if (description?.Length > 1000)
{
throw new ArgumentException("The policy-fragment description cannot exceed 1000 characters.", nameof(description));
}
if (builder.Resource.PolicyFragments.Any(fragment =>
string.Equals(fragment.FragmentName, resolvedFragmentName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"An API Management policy fragment with physical name '{resolvedFragmentName}' has already been added.");
}
var resource = new AzureApiManagementPolicyFragmentResource(
name,
resolvedFragmentName,
policyXml,
description,
builder.Resource);
builder.Resource.PolicyFragments.Add(resource);
var resourceBuilder = builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource);
return resourceBuilder
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName("Code");
}
/// <summary>
/// Includes a policy fragment in the service-level inbound policy.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="fragment">The policy fragment to include.</param>
/// <returns>The resource builder.</returns>
[AspireExport("withApiManagementServiceInboundPolicyFragment", MethodName = "withInboundPolicyFragment")]
public static IResourceBuilder<AzureApiManagementResource> WithInboundPolicyFragment(
this IResourceBuilder<AzureApiManagementResource> builder,
IResourceBuilder<AzureApiManagementPolicyFragmentResource> fragment)
{
ValidatePolicyFragmentParent(builder.Resource, fragment);
builder.WithInboundPolicy(CreateIncludeFragmentPolicy(fragment.Resource.FragmentName));
return builder.WithRelationship(fragment.Resource, "Policy fragment");
}
/// <summary>
/// Includes a policy fragment in the API-level inbound policy.
/// </summary>
/// <param name="builder">The API Management API resource builder.</param>
/// <param name="fragment">The policy fragment to include.</param>
/// <returns>The resource builder.</returns>
[AspireExport("withApiManagementApiInboundPolicyFragment", MethodName = "withInboundPolicyFragment")]
public static IResourceBuilder<AzureApiManagementApiResource> WithInboundPolicyFragment(
this IResourceBuilder<AzureApiManagementApiResource> builder,
IResourceBuilder<AzureApiManagementPolicyFragmentResource> fragment)
{
ValidatePolicyFragmentParent(builder.Resource.Parent, fragment);
builder.WithInboundPolicy(CreateIncludeFragmentPolicy(fragment.Resource.FragmentName));
return builder.WithRelationship(fragment.Resource, "Policy fragment");
}
/// <summary>
/// Includes a policy fragment in the operation-level inbound policy.
/// </summary>
/// <param name="builder">The API Management operation resource builder.</param>
/// <param name="fragment">The policy fragment to include.</param>
/// <returns>The resource builder.</returns>
[AspireExport("withApiManagementOperationInboundPolicyFragment", MethodName = "withInboundPolicyFragment")]
public static IResourceBuilder<AzureApiManagementOperationResource> WithInboundPolicyFragment(
this IResourceBuilder<AzureApiManagementOperationResource> builder,
IResourceBuilder<AzureApiManagementPolicyFragmentResource> fragment)
{
ValidatePolicyFragmentParent(builder.Resource.Parent.Parent, fragment);
builder.WithInboundPolicy(CreateIncludeFragmentPolicy(fragment.Resource.FragmentName));
return builder.WithRelationship(fragment.Resource, "Policy fragment");
}
/// <summary>
/// Sends service-level API Management diagnostics to Application Insights.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="applicationInsights">The Application Insights resource.</param>
/// <param name="options">The diagnostic options.</param>
/// <returns>The resource builder.</returns>
[AspireExport("withApiManagementServiceApplicationInsights", MethodName = "withApplicationInsights")]
public static IResourceBuilder<AzureApiManagementResource> WithApplicationInsights(
this IResourceBuilder<AzureApiManagementResource> builder,
IResourceBuilder<AzureApplicationInsightsResource> applicationInsights,
AzureApiManagementDiagnosticOptions? options = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(applicationInsights);
if (builder.Resource.Diagnostic is not null)
{
throw new InvalidOperationException("Application Insights diagnostics have already been configured for this API Management service.");
}
options ??= new AzureApiManagementDiagnosticOptions();
ValidateDiagnosticOptions(options);
builder.Resource.Diagnostic = new(applicationInsights.Resource, options);
return builder.WithRelationship(applicationInsights.Resource, "Diagnostics");
}
/// <summary>
/// Sends API-level API Management diagnostics to Application Insights.
/// </summary>
/// <param name="builder">The API Management API resource builder.</param>
/// <param name="applicationInsights">The Application Insights resource.</param>
/// <param name="options">The diagnostic options.</param>
/// <returns>The resource builder.</returns>
[AspireExport("withApiManagementApiApplicationInsights", MethodName = "withApplicationInsights")]
public static IResourceBuilder<AzureApiManagementApiResource> WithApplicationInsights(
this IResourceBuilder<AzureApiManagementApiResource> builder,
IResourceBuilder<AzureApplicationInsightsResource> applicationInsights,
AzureApiManagementDiagnosticOptions? options = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(applicationInsights);
if (builder.Resource.Diagnostic is not null)
{
throw new InvalidOperationException("Application Insights diagnostics have already been configured for this API Management API.");
}
options ??= new AzureApiManagementDiagnosticOptions();
ValidateDiagnosticOptions(options);
builder.Resource.Diagnostic = new(applicationInsights.Resource, options);
return builder.WithRelationship(applicationInsights.Resource, "Diagnostics");
}
/// <summary>
/// Configures a custom hostname using a certificate stored in Azure Key Vault.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="hostname">The fully qualified custom hostname.</param>
/// <param name="certificate">The Key Vault secret containing a PFX certificate.</param>
/// <param name="type">The API Management endpoint to configure.</param>
/// <param name="defaultSslBinding">Whether this certificate is the default SNI fallback. This is valid only for gateway hostnames.</param>
/// <param name="negotiateClientCertificate">Whether the endpoint negotiates client certificates.</param>
/// <returns>The resource builder.</returns>
[AspireExportIgnore(Reason = "Polyglot AppHosts pass the Key Vault resource and secret name separately.")]
public static IResourceBuilder<AzureApiManagementResource> WithCustomDomain(
this IResourceBuilder<AzureApiManagementResource> builder,
string hostname,
IAzureKeyVaultSecretReference certificate,
AzureApiManagementHostnameType type = AzureApiManagementHostnameType.Proxy,
bool defaultSslBinding = false,
bool negotiateClientCertificate = false)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(hostname);
ArgumentNullException.ThrowIfNull(certificate);
if (Uri.CheckHostName(hostname) != UriHostNameType.Dns ||
!hostname.TrimEnd('.').Contains('.'))
{
throw new ArgumentException("The custom hostname must be a fully qualified DNS name.", nameof(hostname));
}
if (defaultSslBinding && type != AzureApiManagementHostnameType.Proxy)
{
throw new ArgumentException("The default SSL binding can only be configured for a proxy hostname.", nameof(defaultSslBinding));
}
if (defaultSslBinding && builder.Resource.CustomDomains.Any(domain => domain.DefaultSslBinding))
{
throw new InvalidOperationException("Only one custom hostname can be configured as the default SSL binding.");
}
if (builder.Resource.Options.Sku == AzureApiManagementSku.Consumption &&
type != AzureApiManagementHostnameType.Proxy)
{
throw new InvalidOperationException(
"The API Management Consumption SKU supports custom domains only for the gateway endpoint.");
}
if (builder.Resource.Options.Sku is AzureApiManagementSku.BasicV2 or AzureApiManagementSku.StandardV2 or AzureApiManagementSku.PremiumV2 &&
type is AzureApiManagementHostnameType.Management or AzureApiManagementHostnameType.Scm)
{
throw new InvalidOperationException(
$"Custom domains for the '{type}' endpoint are not supported by API Management v2 SKUs.");
}
if (type == AzureApiManagementHostnameType.Proxy &&
builder.Resource.CustomDomains.Any(domain => domain.Type == AzureApiManagementHostnameType.Proxy) &&
builder.Resource.Options.Sku is not (AzureApiManagementSku.Developer or AzureApiManagementSku.Premium or AzureApiManagementSku.PremiumV2))
{
throw new InvalidOperationException(
$"Multiple gateway custom domains are not supported by the API Management '{builder.Resource.Options.Sku}' SKU.");
}
if (builder.Resource.CustomDomains.Any(domain =>
domain.Type == type &&
string.Equals(domain.Hostname, hostname, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"The custom hostname '{hostname}' has already been configured for endpoint type '{type}'.");
}
if (type != AzureApiManagementHostnameType.Proxy &&
builder.Resource.CustomDomains.Any(domain => domain.Type == type))
{
throw new InvalidOperationException($"Only one custom hostname can be configured for endpoint type '{type}'.");
}
builder.Resource.CustomDomains.Add(new(
hostname,
certificate,
type,
defaultSslBinding,
negotiateClientCertificate));
ConfigureKeyVaultAccess(builder.Resource, certificate, KeyVaultBuiltInRole.KeyVaultCertificateUser);
return builder.WithRelationship(certificate.Resource, "Certificate");
}
/// <summary>
/// Configures a custom hostname from a polyglot AppHost using a certificate stored in Azure Key Vault.
/// </summary>
[AspireExport("withCustomDomain")]
internal static IResourceBuilder<AzureApiManagementResource> WithCustomDomainForPolyglot(
this IResourceBuilder<AzureApiManagementResource> builder,
string hostname,
IResourceBuilder<AzureKeyVaultResource> vault,
string certificateSecretName,
AzureApiManagementHostnameType type = AzureApiManagementHostnameType.Proxy,
bool defaultSslBinding = false,
bool negotiateClientCertificate = false)
{
ArgumentNullException.ThrowIfNull(vault);
return builder.WithCustomDomain(
hostname,
vault.GetSecret(certificateSecretName),
type,
defaultSslBinding,
negotiateClientCertificate);
}
/// <summary>
/// Adds a configurable backend to the API Management service.
/// </summary>
/// <param name="builder">The API Management resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical backend name.</param>
/// <param name="uri">The deferred URI of the backend service.</param>
/// <param name="options">The backend options.</param>
/// <param name="backendName">The physical backend identifier in API Management. The Aspire resource name is used when omitted.</param>
/// <returns>A builder for the backend resource.</returns>
[AspireExport]
public static IResourceBuilder<AzureApiManagementBackendResource> AddBackend(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
ReferenceExpression uri,
AzureApiManagementBackendOptions? options = null,
string? backendName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(uri);
var resolvedBackendName = backendName ?? name;
ValidateGeneralIdentifier(resolvedBackendName, 80, nameof(backendName));
if (builder.Resource.Backends.Any(backend =>
string.Equals(backend.BackendName, resolvedBackendName, StringComparison.OrdinalIgnoreCase)) ||
builder.Resource.BackendPools.Any(pool =>
string.Equals(pool.BackendPoolName, resolvedBackendName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"An API Management backend with physical name '{resolvedBackendName}' has already been added.");
}
options ??= new AzureApiManagementBackendOptions();
ValidateBackendOptions(builder.Resource, options);
ValidateDisplayName(options.Title ?? name, "backend title", nameof(options));
var resource = new AzureApiManagementBackendResource(name, resolvedBackendName, uri, options, builder.Resource);
builder.Resource.Backends.Add(resource);
return (builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource))
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName("ArrowRouting");
}
/// <summary>
/// Adds an Azure OpenAI deployment as an API Management backend.
/// </summary>
[AspireExport]
public static IResourceBuilder<AzureApiManagementBackendResource> AddAzureOpenAIBackend(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
IResourceBuilder<AzureOpenAIDeploymentResource> deployment,
string? backendName = null)
{
ArgumentNullException.ThrowIfNull(deployment);
var backend = AddOpenAIBackend(
builder,
name,
deployment.Resource.Parent,
ReferenceExpression.Create($"{deployment.Resource.Parent.Endpoint}"),
deployment.Resource.DeploymentName,
backendName);
builder.Resource.AddIdentityRoleAssignment(
deployment.Resource.Parent,
new RoleDefinition(
CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser.ToString(),
CognitiveServicesBuiltInRole.GetBuiltInRoleName(CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser)));
return backend.WithRelationship(deployment.Resource, "Backend");
}
/// <summary>
/// Adds a Microsoft Foundry OpenAI deployment as an API Management backend.
/// </summary>
[AspireExport]
public static IResourceBuilder<AzureApiManagementBackendResource> AddFoundryBackend(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
IResourceBuilder<FoundryDeploymentResource> deployment,
string? backendName = null)
{
ArgumentNullException.ThrowIfNull(deployment);
if (!string.Equals(deployment.Resource.Format, "OpenAI", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Foundry deployment '{deployment.Resource.Name}' uses format '{deployment.Resource.Format}'. " +
"Only OpenAI-format deployments can be added as a Foundry backend.");
}
var backend = AddOpenAIBackend(
builder,
name,
deployment.Resource.Parent,
ReferenceExpression.Create($"{deployment.Resource.Parent.Endpoint}"),
deployment.Resource.DeploymentName,
backendName);
builder.Resource.AddIdentityRoleAssignment(
deployment.Resource.Parent,
new RoleDefinition(
CognitiveServicesBuiltInRole.CognitiveServicesUser.ToString(),
CognitiveServicesBuiltInRole.GetBuiltInRoleName(CognitiveServicesBuiltInRole.CognitiveServicesUser)));
return backend.WithRelationship(deployment.Resource, "Backend");
}
/// <summary>
/// Adds an Azure Blob Storage service as an API Management backend.
/// </summary>
[AspireExport]
public static IResourceBuilder<AzureApiManagementBackendResource> AddBlobStorageBackend(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
IResourceBuilder<AzureBlobStorageResource> blobs,
string? backendName = null)
{
ArgumentNullException.ThrowIfNull(blobs);
var backend = builder.AddBackend(
name,
blobs.Resource.UriExpression,
new AzureApiManagementBackendOptions
{
ManagedIdentityResource = "https://storage.azure.com/",
},
backendName);
builder.Resource.AddIdentityRoleAssignment(
blobs.Resource.Parent,
new RoleDefinition(
StorageBuiltInRole.StorageBlobDataReader.ToString(),
StorageBuiltInRole.GetBuiltInRoleName(StorageBuiltInRole.StorageBlobDataReader)));
return backend.WithRelationship(blobs.Resource, "Backend");
}
/// <summary>
/// Adds a reusable load-balancing pool to the API Management service.
/// </summary>
[AspireExport]
public static IResourceBuilder<AzureApiManagementBackendPoolResource> AddBackendPool(
this IResourceBuilder<AzureApiManagementResource> builder,
[ResourceName] string name,
string? displayName = null,
string? backendPoolName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
var resolvedBackendPoolName = backendPoolName ?? name;
ValidateGeneralIdentifier(resolvedBackendPoolName, 80, nameof(backendPoolName));
if (builder.Resource.BackendPools.Any(pool =>
string.Equals(pool.BackendPoolName, resolvedBackendPoolName, StringComparison.OrdinalIgnoreCase)) ||
builder.Resource.Backends.Any(backend =>
string.Equals(backend.BackendName, resolvedBackendPoolName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"An API Management backend or backend pool with physical name '{resolvedBackendPoolName}' has already been added.");
}
var resource = new AzureApiManagementBackendPoolResource(
name,
resolvedBackendPoolName,
displayName ?? name,
builder.Resource);
ValidateDisplayName(resource.DisplayName, "backend-pool display name", nameof(displayName));
builder.Resource.BackendPools.Add(resource);
return (builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource))
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName("ArrowRouting");
}
/// <summary>
/// Adds a backend to a load-balancing pool.
/// </summary>
[AspireExport("addBackendPoolMember")]
public static IResourceBuilder<AzureApiManagementBackendPoolResource> WithBackend(
this IResourceBuilder<AzureApiManagementBackendPoolResource> builder,
IResourceBuilder<AzureApiManagementBackendResource> backend,
int priority = 1,
int weight = 1)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(backend);
if (!ReferenceEquals(builder.Resource.Parent, backend.Resource.Parent))
{
throw new InvalidOperationException(
$"Backend '{backend.Resource.Name}' and pool '{builder.Resource.Name}' must belong to the same API Management service.");
}
if (priority is < 0 or > 100)
{
throw new ArgumentOutOfRangeException(nameof(priority), priority, "Priority must be between 0 and 100.");
}
if (weight is < 0 or > 100)
{
throw new ArgumentOutOfRangeException(nameof(weight), weight, "Weight must be between 0 and 100.");
}
if (builder.Resource.Backends.Count == 30)
{
throw new InvalidOperationException("An API Management backend pool cannot contain more than 30 backends.");
}
if (builder.Resource.Backends.Any(member => ReferenceEquals(member.Backend, backend.Resource)))
{
throw new InvalidOperationException(
$"Backend '{backend.Resource.Name}' has already been added to pool '{builder.Resource.Name}'.");
}
var existingAudience = builder.Resource.Backends
.Select(member => member.Backend.Options.ManagedIdentityResource)
.FirstOrDefault();
if (builder.Resource.Backends.Count > 0 &&
!string.Equals(existingAudience, backend.Resource.Options.ManagedIdentityResource, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"All backends in pool '{builder.Resource.Name}' must use the same managed-identity resource URI.");
}
builder.Resource.Backends.Add(new(backend.Resource, priority, weight));
return builder.WithRelationship(backend.Resource, "Member");
}
/// <summary>
/// Configures an API to route requests to a backend.
/// </summary>
[AspireExport("withApiBackend")]
public static IResourceBuilder<AzureApiManagementApiResource> WithBackend(
this IResourceBuilder<AzureApiManagementApiResource> builder,
IResourceBuilder<AzureApiManagementBackendResource> backend)
{
return WithBackendCore(builder, backend.Resource);
}
/// <summary>
/// Configures an API to route requests to a backend pool.
/// </summary>
[AspireExport("withApiBackendPool")]
public static IResourceBuilder<AzureApiManagementApiResource> WithBackend(
this IResourceBuilder<AzureApiManagementApiResource> builder,
IResourceBuilder<AzureApiManagementBackendPoolResource> backend)
{
return WithBackendCore(builder, backend.Resource);
}
private static IResourceBuilder<AzureApiManagementApiResource> AddApiCore(
IResourceBuilder<AzureApiManagementResource> builder,
string name,
string path,
string? displayName,
bool subscriptionRequired,
string? apiName,
IComputeResource? target = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(path);
ValidateApiIdentifier(apiName ?? name, nameof(apiName));
var normalizedPath = path.Trim('/');
ArgumentException.ThrowIfNullOrEmpty(normalizedPath);
ValidateMaximumLength(normalizedPath, 400, "The API path", nameof(path));
ValidateDisplayName(displayName ?? name, "API display name", nameof(displayName));
ValidateApiUniqueness(builder.Resource, apiName ?? name, normalizedPath);
var resource = target is null
? new AzureApiManagementApiResource(
name,
apiName ?? name,
normalizedPath,
displayName ?? name,
subscriptionRequired,
builder.Resource)
: new AzureApiManagementApiResource(
name,
apiName ?? name,
normalizedPath,
displayName ?? name,
subscriptionRequired,
target,
builder.Resource);
builder.Resource.Apis.Add(resource);
return (builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource))
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName("DocumentTableArrowRight");
}
private static IResourceBuilder<AzureApiManagementBackendResource> AddOpenAIBackend(
IResourceBuilder<AzureApiManagementResource> builder,
string name,
AzureProvisioningResource account,
ReferenceExpression endpoint,
string deploymentName,
string? backendName)
{
if (builder.Resource.Options.Sku == AzureApiManagementSku.Consumption)
{
throw new InvalidOperationException(
"OpenAI backends require API Management backend circuit breakers, which are not supported by the Consumption SKU.");
}
var backend = builder.AddBackend(
name,
ReferenceExpression.Create($"{endpoint}openai/deployments/{deploymentName}"),
new AzureApiManagementBackendOptions
{
ManagedIdentityResource = "https://cognitiveservices.azure.com",
CircuitBreaker = new AzureApiManagementCircuitBreakerOptions
{
Name = "openAIThrottling",
StatusCodeRanges = [new(429, 429)],
AcceptRetryAfter = true,
},
},
backendName);
return backend.WithRelationship(account, "Account");
}
private static IResourceBuilder<AzureApiManagementApiResource> WithBackendCore(
IResourceBuilder<AzureApiManagementApiResource> builder,
IResource backend)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(backend);
if (builder.Resource.Target is not null)
{
throw new InvalidOperationException(
$"API '{builder.Resource.Name}' already targets compute resource '{builder.Resource.Target.Name}'.");
}
var backendParent = backend switch
{
AzureApiManagementBackendResource value => value.Parent,
AzureApiManagementBackendPoolResource value => value.Parent,
_ => throw new ArgumentException("The resource must be an API Management backend or backend pool.", nameof(backend)),
};
if (!ReferenceEquals(builder.Resource.Parent, backendParent))
{
throw new InvalidOperationException(
$"API '{builder.Resource.Name}' and backend '{backend.Name}' must belong to the same API Management service.");
}
if (builder.Resource.Backend is not null)
{
throw new InvalidOperationException($"API '{builder.Resource.Name}' already has a backend.");
}
builder.Resource.Backend = backend;
return builder.WithRelationship(backend, "Backend");
}
/// <summary>
/// Adds an explicitly modeled operation to an API.
/// </summary>
/// <param name="builder">The API resource builder.</param>
/// <param name="name">The globally unique Aspire resource name and default physical operation name.</param>
/// <param name="method">The HTTP method.</param>
/// <param name="urlTemplate">The URL template relative to the API path.</param>
/// <param name="displayName">The operation display name. The resource name is used when omitted.</param>
/// <param name="operationName">The physical operation identifier in API Management. The Aspire resource name is used when omitted.</param>
/// <returns>A builder for the operation resource.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when a required string is empty.</exception>
/// <exception cref="InvalidOperationException">Thrown when the operation identifier or route duplicates another operation or conflicts with a generated proxy route.</exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementOperationResource> AddOperation(
this IResourceBuilder<AzureApiManagementApiResource> builder,
[ResourceName] string name,
string method,
string urlTemplate,
string? displayName = null,
string? operationName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(method);
ArgumentException.ThrowIfNullOrEmpty(urlTemplate);
ValidateMaximumLength(urlTemplate, 1000, "The operation URL template", nameof(urlTemplate));
ValidateDisplayName(displayName ?? name, "operation display name", nameof(displayName));
var physicalOperationName = operationName ?? name;
ValidateOperationIdentifier(physicalOperationName, nameof(operationName));
if (string.Equals(physicalOperationName, ProxyOperationName, StringComparison.OrdinalIgnoreCase) ||
s_proxyOperationMethods.Any(proxyMethod =>
string.Equals(physicalOperationName, GetProxyOperationName(proxyMethod), StringComparison.OrdinalIgnoreCase) ||
string.Equals(physicalOperationName, GetRootOperationName(proxyMethod), StringComparison.OrdinalIgnoreCase)))
{
throw new ArgumentException(
$"The API Management operation identifier '{physicalOperationName}' is reserved for a generated catch-all operation.",
nameof(operationName));
}
if (string.Equals(method, "*", StringComparison.Ordinal))
{
throw new ArgumentException(
"API Management does not reliably dispatch operations that use the wildcard HTTP method. Specify a concrete HTTP method instead.",
nameof(method));
}
if (builder.Resource.Operations.Any(
operation => string.Equals(operation.OperationName, physicalOperationName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"API '{builder.Resource.Name}' already contains an operation with the physical identifier '{physicalOperationName}'.");
}
var signature = GetOperationSignature(method, urlTemplate);
if (builder.Resource.Operations.Any(operation =>
string.Equals(GetOperationSignature(operation.Method, operation.UrlTemplate), signature, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"API '{builder.Resource.Name}' already contains an operation with the route '{method.ToUpperInvariant()} {urlTemplate}'. " +
"HTTP method and URL template must be unique, regardless of parameter names.");
}
var resource = new AzureApiManagementOperationResource(
name,
physicalOperationName,
method.ToUpperInvariant(),
urlTemplate,
displayName ?? name,
builder.Resource);
builder.Resource.Operations.Add(resource);
var resourceBuilder = builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource);
return resourceBuilder
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName("DocumentTableArrowRight");
}
/// <summary>
/// Appends an XML policy statement to the service-level inbound policy section.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="policyXml">One or more well-formed APIM policy elements without a <c>policies</c> envelope.</param>
/// <returns>The resource builder.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="policyXml"/> is empty or is not a valid XML fragment.</exception>
[AspireExport("withApiManagementServiceInboundPolicy", MethodName = "withInboundPolicy")]
public static IResourceBuilder<AzureApiManagementResource> WithInboundPolicy(
this IResourceBuilder<AzureApiManagementResource> builder,
string policyXml)
{
ArgumentNullException.ThrowIfNull(builder);
ValidatePolicyFragment(policyXml);
EnsurePolicyFragmentCanBeAdded(builder.Resource.PolicyXml, "service");
builder.Resource.AddInboundPolicyStatement(policyXml);
return builder;
}
/// <summary>
/// Replaces the complete service-level policy document.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="policyXml">A complete APIM <c>policies</c> XML document.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// APIM policy updates replace the complete policy document at a scope. To prevent silently dropping policy fragments,
/// this method cannot be combined with <see cref="WithInboundPolicy(IResourceBuilder{AzureApiManagementResource}, string)"/>
/// at the same scope.
/// </remarks>
/// <exception cref="ArgumentException">Thrown when <paramref name="policyXml"/> is not a complete policy document.</exception>
[AspireExport("withApiManagementServicePolicy", MethodName = "withPolicy")]
public static IResourceBuilder<AzureApiManagementResource> WithPolicy(
this IResourceBuilder<AzureApiManagementResource> builder,
string policyXml)
{
ArgumentNullException.ThrowIfNull(builder);
ValidatePolicyDocument(policyXml);
EnsureCompletePolicyCanBeSet(builder.Resource.InboundPolicyStatements, "service");
builder.Resource.PolicyXml = policyXml;
return builder;
}
/// <summary>
/// Appends an XML policy statement to the API-level inbound policy section.
/// </summary>
/// <param name="builder">The API resource builder.</param>
/// <param name="policyXml">One or more well-formed APIM policy elements without a <c>policies</c> envelope.</param>
/// <returns>The resource builder.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="policyXml"/> is empty or is not a valid XML fragment.</exception>
[AspireExport("withApiManagementApiInboundPolicy", MethodName = "withInboundPolicy")]
public static IResourceBuilder<AzureApiManagementApiResource> WithInboundPolicy(
this IResourceBuilder<AzureApiManagementApiResource> builder,
string policyXml)
{
ArgumentNullException.ThrowIfNull(builder);
ValidatePolicyFragment(policyXml);
EnsurePolicyFragmentCanBeAdded(builder.Resource.PolicyXml, "API");
builder.Resource.AddInboundPolicyStatement(policyXml);
return builder;
}
/// <summary>
/// Replaces the complete API-level policy document.
/// </summary>
/// <param name="builder">The API resource builder.</param>
/// <param name="policyXml">A complete APIM <c>policies</c> XML document.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// Replacing the API policy also replaces the generated backend-routing policy. The supplied document must
/// configure backend routing when the API should continue forwarding requests.
/// </remarks>
/// <exception cref="ArgumentException">Thrown when <paramref name="policyXml"/> is not a complete policy document.</exception>
[AspireExport("withApiManagementApiPolicy", MethodName = "withPolicy")]
public static IResourceBuilder<AzureApiManagementApiResource> WithPolicy(
this IResourceBuilder<AzureApiManagementApiResource> builder,
string policyXml)
{
ArgumentNullException.ThrowIfNull(builder);
ValidatePolicyDocument(policyXml);
EnsureCompletePolicyCanBeSet(builder.Resource.InboundPolicyStatements, "API");
builder.Resource.PolicyXml = policyXml;
return builder;
}
/// <summary>
/// Appends an XML policy statement to the operation-level inbound policy section.
/// </summary>
/// <param name="builder">The operation resource builder.</param>
/// <param name="policyXml">One or more well-formed APIM policy elements without a <c>policies</c> envelope.</param>
/// <returns>The resource builder.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="policyXml"/> is empty or is not a valid XML fragment.</exception>
[AspireExport("withApiManagementOperationInboundPolicy", MethodName = "withInboundPolicy")]
public static IResourceBuilder<AzureApiManagementOperationResource> WithInboundPolicy(
this IResourceBuilder<AzureApiManagementOperationResource> builder,
string policyXml)
{
ArgumentNullException.ThrowIfNull(builder);
ValidatePolicyFragment(policyXml);
EnsurePolicyFragmentCanBeAdded(builder.Resource.PolicyXml, "operation");
builder.Resource.AddInboundPolicyStatement(policyXml);
return builder;
}
/// <summary>
/// Replaces the complete operation-level policy document.
/// </summary>
/// <param name="builder">The operation resource builder.</param>
/// <param name="policyXml">A complete APIM <c>policies</c> XML document.</param>
/// <returns>The resource builder.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="policyXml"/> is not a complete policy document.</exception>
[AspireExport("withApiManagementOperationPolicy", MethodName = "withPolicy")]
public static IResourceBuilder<AzureApiManagementOperationResource> WithPolicy(
this IResourceBuilder<AzureApiManagementOperationResource> builder,
string policyXml)
{
ArgumentNullException.ThrowIfNull(builder);
ValidatePolicyDocument(policyXml);
EnsureCompletePolicyCanBeSet(builder.Resource.InboundPolicyStatements, "operation");
builder.Resource.PolicyXml = policyXml;
return builder;
}
/// <summary>
/// Injects a classic Developer or Premium API Management service into a virtual network subnet.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="subnet">The subnet resource. Classic APIM injection requires an undelegated subnet with an NSG.</param>
/// <param name="mode">Whether the APIM endpoints are externally or internally accessible.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// This API models classic VNet injection only. Standard v2 outbound integration and Premium v2 injection have
/// different subnet delegations and lifecycle constraints. Use <see cref="WithVirtualNetworkIntegration"/> for
/// Standard v2 outbound integration or <see cref="WithVirtualNetworkInjection"/> for Premium v2 injection.
/// See <see href="https://learn.microsoft.com/azure/api-management/virtual-network-concepts">APIM virtual networking</see>.
/// </remarks>
/// <exception cref="InvalidOperationException">Thrown when the selected SKU does not support classic VNet injection or the subnet is delegated.</exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementResource> WithClassicVirtualNetwork(
this IResourceBuilder<AzureApiManagementResource> builder,
IResourceBuilder<AzureSubnetResource> subnet,
AzureApiManagementVirtualNetworkMode mode)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(subnet);
if (builder.Resource.Options.Sku is not (AzureApiManagementSku.Developer or AzureApiManagementSku.Premium))
{
throw new InvalidOperationException(
$"Classic virtual network injection is supported only by the Developer and Premium SKUs, not '{builder.Resource.Options.Sku}'.");
}
if (subnet.Resource.Annotations.OfType<AzureSubnetServiceDelegationAnnotation>().Any())
{
throw new InvalidOperationException(
$"Subnet '{subnet.Resource.Name}' is delegated to another Azure service. Classic API Management injection requires an undelegated subnet.");
}
ValidateSubnetOwnership(builder.Resource, subnet.Resource);
if (SetVirtualNetworkConfiguration(
builder.Resource,
subnet.Resource,
mode,
AzureApiManagementVirtualNetworkKind.Classic))
{
subnet.Resource.Annotations.Add(new AzureApiManagementSubnetUsageAnnotation(builder.Resource));
}
return builder.WithRelationship(subnet.Resource, "Virtual network");
}
/// <summary>
/// Integrates a Standard v2 or Premium v2 API Management service with a virtual network for outbound access.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="subnet">A dedicated subnet for API Management virtual network integration.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// The subnet is delegated to <c>Microsoft.Web/serverFarms</c>. The APIM gateway remains publicly accessible,
/// while backend traffic can reach resources in the virtual network.
/// See <see href="https://learn.microsoft.com/azure/api-management/integrate-vnet-outbound">APIM outbound virtual network integration</see>.
/// </remarks>
/// <exception cref="InvalidOperationException">
/// Thrown when the selected SKU does not support v2 virtual network integration, the subnet has a conflicting
/// delegation, the subnet is already used by another API Management service, or its NSG cannot
/// accommodate the required Key Vault allow rule ahead of outbound deny rules.
/// </exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementResource> WithVirtualNetworkIntegration(
this IResourceBuilder<AzureApiManagementResource> builder,
IResourceBuilder<AzureSubnetResource> subnet)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(subnet);
if (builder.Resource.Options.Sku is not (AzureApiManagementSku.StandardV2 or AzureApiManagementSku.PremiumV2))
{
throw new InvalidOperationException(
$"Virtual network integration is supported only by the Standard v2 and Premium v2 SKUs, not '{builder.Resource.Options.Sku}'.");
}
ValidateV2SubnetSize(subnet.Resource);
ValidateSubnetOwnership(builder.Resource, subnet.Resource);
if (builder.Resource.VirtualNetworkConfiguration is null)
{
_ = GetKeyVaultRulePriority(subnet.Resource);
}
ConfigureV2SubnetDelegation(subnet, AzureSubnetServiceDelegations.AppServiceEnvironments);
if (SetVirtualNetworkConfiguration(
builder.Resource,
subnet.Resource,
AzureApiManagementVirtualNetworkMode.External,
AzureApiManagementVirtualNetworkKind.V2Integration))
{
subnet.Resource.Annotations.Add(new AzureApiManagementSubnetUsageAnnotation(builder.Resource));
ConfigureV2SubnetNetworkSecurityGroup(subnet);
}
return builder.WithRelationship(subnet.Resource, "Virtual network integration");
}
/// <summary>
/// Injects a Premium v2 API Management service into a virtual network for private inbound and outbound access.
/// </summary>
/// <param name="builder">The Azure API Management resource builder.</param>
/// <param name="subnet">A dedicated subnet for API Management virtual network injection.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// The subnet is delegated to <c>Microsoft.Web/hostingEnvironments</c>. Premium v2 injection can only be
/// selected when the service is created and cannot later be changed to virtual network integration.
/// See <see href="https://learn.microsoft.com/azure/api-management/inject-vnet-v2">Premium v2 virtual network injection</see>.
/// </remarks>
/// <exception cref="InvalidOperationException">
/// Thrown when the selected SKU is not Premium v2, the subnet has a conflicting delegation, or the subnet is
/// already used by another API Management service, or its NSG cannot accommodate the required
/// Key Vault allow rule ahead of outbound deny rules.
/// </exception>
[AspireExport]
public static IResourceBuilder<AzureApiManagementResource> WithVirtualNetworkInjection(
this IResourceBuilder<AzureApiManagementResource> builder,
IResourceBuilder<AzureSubnetResource> subnet)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(subnet);
if (builder.Resource.Options.Sku is not AzureApiManagementSku.PremiumV2)
{
throw new InvalidOperationException(
$"Virtual network injection is supported only by the Premium v2 SKU, not '{builder.Resource.Options.Sku}'.");
}
ValidateV2SubnetSize(subnet.Resource);
ValidateSubnetOwnership(builder.Resource, subnet.Resource);
if (builder.Resource.VirtualNetworkConfiguration is null)
{
_ = GetKeyVaultRulePriority(subnet.Resource);
}
ConfigureV2SubnetDelegation(subnet, PremiumV2SubnetDelegation);
if (SetVirtualNetworkConfiguration(
builder.Resource,
subnet.Resource,
AzureApiManagementVirtualNetworkMode.Internal,
AzureApiManagementVirtualNetworkKind.PremiumV2Injection))
{
subnet.Resource.Annotations.Add(new AzureApiManagementSubnetUsageAnnotation(builder.Resource));
ConfigureV2SubnetNetworkSecurityGroup(subnet);
}
return builder.WithRelationship(subnet.Resource, "Virtual network injection");
}
private static void ConfigureInfrastructure(AzureResourceInfrastructure infrastructure)
{
var azureResource = (AzureApiManagementResource)infrastructure.AspireResource;
ValidateBackendPhysicalNames(azureResource);
if (azureResource.VirtualNetworkConfiguration is { } virtualNetworkConfiguration)
{
ValidateFinalSubnetDelegation(virtualNetworkConfiguration);
}
if (azureResource.VirtualNetworkConfiguration is
{ Kind: AzureApiManagementVirtualNetworkKind.V2Integration or AzureApiManagementVirtualNetworkKind.PremiumV2Injection } network)
{
var rules = network.Subnet.NetworkSecurityGroup?.SecurityRules;
var allowRule = rules?.SingleOrDefault(rule => string.Equals(rule.Name, "allow-apim-key-vault", StringComparison.OrdinalIgnoreCase));
if (rules is null || allowRule is null || allowRule.Access != SecurityRuleAccess.Allow ||
allowRule.Direction != SecurityRuleDirection.Outbound ||
allowRule.Protocol != SecurityRuleProtocol.Tcp ||
allowRule.DestinationPortRange != "443" ||
allowRule.SourcePortRange != "*" ||
allowRule.SourceAddressPrefix != AzureServiceTags.VirtualNetwork ||
allowRule.DestinationAddressPrefix != AzureServiceTags.AzureKeyVault ||
allowRule.SourceAddressPrefixReference is not null ||
allowRule.DestinationAddressPrefixReference is not null ||
rules.Any(rule => CouldDenyKeyVault(rule) && rule.Priority <= allowRule.Priority))
{
throw new InvalidOperationException(
$"Subnet '{network.Subnet.Name}' must retain an effective 'allow-apim-key-vault' NSG rule ahead of outbound TCP/443 deny rules.");
}
}
var isExisting = azureResource.IsExisting();
if (isExisting && !azureResource.Options.HasExplicitSku)
{
throw new InvalidOperationException(
$"Existing API Management resource '{azureResource.Name}' requires an explicit {nameof(AzureApiManagementOptions.Sku)} " +
"matching the adopted service. The default Developer SKU is only used for new services.");
}
if (isExisting &&
!azureResource.Identity.IsExisting() &&
(azureResource.Backends.Any(backend => backend.Options.ManagedIdentityResource is not null) ||
azureResource.NamedValues.Any(namedValue => namedValue.Value is IAzureKeyVaultSecretReference)))
{
throw new InvalidOperationException(
$"Existing API Management resource '{azureResource.Name}' uses managed-identity backends or Key Vault-backed named values, " +
"but its user-assigned identity is not configured as an existing resource. " +
$"Supply an already-attached identity using {nameof(WithAzureUserAssignedIdentity)}(...) and configure it with PublishAsExisting(...). " +
"Aspire does not attach identities to existing services.");
}
var hasExplicitExistingScope =
azureResource.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingAnnotation) &&
(existingAnnotation.ResourceGroup is not null || existingAnnotation.Subscription is not null);
var hasPrivateEndpoint = azureResource.HasAnnotationOfType<PrivateEndpointTargetAnnotation>();
if (hasPrivateEndpoint)
{
if (azureResource.Options.Sku is AzureApiManagementSku.Consumption or AzureApiManagementSku.BasicV2)
{
throw new InvalidOperationException(
$"API Management SKU '{azureResource.Options.Sku}' does not support private endpoints.");
}
if (azureResource.VirtualNetworkConfiguration is
{
Kind: AzureApiManagementVirtualNetworkKind.Classic or
AzureApiManagementVirtualNetworkKind.PremiumV2Injection,
})
{
throw new InvalidOperationException(
$"API Management resource '{azureResource.Name}' cannot combine a private endpoint with virtual network injection.");
}
}
if (isExisting && azureResource.VirtualNetworkConfiguration is not null)
{
throw new InvalidOperationException(
$"Existing API Management resource '{azureResource.Name}' cannot change its virtual network configuration.");
}
if (isExisting && azureResource.CustomDomains.Count > 0)
{
throw new InvalidOperationException(
$"Existing API Management resource '{azureResource.Name}' cannot change its custom domains.");
}
if (isExisting &&
hasExplicitExistingScope &&
(azureResource.Diagnostic is not null ||
azureResource.Apis.Any(api => api.Diagnostic is not null)))
{
throw new InvalidOperationException(
$"Existing API Management resource '{azureResource.Name}' cannot configure diagnostics " +
"when it is adopted from another resource group or subscription.");
}
var service = isExisting
? AzureProvisioningResource.CreateExistingOrNewProvisionableResource(
infrastructure,
static (identifier, name) =>
{
var existing = ApiManagementService.FromExisting(identifier, "2025-03-01-preview");
existing.Name = name;
return existing;
},
static _ => throw new UnreachableException())
// Premium v2 injection requires this preview ARM version, even though the SDK defaults to 2024-05-01.
// https://learn.microsoft.com/azure/api-management/inject-vnet-v2
: new ApiManagementService(infrastructure.AspireResource.GetBicepIdentifier(), "2025-03-01-preview")
{
PublisherEmail = azureResource.Options.PublisherEmail,
PublisherName = azureResource.Options.PublisherName,
Sku = new ApiManagementServiceSkuProperties
{
Name = GetProvisioningSku(azureResource.Options.Sku),
Capacity = azureResource.Options.Capacity,
},
Identity = new ManagedServiceIdentity
{
ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned,
UserAssignedIdentities =
{
[BicepFunction.Interpolate($"{azureResource.Identity.Id.AsProvisioningParameter(infrastructure)}").Compile().ToString()] =
new UserAssignedIdentityDetails(),
},
},
VirtualNetworkType = VirtualNetworkType.None,
Tags =
{
{ "aspire-resource-name", azureResource.Name },
},
};
if (azureResource.VirtualNetworkConfiguration is { } virtualNetwork)
{
service.VirtualNetworkType = virtualNetwork.Mode switch
{
AzureApiManagementVirtualNetworkMode.External => VirtualNetworkType.External,
AzureApiManagementVirtualNetworkMode.Internal => VirtualNetworkType.Internal,
_ => throw new UnreachableException(),
};
service.VirtualNetworkConfiguration = new VirtualNetworkConfiguration
{
SubnetResourceId = virtualNetwork.Subnet.Id.AsProvisioningParameter(infrastructure),
};
}
var keyVaultRoleAssignments = new Dictionary<string, RoleAssignment>(StringComparer.Ordinal);
foreach (var customDomain in azureResource.CustomDomains)
{
var secret = customDomain.Certificate.AsKeyVaultSecret(infrastructure);
if (customDomain.Certificate.Resource is not AzureProvisioningResource)
{
var roleAssignment = AddKeyVaultRoleAssignment(
infrastructure,
customDomain.Certificate,
(UserAssignedIdentity)azureResource.Identity.AddAsExistingResource(infrastructure),
KeyVaultBuiltInRole.KeyVaultCertificateUser,
keyVaultRoleAssignments);
service.DependsOn.Add(roleAssignment);
}
service.HostnameConfigurations.Add(new HostnameConfiguration
{
HostnameType = GetProvisioningHostnameType(customDomain.Type),
HostName = customDomain.Hostname,
KeyVaultSecretUri = new BicepValue<Uri>(CreateVersionlessSecretUri(secret).Compile()),
IdentityClientId = azureResource.Identity.ClientId.AsProvisioningParameter(infrastructure),
IsDefaultSslBindingEnabled = customDomain.DefaultSslBinding,
IsClientCertificateNegotiationEnabled = customDomain.NegotiateClientCertificate,
});
}
if (!isExisting)
{
infrastructure.Add(service);
}
var namedValues = AddNamedValues(
infrastructure,
azureResource,
service,
keyVaultRoleAssignments);
var policyFragments = AddPolicyFragments(infrastructure, azureResource, service, namedValues);
AddServicePolicy(infrastructure, azureResource, service, policyFragments, namedValues);
var provisionedBackends = AddBackends(infrastructure, azureResource, service);
var provisionedBackendPools = AddBackendPools(infrastructure, azureResource, service, provisionedBackends);
var provisionedApis = new Dictionary<AzureApiManagementApiResource, ApiManagementApi>();
foreach (var apiResource in azureResource.Apis)
{
provisionedApis.Add(
apiResource,
AddApi(
infrastructure,
apiResource,
service,
provisionedBackends,
provisionedBackendPools,
policyFragments,
namedValues));
}
AddProducts(infrastructure, azureResource, service, provisionedApis);
AddDiagnostics(infrastructure, azureResource, service, provisionedApis);
infrastructure.Add(new ProvisioningOutput("gatewayUrl", typeof(string))
{
Value = service.GatewayUri,
});
infrastructure.Add(new ProvisioningOutput("name", typeof(string))
{
Value = service.Name,
});
infrastructure.Add(new ProvisioningOutput("id", typeof(string))
{
Value = service.Id,
});
}
private static void AddServicePolicy(
AzureResourceInfrastructure infrastructure,
AzureApiManagementResource azureResource,
ApiManagementService service,
IReadOnlyList<PolicyFragmentContract> policyFragments,
IReadOnlyList<ApiManagementNamedValue> namedValues)
{
var policyXml = azureResource.PolicyXml ??
CreatePolicyDocument(azureResource.InboundPolicyStatements, inheritParentPolicy: false);
if (policyXml is null)
{
return;
}
var policy = new ApiManagementPolicy(
CreateGeneratedBicepIdentifier("servicePolicy", service.BicepIdentifier))
{
Parent = service,
Name = "policy",
Format = PolicyContentFormat.RawXml,
Value = policyXml!,
};
foreach (var policyFragment in policyFragments)
{
policy.DependsOn.Add(policyFragment);
}
foreach (var namedValue in namedValues)
{
policy.DependsOn.Add(namedValue);
}
infrastructure.Add(policy);
}
private static IReadOnlyList<PolicyFragmentContract> AddPolicyFragments(
AzureResourceInfrastructure infrastructure,
AzureApiManagementResource azureResource,
ApiManagementService service,
IReadOnlyList<ApiManagementNamedValue> namedValues)
{
var provisionedFragments = new List<PolicyFragmentContract>();
foreach (var fragmentResource in azureResource.PolicyFragments)
{
var fragment = new PolicyFragmentContract(
Infrastructure.NormalizeBicepIdentifier(fragmentResource.Name))
{
Parent = service,
Name = fragmentResource.FragmentName,
Format = PolicyFragmentContentFormat.Rawxml,
Value = CreatePolicyFragmentDocument(fragmentResource.Value),
};
if (fragmentResource.Description is not null)
{
fragment.Description = fragmentResource.Description;
}
foreach (var namedValue in namedValues)
{
fragment.DependsOn.Add(namedValue);
}
infrastructure.Add(fragment);
provisionedFragments.Add(fragment);
}
return provisionedFragments;
}
private static IReadOnlyList<ApiManagementNamedValue> AddNamedValues(
AzureResourceInfrastructure infrastructure,
AzureApiManagementResource azureResource,
ApiManagementService service,
Dictionary<string, RoleAssignment> keyVaultRoleAssignments)
{
var provisionedNamedValues = new List<ApiManagementNamedValue>();
foreach (var namedValueResource in azureResource.NamedValues)
{
var namedValue = new ApiManagementNamedValue(
Infrastructure.NormalizeBicepIdentifier(namedValueResource.Name))
{
Parent = service,
Name = namedValueResource.NamedValueName,
DisplayName = namedValueResource.DisplayName,
IsSecret = namedValueResource.Secret,
};
foreach (var tag in namedValueResource.Tags)
{
namedValue.Tags.Add(tag);
}
if (namedValueResource.Value is IAzureKeyVaultSecretReference secretReference)
{
var secret = secretReference.AsKeyVaultSecret(infrastructure);
namedValue.KeyVault = new KeyVaultContractCreateProperties
{
SecretIdentifier = CreateVersionlessSecretUri(secret),
IdentityClientId = azureResource.Identity.ClientId.AsProvisioningParameter(infrastructure),
};
if (secretReference.Resource is not AzureProvisioningResource)
{
var roleAssignment = AddKeyVaultRoleAssignment(
infrastructure,
secretReference,
(UserAssignedIdentity)azureResource.Identity.AddAsExistingResource(infrastructure),
KeyVaultBuiltInRole.KeyVaultSecretsUser,
keyVaultRoleAssignments);
namedValue.DependsOn.Add(roleAssignment);
}
}
else if (namedValueResource.Value is ParameterResource parameter)
{
namedValue.Value = parameter.AsProvisioningParameter(
infrastructure,
parameterName: CreateGeneratedBicepIdentifier("namedValueParameter", namedValueResource.Name),
isSecure: true);
}
else
{
namedValue.Value = (string)namedValueResource.Value;
}
infrastructure.Add(namedValue);
provisionedNamedValues.Add(namedValue);
}
return provisionedNamedValues;
}
private static void AddProducts(
AzureResourceInfrastructure infrastructure,
AzureApiManagementResource azureResource,
ApiManagementService service,
IReadOnlyDictionary<AzureApiManagementApiResource, ApiManagementApi> provisionedApis)
{
foreach (var productResource in azureResource.Products)
{
var productIdentifier = Infrastructure.NormalizeBicepIdentifier(productResource.Name);
var product = new ApiManagementProduct(productIdentifier)
{
Parent = service,
Name = productResource.ProductName,
DisplayName = productResource.DisplayName,
IsSubscriptionRequired = productResource.Options.SubscriptionRequired,
IsApprovalRequired = productResource.Options.ApprovalRequired,
State = productResource.Options.State switch
{
AzureApiManagementProductState.NotPublished => ApiManagementProductState.NotPublished,
AzureApiManagementProductState.Published => ApiManagementProductState.Published,
_ => throw new UnreachableException(),
},
};
if (productResource.Options.SubscriptionsLimit is { } subscriptionsLimit)
{
product.SubscriptionsLimit = subscriptionsLimit;
}
if (productResource.Options.Description is not null)
{
product.Description = productResource.Options.Description;
}
if (productResource.Options.Terms is not null)
{
product.Terms = productResource.Options.Terms;
}
infrastructure.Add(product);
foreach (var apiResource in productResource.Apis)
{
var productApi = new ServiceProductApiLink(
CreateGeneratedBicepIdentifier("productApi", productResource.Name, apiResource.Name))
{
Parent = product,
Name = CreateBoundedIdentifier(apiResource.ApiName, 80),
ApiId = new BicepValue<string>(provisionedApis[apiResource].Id.Compile()),
};
productApi.DependsOn.Add(provisionedApis[apiResource]);
infrastructure.Add(productApi);
}
foreach (var subscriptionResource in productResource.Subscriptions)
{
var subscription = new ApiManagementSubscription(
Infrastructure.NormalizeBicepIdentifier(subscriptionResource.Name))
{
Parent = service,
Name = subscriptionResource.SubscriptionName,
DisplayName = subscriptionResource.DisplayName,
Scope = new BicepValue<string>(product.Id.Compile()),
State = subscriptionResource.Options.State switch
{
AzureApiManagementSubscriptionState.Active => SubscriptionState.Active,
AzureApiManagementSubscriptionState.Suspended => SubscriptionState.Suspended,
AzureApiManagementSubscriptionState.Submitted => SubscriptionState.Submitted,
AzureApiManagementSubscriptionState.Rejected => SubscriptionState.Rejected,
AzureApiManagementSubscriptionState.Expired => SubscriptionState.Expired,
AzureApiManagementSubscriptionState.Cancelled => SubscriptionState.Cancelled,
_ => throw new UnreachableException(),
},
AllowTracing = subscriptionResource.Options.AllowTracing,
};
infrastructure.Add(subscription);
}
}
}
private static void AddDiagnostics(
AzureResourceInfrastructure infrastructure,
AzureApiManagementResource azureResource,
ApiManagementService service,
IReadOnlyDictionary<AzureApiManagementApiResource, ApiManagementApi> provisionedApis)
{
var configuredDiagnostics = azureResource.Apis
.Where(api => api.Diagnostic is not null)
.Select(api => api.Diagnostic!)
.Prepend(azureResource.Diagnostic)
.OfType<AzureApiManagementDiagnostic>()
.ToArray();
if (configuredDiagnostics.Length == 0)
{
return;
}
var loggers = new Dictionary<AzureApplicationInsightsResource, ApiManagementLogger>();
foreach (var diagnostic in configuredDiagnostics)
{
if (loggers.ContainsKey(diagnostic.ApplicationInsights))
{
continue;
}
var applicationInsights = (ApplicationInsightsComponent)diagnostic.ApplicationInsights.AddAsExistingResource(infrastructure);
var logger = new ApiManagementLogger(
CreateGeneratedBicepIdentifier("logger", diagnostic.ApplicationInsights.Name))
{
Parent = service,
Name = CreateBoundedIdentifier($"{diagnostic.ApplicationInsights.Name}-application-insights", 256),
LoggerType = LoggerType.ApplicationInsights,
ResourceId = applicationInsights.Id,
IsBuffered = true,
Credentials =
{
{ "instrumentationKey", applicationInsights.InstrumentationKey },
},
};
infrastructure.Add(logger);
loggers.Add(diagnostic.ApplicationInsights, logger);
}
if (azureResource.Diagnostic is { } serviceDiagnostic)
{
var diagnostic = CreateServiceDiagnostic(
service,
loggers[serviceDiagnostic.ApplicationInsights],
serviceDiagnostic.Options);
infrastructure.Add(diagnostic);
}
foreach (var apiResource in azureResource.Apis)
{
if (apiResource.Diagnostic is not { } apiDiagnostic)
{
continue;
}
var diagnostic = CreateApiDiagnostic(
provisionedApis[apiResource],
loggers[apiDiagnostic.ApplicationInsights],
apiDiagnostic.Options);
infrastructure.Add(diagnostic);
}
}
private static ApiManagementDiagnostic CreateServiceDiagnostic(
ApiManagementService service,
ApiManagementLogger logger,
AzureApiManagementDiagnosticOptions options)
{
var diagnostic = new ApiManagementDiagnostic(
CreateGeneratedBicepIdentifier("serviceDiagnostic", service.BicepIdentifier))
{
Parent = service,
Name = "applicationinsights",
LoggerId = new BicepValue<string>(logger.Id.Compile()),
AlwaysLog = AlwaysLog.AllErrors,
Sampling = new SamplingSettings
{
SamplingType = SamplingType.Fixed,
Percentage = options.SamplingPercentage,
},
HttpCorrelationProtocol = HttpCorrelationProtocol.W3C,
IsLogClientIPEnabled = options.LogClientIp,
Verbosity = GetProvisioningVerbosity(options.Verbosity),
OperationNameFormat = OperationNameFormat.Name,
Metrics = true,
};
diagnostic.DependsOn.Add(logger);
return diagnostic;
}
private static ApiDiagnostic CreateApiDiagnostic(
ApiManagementApi api,
ApiManagementLogger logger,
AzureApiManagementDiagnosticOptions options)
{
var diagnostic = new ApiDiagnostic(
CreateGeneratedBicepIdentifier("apiDiagnostic", api.BicepIdentifier))
{
Parent = api,
Name = "applicationinsights",
LoggerId = new BicepValue<string>(logger.Id.Compile()),
AlwaysLog = AlwaysLog.AllErrors,
Sampling = new SamplingSettings
{
SamplingType = SamplingType.Fixed,
Percentage = options.SamplingPercentage,
},
HttpCorrelationProtocol = HttpCorrelationProtocol.W3C,
IsLogClientIPEnabled = options.LogClientIp,
Verbosity = GetProvisioningVerbosity(options.Verbosity),
OperationNameFormat = OperationNameFormat.Name,
Metrics = true,
};
diagnostic.DependsOn.Add(logger);
return diagnostic;
}
private static void ConfigureKeyVaultAccess(
AzureApiManagementResource apiManagement,
IAzureKeyVaultSecretReference secret,
KeyVaultBuiltInRole role)
{
if (secret.Resource is AzureProvisioningResource target)
{
apiManagement.AddIdentityRoleAssignment(
target,
new RoleDefinition(role.ToString(), KeyVaultBuiltInRole.GetBuiltInRoleName(role)));
}
}
private static RoleAssignment AddKeyVaultRoleAssignment(
AzureResourceInfrastructure infrastructure,
IAzureKeyVaultSecretReference secretReference,
UserAssignedIdentity identity,
KeyVaultBuiltInRole role,
Dictionary<string, RoleAssignment> roleAssignments)
{
var secret = secretReference.AsKeyVaultSecret(infrastructure);
var vault = secret.Parent
?? throw new InvalidOperationException($"Key Vault secret '{secretReference.SecretName}' does not have a parent vault.");
var key = $"{vault.BicepIdentifier}:{role}";
if (roleAssignments.TryGetValue(key, out var existingRoleAssignment))
{
return existingRoleAssignment;
}
var roleDefinitionId = BicepFunction.GetSubscriptionResourceId(
"Microsoft.Authorization/roleDefinitions",
role.ToString());
var roleAssignment = new RoleAssignment(
Infrastructure.NormalizeBicepIdentifier($"{vault.BicepIdentifier}_{KeyVaultBuiltInRole.GetBuiltInRoleName(role)}"))
{
// Role assignment names must be known at the start of deployment. The principal ID is
// runtime-only, so use the identity resource ID to keep the name stable and deterministic.
Name = BicepFunction.CreateGuid(vault.Id, identity.Id, roleDefinitionId),
Scope = new IdentifierExpression(vault.BicepIdentifier),
PrincipalType = RoleManagementPrincipalType.ServicePrincipal,
PrincipalId = identity.PrincipalId,
RoleDefinitionId = roleDefinitionId,
};
infrastructure.Add(roleAssignment);
roleAssignments.Add(key, roleAssignment);
return roleAssignment;
}
private static BicepValue<string> CreateVersionlessSecretUri(KeyVaultSecret secret)
{
var vault = secret.Parent
?? throw new InvalidOperationException($"Key Vault secret '{secret.BicepIdentifier}' does not have a parent vault.");
// APIM refreshes Key Vault-backed values and certificates only when the URI does not pin a secret version.
// The generated URI has the form https://{vault}.vault.azure.net/secrets/{secret-name}.
return BicepFunction.Interpolate($"{vault.Properties.VaultUri}secrets/{secret.Name}");
}
private static ApiManagementApi AddApi(
AzureResourceInfrastructure infrastructure,
AzureApiManagementApiResource apiResource,
ApiManagementService service,
IReadOnlyDictionary<AzureApiManagementBackendResource, ApiManagementBackend> provisionedBackends,
IReadOnlyDictionary<AzureApiManagementBackendPoolResource, ApiManagementBackend> provisionedBackendPools,
IReadOnlyList<PolicyFragmentContract> policyFragments,
IReadOnlyList<ApiManagementNamedValue> namedValues)
{
var apiIdentifier = Infrastructure.NormalizeBicepIdentifier(apiResource.Name);
var api = new ApiManagementApi(
apiIdentifier)
{
Parent = service,
Name = apiResource.ApiName,
DisplayName = apiResource.DisplayName,
Path = apiResource.Path,
IsSubscriptionRequired = apiResource.SubscriptionRequired,
ApiType = ApiType.Http,
Protocols =
{
ApiOperationInvokableProtocol.Https,
},
};
if (apiResource.OpenApiSource is AzureApiManagementOpenApiFile document)
{
var content = File.ReadAllText(document.Path);
if (string.IsNullOrWhiteSpace(content))
{
throw new InvalidOperationException(
$"The OpenAPI document '{document.Path}' for API '{apiResource.Name}' is empty.");
}
api.Format = GetOpenApiImportFormat(document.Format, isLink: false);
api.Value = content;
}
else if (apiResource.OpenApiSource is AzureApiManagementOpenApiEndpoint endpointSource)
{
var documentUrl = ResolveOpenApiEndpoint(apiResource, endpointSource);
api.Format = GetOpenApiImportFormat(endpointSource.Format, isLink: true);
api.Value = documentUrl.AsProvisioningParameter(
infrastructure,
CreateGeneratedBicepIdentifier("openApiUrl", apiIdentifier));
}
var usesImportedServiceUrl = apiResource.OpenApiSource is AzureApiManagementOpenApiEndpoint;
if (usesImportedServiceUrl)
{
infrastructure.Add(api);
}
var (backendIdentifier, backend, managedIdentityResource) = apiResource.Target is not null
? AddComputeBackend(infrastructure, apiResource, api, service, apiIdentifier)
: ResolveApiManagementBackend(apiResource, provisionedBackends, provisionedBackendPools);
if (!usesImportedServiceUrl)
{
infrastructure.Add(api);
}
List<ProvisionableResource> operationDependencies = [];
if (apiResource.OpenApiSource is null)
{
// Re-check explicit operations against the reserved proxy routes here, once the API's final
// OpenAPI configuration is known, rather than at AddOperation call time. WithOpenApiDocument
// can be applied after AddOperation, and call order must not change validation outcomes.
foreach (var operationResource in apiResource.Operations)
{
var signature = GetOperationSignature(operationResource.Method, operationResource.UrlTemplate);
if (s_proxyOperationMethods.Any(proxyMethod =>
string.Equals(signature, GetOperationSignature(proxyMethod, "/{*path}"), StringComparison.OrdinalIgnoreCase) ||
string.Equals(signature, GetOperationSignature(proxyMethod, "/"), StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"The route '{operationResource.Method} {operationResource.UrlTemplate}' conflicts with a generated proxy operation. " +
$"Configure an OpenAPI import for API '{apiResource.Name}' if you do not want the generated proxy routes.");
}
}
// APIM's management plane accepts "*" as an operation method, but gateways do not
// reliably dispatch it. Materialize the catch-all route for each supported method.
foreach (var method in s_proxyOperationMethods)
{
// The wildcard matches descendants but not the API suffix itself (for example /api).
var rootOperation = new ApiOperation(
CreateGeneratedBicepIdentifier($"proxy{method}RootOperation", apiIdentifier))
{
Parent = api,
Name = GetRootOperationName(method),
DisplayName = $"Proxy {method} root",
Method = method,
UriTemplate = "/",
};
infrastructure.Add(rootOperation);
operationDependencies.Add(rootOperation);
var catchAllOperation = new ApiOperation(
CreateGeneratedBicepIdentifier($"proxy{method}Operation", apiIdentifier))
{
Parent = api,
Name = GetProxyOperationName(method),
DisplayName = $"Proxy {method}",
Method = method,
UriTemplate = "/{*path}",
};
catchAllOperation.TemplateParameters.Add(new ParameterContract
{
Name = "path",
ParameterContractType = "string",
IsRequired = true,
});
infrastructure.Add(catchAllOperation);
operationDependencies.Add(catchAllOperation);
}
}
foreach (var operationResource in apiResource.Operations)
{
operationDependencies.Add(AddOperation(infrastructure, operationResource, api, policyFragments, namedValues));
}
var policyXml = apiResource.PolicyXml ??
CreatePolicyDocument(
apiResource.InboundPolicyStatements,
backendIdentifier,
managedIdentityResource: managedIdentityResource,
managedIdentityClientId: managedIdentityResource is not null
? apiResource.Parent.Identity.ClientId.AsProvisioningParameter(infrastructure)
: (BicepValue<string>?)null);
var policy = new ApiPolicy(
CreateGeneratedBicepIdentifier("apiPolicy", apiIdentifier))
{
Parent = api,
Name = "policy",
Format = PolicyContentFormat.RawXml,
Value = policyXml!,
};
policy.DependsOn.Add(backend);
// The API policy write failed with PreconditionFailed while operation writes overlapped
// in the APIM deployment test. Finish this API's operations and their policies first.
foreach (var operationDependency in operationDependencies)
{
policy.DependsOn.Add(operationDependency);
}
foreach (var policyFragment in policyFragments)
{
policy.DependsOn.Add(policyFragment);
}
foreach (var namedValue in namedValues)
{
policy.DependsOn.Add(namedValue);
}
infrastructure.Add(policy);
return api;
}
private static (string Identifier, ApiManagementBackend Backend, string? ManagedIdentityResource)
AddComputeBackend(
AzureResourceInfrastructure infrastructure,
AzureApiManagementApiResource apiResource,
ApiManagementApi api,
ApiManagementService service,
string apiIdentifier)
{
Debug.Assert(apiResource.Target is not null);
if (apiResource.Target is not IResourceWithEndpoints endpointResource)
{
throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' does not expose endpoints and cannot be used as an API Management backend.");
}
var httpEndpoints = endpointResource.GetEndpoints()
.Where(e => e.EndpointAnnotation.UriScheme is "http" or "https")
.ToArray();
var endpoint = httpEndpoints.FirstOrDefault(e => e.EndpointAnnotation.IsExternal)
?? httpEndpoints.FirstOrDefault()
?? throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' does not have an HTTP or HTTPS endpoint.");
if (!endpoint.EndpointAnnotation.IsExternal && apiResource.Parent.VirtualNetworkConfiguration is null)
{
throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' exposes only internal HTTP endpoints. " +
$"Configure virtual network connectivity for API Management resource '{apiResource.Parent.Name}' before adding it as a backend.");
}
if (!ComputeEnvironmentEndpointResolver.TryGetEffectiveComputeEnvironment(apiResource.Target, out var computeEnvironment))
{
throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' does not have a compute environment. " +
"Configure an Azure deployment environment before adding it to API Management.");
}
if (computeEnvironment.TryGetLastAnnotation<InternalLoadBalancerAnnotation>(out var internalLoadBalancer))
{
var apiManagementNetwork = apiResource.Parent.VirtualNetworkConfiguration;
if (apiManagementNetwork is null ||
!ReferenceEquals(apiManagementNetwork.Subnet.Parent, internalLoadBalancer.VirtualNetwork))
{
throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' is deployed to an internal Container Apps environment. " +
$"API Management resource '{apiResource.Parent.Name}' must be injected into the same virtual network.");
}
}
var backendIdentifier = CreateGeneratedBicepIdentifier("computeBackend", apiIdentifier);
var backendName = CreateBoundedIdentifier($"{apiIdentifier}Backend", 80);
var endpointExpression = computeEnvironment.GetEndpointPropertyExpression(
endpoint.Property(EndpointProperty.Url));
var computeBackendUrl = endpointExpression.AsProvisioningParameter(
infrastructure,
CreateGeneratedBicepIdentifier("computeBackendUrl", apiIdentifier));
var importedServiceUrl = new SafeMemberExpression(
new MemberExpression(new IdentifierExpression(api.BicepIdentifier), "properties"),
"serviceUrl");
BicepValue<string> backendUrl = apiResource.OpenApiSource is AzureApiManagementOpenApiEndpoint
// APIM resolves the document's servers entry during import. Reuse that value so a
// non-root server path is not discarded when the policy selects the named backend.
// Documents without a server URL can omit serviceUrl or leave it empty, so retain
// the compute endpoint as a fallback.
? new ConditionalExpression(
new FunctionCallExpression(new IdentifierExpression("empty"), importedServiceUrl),
computeBackendUrl.Value.Compile(),
importedServiceUrl)
: computeBackendUrl;
var backend = new ApiManagementBackend(backendIdentifier)
{
Parent = service,
Name = backendName,
Protocol = BackendProtocol.Http,
Uri = new BicepValue<Uri>(backendUrl.Compile()),
Title = apiResource.DisplayName,
TypePropertiesType = BackendType.Single,
Tls = new BackendTlsProperties
{
ShouldValidateCertificateChain = true,
ShouldValidateCertificateName = true,
},
};
infrastructure.Add(backend);
return (backendName, backend, null);
}
private static ReferenceExpression ResolveOpenApiEndpoint(
AzureApiManagementApiResource apiResource,
AzureApiManagementOpenApiEndpoint source)
{
Debug.Assert(apiResource.Target is IResourceWithEndpoints);
var endpointResource = (IResourceWithEndpoints)apiResource.Target;
var endpoint = source.EndpointName is not null
? endpointResource.GetEndpoint(source.EndpointName)
: endpointResource.GetEndpoints()
.FirstOrDefault(candidate =>
candidate.EndpointAnnotation.IsExternal &&
candidate.EndpointAnnotation.UriScheme is "http" or "https");
if (endpoint is null ||
!endpoint.EndpointAnnotation.IsExternal ||
endpoint.EndpointAnnotation.UriScheme is not ("http" or "https"))
{
throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' does not expose a suitable external HTTP or HTTPS endpoint for OpenAPI import. " +
"Use WithOpenApiDocument for a private backend.");
}
if (!ComputeEnvironmentEndpointResolver.TryGetEffectiveComputeEnvironment(apiResource.Target, out var computeEnvironment))
{
throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' does not have a compute environment. " +
"Configure an Azure deployment environment before importing its OpenAPI endpoint.");
}
if (computeEnvironment.HasAnnotationOfType<InternalLoadBalancerAnnotation>())
{
throw new InvalidOperationException(
$"Resource '{apiResource.Target.Name}' is deployed to an internal Container Apps environment. " +
"The API Management control plane cannot retrieve its OpenAPI document. Use WithOpenApiDocument instead.");
}
var endpointExpression = computeEnvironment.GetEndpointPropertyExpression(
endpoint.Property(EndpointProperty.Url));
return ReferenceExpression.Create($"{endpointExpression}{source.Path}");
}
private static Dictionary<AzureApiManagementBackendResource, ApiManagementBackend> AddBackends(
AzureResourceInfrastructure infrastructure,
AzureApiManagementResource azureResource,
ApiManagementService service)
{
var provisionedBackends = new Dictionary<AzureApiManagementBackendResource, ApiManagementBackend>();
foreach (var backendResource in azureResource.Backends)
{
var backendIdentifier = Infrastructure.NormalizeBicepIdentifier(backendResource.Name);
var backend = new ApiManagementBackend(backendIdentifier)
{
Parent = service,
Name = backendResource.BackendName,
Protocol = GetProvisioningBackendProtocol(backendResource.Options.Protocol),
Uri = new BicepValue<Uri>(backendResource.UriExpression.AsProvisioningParameter(
infrastructure,
CreateGeneratedBicepIdentifier("backendUrl", backendIdentifier)).Value.Compile()),
Title = backendResource.Options.Title ?? backendResource.Name,
TypePropertiesType = BackendType.Single,
Tls = new BackendTlsProperties
{
ShouldValidateCertificateChain = backendResource.Options.ValidateCertificateChain,
ShouldValidateCertificateName = backendResource.Options.ValidateCertificateName,
},
};
if (backendResource.Options.CircuitBreaker is { } circuitBreaker)
{
backend.CircuitBreakerRules.Add(CreateCircuitBreakerRule(circuitBreaker));
}
infrastructure.Add(backend);
provisionedBackends.Add(backendResource, backend);
}
return provisionedBackends;
}
private static void ValidateBackendPhysicalNames(AzureApiManagementResource azureResource)
{
var backendNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var backendName in azureResource.Backends.Select(backend => backend.BackendName)
.Concat(azureResource.BackendPools.Select(pool => pool.BackendPoolName)))
{
backendNames.Add(backendName);
}
foreach (var apiResource in azureResource.Apis.Where(api => api.Target is not null))
{
var apiIdentifier = Infrastructure.NormalizeBicepIdentifier(apiResource.Name);
var backendName = CreateBoundedIdentifier($"{apiIdentifier}Backend", 80);
if (!backendNames.Add(backendName))
{
throw new InvalidOperationException(
$"API Management API '{apiResource.Name}' generates backend physical name '{backendName}', " +
"which is already used by another backend or backend pool.");
}
}
}
private static Dictionary<AzureApiManagementBackendPoolResource, ApiManagementBackend> AddBackendPools(
AzureResourceInfrastructure infrastructure,
AzureApiManagementResource azureResource,
ApiManagementService service,
IReadOnlyDictionary<AzureApiManagementBackendResource, ApiManagementBackend> provisionedBackends)
{
var provisionedPools = new Dictionary<AzureApiManagementBackendPoolResource, ApiManagementBackend>();
foreach (var poolResource in azureResource.BackendPools)
{
if (poolResource.Backends.Count == 0)
{
throw new InvalidOperationException(
$"API Management backend pool '{poolResource.Name}' does not contain any backends.");
}
var poolIdentifier = Infrastructure.NormalizeBicepIdentifier(poolResource.Name);
var pool = new ApiManagementBackend(poolIdentifier)
{
Parent = service,
Name = poolResource.BackendPoolName,
Title = poolResource.DisplayName,
TypePropertiesType = BackendType.Pool,
};
foreach (var member in poolResource.Backends)
{
var backend = provisionedBackends[member.Backend];
pool.PoolServices.Add(new BackendPoolItem
{
Id = backend.Id,
Priority = member.Priority,
Weight = member.Weight,
});
pool.DependsOn.Add(backend);
}
infrastructure.Add(pool);
provisionedPools.Add(poolResource, pool);
}
return provisionedPools;
}
private static (string Identifier, ApiManagementBackend Backend, string? ManagedIdentityResource)
ResolveApiManagementBackend(
AzureApiManagementApiResource apiResource,
IReadOnlyDictionary<AzureApiManagementBackendResource, ApiManagementBackend> provisionedBackends,
IReadOnlyDictionary<AzureApiManagementBackendPoolResource, ApiManagementBackend> provisionedBackendPools)
{
return apiResource.Backend switch
{
AzureApiManagementBackendResource backend =>
(backend.BackendName, provisionedBackends[backend], backend.Options.ManagedIdentityResource),
AzureApiManagementBackendPoolResource pool =>
(pool.BackendPoolName, provisionedBackendPools[pool], pool.Backends[0].Backend.Options.ManagedIdentityResource),
null => throw new InvalidOperationException(
$"API '{apiResource.Name}' does not have a backend. Call {nameof(WithBackend)} before deployment."),
_ => throw new UnreachableException(),
};
}
private static CircuitBreakerRule CreateCircuitBreakerRule(
AzureApiManagementCircuitBreakerOptions options)
{
var rule = new CircuitBreakerRule
{
Name = options.Name,
FailureCondition = new CircuitBreakerFailureCondition
{
Count = options.FailureCount,
// Azure.Provisioning serializes TimeSpan literals as "00:00:30", but APIM requires ISO 8601.
// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/backends#circuitbreakerfailurecondition
Interval = new StringLiteralExpression(XmlConvert.ToString(TimeSpan.FromSeconds(options.FailureIntervalSeconds))),
},
TripDuration = new StringLiteralExpression(XmlConvert.ToString(TimeSpan.FromSeconds(options.TripDurationSeconds))),
AcceptRetryAfter = options.AcceptRetryAfter,
};
foreach (var range in options.StatusCodeRanges)
{
rule.FailureCondition.StatusCodeRanges.Add(new FailureStatusCodeRange
{
Min = range.Minimum,
Max = range.Maximum,
});
}
return rule;
}
private static ProvisionableResource AddOperation(
AzureResourceInfrastructure infrastructure,
AzureApiManagementOperationResource operationResource,
ApiManagementApi api,
IReadOnlyList<PolicyFragmentContract> policyFragments,
IReadOnlyList<ApiManagementNamedValue> namedValues)
{
var operationIdentifier = Infrastructure.NormalizeBicepIdentifier(operationResource.Name);
var operation = new ApiOperation(
operationIdentifier)
{
Parent = api,
Name = operationResource.OperationName,
DisplayName = operationResource.DisplayName,
Method = operationResource.Method,
UriTemplate = operationResource.UrlTemplate,
};
foreach (var parameterName in GetTemplateParameterNames(operationResource.UrlTemplate))
{
operation.TemplateParameters.Add(new ParameterContract
{
Name = parameterName,
ParameterContractType = "string",
IsRequired = true,
});
}
infrastructure.Add(operation);
var policyXml = operationResource.PolicyXml ??
CreatePolicyDocument(operationResource.InboundPolicyStatements);
if (policyXml is null)
{
return operation;
}
var policy = new ApiOperationPolicy(
CreateGeneratedBicepIdentifier("operationPolicy", operationIdentifier))
{
Parent = operation,
Name = "policy",
Format = PolicyContentFormat.RawXml,
Value = policyXml,
};
foreach (var policyFragment in policyFragments)
{
policy.DependsOn.Add(policyFragment);
}
foreach (var namedValue in namedValues)
{
policy.DependsOn.Add(namedValue);
}
infrastructure.Add(policy);
return policy;
}
private static IResourceBuilder<AzureApiManagementNamedValueResource> AddNamedValueCore(
IResourceBuilder<AzureApiManagementResource> builder,
string name,
object value,
bool secret,
string? displayName,
string? namedValueName,
string[]? tags)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
var resolvedNamedValueName = namedValueName ?? name;
var resolvedDisplayName = displayName ?? name;
ValidateGeneralIdentifier(resolvedNamedValueName, 256, nameof(namedValueName));
if (resolvedDisplayName.Length is 0 or > 256 ||
resolvedDisplayName.Any(character => !char.IsLetterOrDigit(character) && character is not ('-' or '.' or '_')))
{
throw new ArgumentException(
"The named-value display name must contain between 1 and 256 letters, digits, hyphens, periods, or underscores.",
nameof(displayName));
}
if (builder.Resource.NamedValues.Any(namedValue =>
string.Equals(namedValue.NamedValueName, resolvedNamedValueName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(namedValue.DisplayName, resolvedDisplayName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"An API Management named value with physical name '{resolvedNamedValueName}' or display name '{resolvedDisplayName}' has already been added.");
}
var resource = new AzureApiManagementNamedValueResource(
name,
resolvedNamedValueName,
resolvedDisplayName,
value,
secret,
tags ?? [],
builder.Resource);
builder.Resource.NamedValues.Add(resource);
var resourceBuilder = builder.ApplicationBuilder.ExecutionContext.IsRunMode
? builder.ApplicationBuilder.CreateResourceBuilder(resource)
: builder.ApplicationBuilder.AddResource(resource);
return resourceBuilder
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
.WithIconName(secret ? "LockClosed" : "BracesVariable");
}
private static void ValidatePolicyFragmentParent(
AzureApiManagementResource parent,
IResourceBuilder<AzureApiManagementPolicyFragmentResource> fragment)
{
ArgumentNullException.ThrowIfNull(fragment);
if (!ReferenceEquals(parent, fragment.Resource.Parent))
{
throw new InvalidOperationException(
"An API Management policy can only include policy fragments from the same API Management service.");
}
}
private static string CreateIncludeFragmentPolicy(string fragmentName) =>
$"<include-fragment fragment-id=\"{fragmentName}\" />";
private static string CreatePolicyFragmentDocument(string policyXml)
{
var builder = new StringBuilder();
builder.Append("<fragment>\n");
foreach (var line in policyXml.Split('\n'))
{
builder.Append(" ").Append(line.TrimEnd('\r')).Append('\n');
}
builder.Append("</fragment>");
return builder.ToString();
}
private static HostnameType GetProvisioningHostnameType(AzureApiManagementHostnameType type) =>
type switch
{
AzureApiManagementHostnameType.ConfigurationApi => HostnameType.ConfigurationApi,
AzureApiManagementHostnameType.DeveloperPortal => HostnameType.DeveloperPortal,
AzureApiManagementHostnameType.Portal => HostnameType.Portal,
AzureApiManagementHostnameType.Proxy => HostnameType.Proxy,
AzureApiManagementHostnameType.Management => HostnameType.Management,
AzureApiManagementHostnameType.Scm => HostnameType.Scm,
_ => throw new UnreachableException(),
};
private static TraceVerbosityLevel GetProvisioningVerbosity(AzureApiManagementDiagnosticVerbosity verbosity) =>
verbosity switch
{
AzureApiManagementDiagnosticVerbosity.Error => TraceVerbosityLevel.Error,
AzureApiManagementDiagnosticVerbosity.Information => TraceVerbosityLevel.Information,
AzureApiManagementDiagnosticVerbosity.Verbose => TraceVerbosityLevel.Verbose,
_ => throw new UnreachableException(),
};
private static BackendProtocol GetProvisioningBackendProtocol(AzureApiManagementBackendProtocol protocol) =>
protocol switch
{
AzureApiManagementBackendProtocol.Http => BackendProtocol.Http,
AzureApiManagementBackendProtocol.Soap => BackendProtocol.Soap,
_ => throw new UnreachableException(),
};
private static void ValidateBackendOptions(
AzureApiManagementResource service,
AzureApiManagementBackendOptions options)
{
if (string.IsNullOrWhiteSpace(options.ManagedIdentityResource) &&
options.ManagedIdentityResource is not null)
{
throw new ArgumentException(
"The managed-identity resource URI cannot be empty.",
nameof(options));
}
var circuitBreaker = options.CircuitBreaker;
if (circuitBreaker is null)
{
return;
}
if (service.Options.Sku == AzureApiManagementSku.Consumption)
{
throw new InvalidOperationException(
"API Management backend circuit breakers are not supported by the Consumption SKU.");
}
ArgumentException.ThrowIfNullOrEmpty(circuitBreaker.Name);
ArgumentOutOfRangeException.ThrowIfLessThan(circuitBreaker.FailureCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(circuitBreaker.FailureIntervalSeconds, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(circuitBreaker.TripDurationSeconds, 1);
foreach (var range in circuitBreaker.StatusCodeRanges)
{
if (range.Minimum is < 200 or > 599)
{
throw new ArgumentOutOfRangeException(
nameof(options),
range.Minimum,
"The minimum circuit-breaker status code must be between 200 and 599.");
}
if (range.Maximum < range.Minimum || range.Maximum > 599)
{
throw new ArgumentOutOfRangeException(
nameof(options),
range.Maximum,
"The maximum circuit-breaker status code must be between the minimum status code and 599.");
}
}
if (circuitBreaker.StatusCodeRanges.Length > 10)
{
throw new ArgumentException(
"An API Management circuit-breaker rule cannot contain more than 10 status-code ranges.",
nameof(options));
}
}
private static void ValidateDisplayName(string value, string description, string parameterName)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 300)
{
throw new ArgumentException(
$"The {description} must be between 1 and 300 characters.",
parameterName);
}
}
private static void ValidateMaximumLength(string value, int maximumLength, string description, string parameterName)
{
if (value.Length > maximumLength)
{
throw new ArgumentException($"{description} cannot exceed {maximumLength} characters.", parameterName);
}
}
private static void ValidateDiagnosticOptions(AzureApiManagementDiagnosticOptions options)
{
if (!double.IsFinite(options.SamplingPercentage) ||
options.SamplingPercentage is < 0 or > 100)
{
throw new ArgumentOutOfRangeException(
nameof(options),
options.SamplingPercentage,
"The Application Insights sampling percentage must be between 0 and 100.");
}
}
private static void ValidateGeneralIdentifier(string identifier, int maximumLength, string parameterName)
{
ArgumentException.ThrowIfNullOrEmpty(identifier, parameterName);
if (identifier.Length > maximumLength)
{
throw new ArgumentException(
$"The API Management identifier cannot exceed {maximumLength} characters.",
parameterName);
}
if (identifier.IndexOfAny(s_invalidApiIdentifierCharacters) >= 0)
{
throw new ArgumentException(
"The API Management identifier cannot contain '*', '#', '&', '+', ':', '<', '>', or '?'.",
parameterName);
}
}
private static void ValidatePolicyFragmentIdentifier(string identifier, string parameterName)
{
ArgumentException.ThrowIfNullOrEmpty(identifier, parameterName);
if (identifier.Length > 80)
{
throw new ArgumentException("The policy-fragment identifier cannot exceed 80 characters.", parameterName);
}
static bool IsWordCharacter(char value) => char.IsLetterOrDigit(value) || value == '_';
if (!IsWordCharacter(identifier[0]) ||
!IsWordCharacter(identifier[^1]) ||
identifier.Any(character => !IsWordCharacter(character) && character != '-'))
{
throw new ArgumentException(
"The policy-fragment identifier may contain letters, digits, underscores, and non-leading or trailing hyphens.",
parameterName);
}
}
private static BicepValue<string>? CreatePolicyDocument(
IReadOnlyList<string> inboundStatements,
string? backendIdentifier = null,
bool inheritParentPolicy = true,
string? managedIdentityResource = null,
BicepValue<string>? managedIdentityClientId = null)
{
if (backendIdentifier is null && inboundStatements.Count == 0)
{
return null;
}
var builder = new StringBuilder();
builder.Append("<policies>\n");
builder.Append(" <inbound>\n");
if (inheritParentPolicy)
{
builder.Append(" <base />\n");
}
string? prefix = null;
if (managedIdentityResource is not null)
{
Debug.Assert(managedIdentityClientId is not null);
// APIM defaults to its system identity unless client-id selects the attached UAMI.
// https://learn.microsoft.com/azure/api-management/authentication-managed-identity-policy
builder.Append(" <authentication-managed-identity ")
.Append(new XAttribute("resource", managedIdentityResource))
.Append(" client-id=\"");
prefix = builder.ToString();
builder.Clear();
builder.Append("\" />\n");
}
if (backendIdentifier is not null)
{
builder.Append(" <set-backend-service ")
.Append(new XAttribute("backend-id", backendIdentifier))
.Append(" />\n");
}
foreach (var statement in inboundStatements)
{
foreach (var line in statement.Split('\n'))
{
builder.Append(" ").Append(line.TrimEnd('\r')).Append('\n');
}
}
builder.Append(" </inbound>\n");
if (inheritParentPolicy)
{
builder.Append(" <backend><base /></backend>\n");
builder.Append(" <outbound><base /></outbound>\n");
builder.Append(" <on-error><base /></on-error>\n");
}
else
{
// A service policy is the root of the policy hierarchy, so <base /> has no parent
// scope to inherit. It must forward the request explicitly instead.
builder.Append(" <backend><forward-request /></backend>\n");
builder.Append(" <outbound />\n");
builder.Append(" <on-error />\n");
}
builder.Append("</policies>");
return prefix is null
? new BicepValue<string>(builder.ToString())
: BicepFunction.Interpolate($"{prefix}{managedIdentityClientId}{builder.ToString()}");
}
private static IEnumerable<string> GetTemplateParameterNames(string uriTemplate)
{
// APIM operation templates use placeholders such as:
// /products/{productId}/items/{itemId}?locale={locale}
// Each distinct placeholder must have a matching templateParameters entry.
var parameterNames = new HashSet<string>(StringComparer.Ordinal);
var searchIndex = 0;
while (searchIndex < uriTemplate.Length)
{
var openingBrace = uriTemplate.IndexOf('{', searchIndex);
if (openingBrace < 0)
{
yield break;
}
var closingBrace = uriTemplate.IndexOf('}', openingBrace + 1);
if (closingBrace < 0)
{
yield break;
}
var parameterName = uriTemplate[(openingBrace + 1)..closingBrace];
if (parameterName.StartsWith('*'))
{
// Catch-all operation templates use /{*path}, while APIM expects the
// corresponding templateParameters entry to be named "path".
parameterName = parameterName[1..];
}
if (parameterName.Length > 0 && parameterNames.Add(parameterName))
{
yield return parameterName;
}
searchIndex = closingBrace + 1;
}
}
private static string GetProxyOperationName(string method) =>
$"{ProxyOperationName}-{method.ToLowerInvariant()}";
private static string GetRootOperationName(string method) =>
$"{GetProxyOperationName(method)}-root";
private static string GetOperationSignature(string method, string urlTemplate)
{
// /products/{id}?expand={fields} and /products/{productId}?expand={selection}
// describe the same route. Keep literal segments and wildcard markers, not parameter names.
var template = new StringBuilder();
for (var index = 0; index < urlTemplate.Length; index++)
{
if (urlTemplate[index] == '{' && urlTemplate.IndexOf('}', index + 1) is var end && end > index)
{
template.Append(urlTemplate[index + 1] == '*' ? "{*}" : "{}");
index = end;
}
else
{
template.Append(urlTemplate[index]);
}
}
var parts = template.ToString().Split('?', 2);
var path = "/" + parts[0].TrimStart('/');
var query = parts.Length == 2
? "?" + string.Join('&', parts[1].Split('&').Order(StringComparer.OrdinalIgnoreCase))
: string.Empty;
return $"{method.ToUpperInvariant()} {path}{query}";
}
private static void ValidatePolicyFragment(string policyXml)
{
ArgumentException.ThrowIfNullOrEmpty(policyXml);
try
{
var document = XDocument.Parse($"<fragment>{policyXml}</fragment>");
if (document.Root?.Elements().Any(e => e.Name.LocalName == "policies") is true)
{
throw new ArgumentException(
"An inbound policy statement must not contain a complete <policies> document.",
nameof(policyXml));
}
}
catch (System.Xml.XmlException exception)
{
throw new ArgumentException("The policy statement must be well-formed XML.", nameof(policyXml), exception);
}
}
private static void ValidatePolicyDocument(string policyXml)
{
ArgumentException.ThrowIfNullOrEmpty(policyXml);
try
{
var document = XDocument.Parse(policyXml);
if (document.Root?.Name.LocalName != "policies")
{
throw new ArgumentException(
"A complete policy document must have a <policies> root element.",
nameof(policyXml));
}
}
catch (System.Xml.XmlException exception)
{
throw new ArgumentException("The policy document must be well-formed XML.", nameof(policyXml), exception);
}
}
private static void EnsurePolicyFragmentCanBeAdded(string? completePolicyXml, string scope)
{
if (completePolicyXml is not null)
{
throw new InvalidOperationException(
$"A complete {scope}-level policy has already been configured. " +
$"{nameof(WithPolicy)} and {nameof(WithInboundPolicy)} cannot be combined at the same scope.");
}
}
private static void EnsureCompletePolicyCanBeSet(IReadOnlyList<string> inboundPolicyStatements, string scope)
{
if (inboundPolicyStatements.Count > 0)
{
throw new InvalidOperationException(
$"{scope}-level inbound policy statements have already been configured. " +
$"{nameof(WithPolicy)} and {nameof(WithInboundPolicy)} cannot be combined at the same scope.");
}
}
private static void ValidateApiIdentifier(string value, string parameterName)
{
ArgumentException.ThrowIfNullOrEmpty(value, parameterName);
if (value.Length > 256)
{
throw new ArgumentException(
"API Management API identifiers cannot exceed 256 characters.",
parameterName);
}
if (value.IndexOfAny(s_invalidApiIdentifierCharacters) >= 0)
{
throw new ArgumentException(
"API Management API identifiers cannot contain '*', '#', '&', '+', ':', '<', '>', or '?'.",
parameterName);
}
}
private static void ValidateOperationIdentifier(string value, string parameterName)
{
ArgumentException.ThrowIfNullOrEmpty(value, parameterName);
if (value.Length > 80)
{
throw new ArgumentException(
"API Management operation identifiers cannot exceed 80 characters.",
parameterName);
}
}
private static void ValidateApiUniqueness(
AzureApiManagementResource service,
string apiName,
string normalizedPath)
{
if (service.Apis.Any(api => string.Equals(api.ApiName, apiName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"API Management service '{service.Name}' already contains an API with the physical identifier '{apiName}'.");
}
if (service.Apis.Any(api => string.Equals(api.Path, normalizedPath, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"API Management service '{service.Name}' already contains an API at path '{normalizedPath}'.");
}
}
internal static string CreateBoundedIdentifier(string value, int maximumLength)
{
if (value.Length <= maximumLength)
{
return value;
}
var hash = GetStableHash(value);
return $"{value[..(maximumLength - hash.Length - 1)]}-{hash}";
}
private static string CreateGeneratedBicepIdentifier(string kind, params string[] resourceNames)
{
// Aspire resource names must start with a letter, so this prefix reserves a namespace for
// generated APIM symbols that cannot collide with symbols derived from user resource names.
var identifier = Infrastructure.NormalizeBicepIdentifier(
string.Join('_', resourceNames.Prepend(kind).Prepend("apim").Prepend(string.Empty)));
if (resourceNames.Length > 1)
{
// Joining normalized names is ambiguous when a separator can also occur within a name.
// A null character cannot occur in an Aspire resource name, so it preserves component
// boundaries when computing the stable identity suffix.
return $"{identifier}_{GetStableHash(string.Join('\0', resourceNames))}";
}
return identifier;
}
private static string GetStableHash(string value) =>
Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(value))).ToLowerInvariant()[..8];
private static AzureApiManagementOpenApiFormat InferOpenApiFormat(string path)
{
var pathWithoutQuery = path.Split('?', '#')[0];
return Path.GetExtension(pathWithoutQuery).ToLowerInvariant() switch
{
".json" => AzureApiManagementOpenApiFormat.OpenApiJson,
".yaml" or ".yml" => AzureApiManagementOpenApiFormat.OpenApi,
_ => throw new ArgumentException(
"The OpenAPI format cannot be inferred. Specify the format explicitly.",
nameof(path)),
};
}
private static ContentFormat GetOpenApiImportFormat(AzureApiManagementOpenApiFormat format, bool isLink) =>
(format, isLink) switch
{
(AzureApiManagementOpenApiFormat.OpenApi, false) => ContentFormat.OpenApi,
(AzureApiManagementOpenApiFormat.OpenApi, true) => ContentFormat.OpenApiLink,
(AzureApiManagementOpenApiFormat.OpenApiJson, false) => ContentFormat.OpenApiJson,
(AzureApiManagementOpenApiFormat.OpenApiJson, true) => ContentFormat.OpenApiJsonLink,
(AzureApiManagementOpenApiFormat.SwaggerJson, false) => ContentFormat.SwaggerJson,
(AzureApiManagementOpenApiFormat.SwaggerJson, true) => ContentFormat.SwaggerLinkJson,
_ => throw new UnreachableException(),
};
// WithServiceDelegation is explicitly last-write-wins (see AzureVirtualNetworkExtensions), so a
// subnet's delegation recorded at WithClassicVirtualNetwork/WithVirtualNetworkIntegration/
// WithVirtualNetworkInjection call time can be replaced afterward by an unrelated later call on the
// same subnet. Revalidate the subnet's *final* delegation here, during infrastructure generation,
// so call order cannot produce an APIM service with an invalid or missing Azure delegation.
private static void ValidateFinalSubnetDelegation(AzureApiManagementVirtualNetworkConfiguration network)
{
network.Subnet.TryGetLastAnnotation<AzureSubnetServiceDelegationAnnotation>(out var delegation);
var requiredDelegation = network.Kind switch
{
AzureApiManagementVirtualNetworkKind.Classic => null,
AzureApiManagementVirtualNetworkKind.V2Integration => AzureSubnetServiceDelegations.AppServiceEnvironments,
AzureApiManagementVirtualNetworkKind.PremiumV2Injection => PremiumV2SubnetDelegation,
_ => throw new UnreachableException(),
};
if (requiredDelegation is null)
{
if (delegation is not null)
{
throw new InvalidOperationException(
$"Subnet '{network.Subnet.Name}' is delegated to '{delegation.ServiceName}', " +
"but classic API Management virtual network injection requires an undelegated subnet.");
}
return;
}
if (delegation is null || !string.Equals(delegation.ServiceName, requiredDelegation, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Subnet '{network.Subnet.Name}' must be delegated to '{requiredDelegation}' for API Management " +
$"{(network.Kind == AzureApiManagementVirtualNetworkKind.PremiumV2Injection ? "virtual network injection" : "virtual network integration")}, " +
$"but it is {(delegation is null ? "no longer delegated" : $"delegated to '{delegation.ServiceName}'")}.");
}
}
private static void ConfigureV2SubnetDelegation(
IResourceBuilder<AzureSubnetResource> subnet,
string delegation)
{
if (subnet.Resource.TryGetLastAnnotation<AzureSubnetServiceDelegationAnnotation>(out var existingDelegation))
{
if (!string.Equals(existingDelegation.ServiceName, delegation, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Subnet '{subnet.Resource.Name}' is delegated to '{existingDelegation.ServiceName}', " +
$"but API Management requires delegation to '{delegation}'.");
}
return;
}
subnet.WithServiceDelegation(delegation);
}
private static void ValidateSubnetOwnership(
AzureApiManagementResource apiManagement,
AzureSubnetResource subnet)
{
var existingUsage = subnet.Annotations
.OfType<AzureApiManagementSubnetUsageAnnotation>()
.FirstOrDefault();
if (existingUsage is not null && !ReferenceEquals(existingUsage.ApiManagement, apiManagement))
{
throw new InvalidOperationException(
$"Subnet '{subnet.Name}' is already used by API Management resource '{existingUsage.ApiManagement.Name}'. " +
"API Management networking requires a dedicated subnet for each service.");
}
}
private static void ConfigureV2SubnetNetworkSecurityGroup(
IResourceBuilder<AzureSubnetResource> subnet)
{
subnet.AllowOutbound(
port: "443",
from: AzureServiceTags.VirtualNetwork,
to: AzureServiceTags.AzureKeyVault,
protocol: SecurityRuleProtocol.Tcp,
priority: GetKeyVaultRulePriority(subnet.Resource),
name: "allow-apim-key-vault");
}
private static int GetKeyVaultRulePriority(AzureSubnetResource subnet)
{
// Both v2 networking modes require APIM's Azure Key Vault dependency. A late allow can be
// shadowed by an outbound deny; conservatively place it ahead of all TCP/443 denies because
// address prefixes may contain service tags or deploy-time expressions.
// See https://learn.microsoft.com/azure/api-management/integrate-vnet-outbound#network-security-group.
var rules = subnet.NetworkSecurityGroup?.SecurityRules;
if (rules?.Any(rule => string.Equals(rule.Name, "allow-apim-key-vault", StringComparison.OrdinalIgnoreCase)) == true)
{
throw new InvalidOperationException(
$"Subnet '{subnet.Name}' already contains the reserved NSG rule 'allow-apim-key-vault'.");
}
var firstDenyPriority = rules?
.Where(CouldDenyKeyVault)
.Select(rule => rule.Priority)
.DefaultIfEmpty(4097)
.Min() ?? 4097;
var priority = Enumerable.Range(100, Math.Clamp(firstDenyPriority - 100, 0, 3997))
.FirstOrDefault(candidate => rules?.Any(rule => rule.Priority == candidate) != true);
if (priority == 0)
{
throw new InvalidOperationException(
$"Subnet '{subnet.Name}' has no free NSG priority before its outbound deny rules. " +
"Reserve an earlier priority for the API Management Key Vault allow rule.");
}
return priority;
}
private static bool CouldDenyKeyVault(AzureSecurityRule rule)
{
if (rule.Direction != SecurityRuleDirection.Outbound ||
rule.Access != SecurityRuleAccess.Deny ||
(rule.Protocol != SecurityRuleProtocol.Tcp && rule.Protocol != SecurityRuleProtocol.Asterisk))
{
return false;
}
if (rule.DestinationPortRange is "*" or "443")
{
return true;
}
// NSG port ranges use "start-end", such as "400-500".
var ports = rule.DestinationPortRange.Split('-', 2);
return ports.Length == 2 &&
int.TryParse(ports[0], out var first) &&
int.TryParse(ports[1], out var last) &&
first <= 443 && last >= 443;
}
private static void ValidateV2SubnetSize(AzureSubnetResource subnet)
{
if (subnet.AddressPrefix is not { } addressPrefix ||
!int.TryParse(addressPrefix[(addressPrefix.LastIndexOf('/') + 1)..], out var prefixLength))
{
return;
}
if (prefixLength > 27)
{
throw new InvalidOperationException(
$"Subnet '{subnet.Name}' uses '{addressPrefix}', but API Management v2 networking requires a /27 or larger subnet.");
}
}
private static bool SetVirtualNetworkConfiguration(
AzureApiManagementResource resource,
AzureSubnetResource subnet,
AzureApiManagementVirtualNetworkMode mode,
AzureApiManagementVirtualNetworkKind kind)
{
if (resource.VirtualNetworkConfiguration is { } existing &&
(!ReferenceEquals(existing.Subnet, subnet) ||
existing.Mode != mode ||
existing.Kind != kind))
{
throw new InvalidOperationException(
$"API Management resource '{resource.Name}' already has a different virtual network configuration.");
}
if (resource.VirtualNetworkConfiguration is not null)
{
return false;
}
resource.VirtualNetworkConfiguration = new(subnet, mode, kind);
return true;
}
private static void ValidateCapacity(AzureApiManagementSku sku, int capacity)
{
var (minimum, maximum) = sku switch
{
AzureApiManagementSku.Consumption => (0, 0),
AzureApiManagementSku.Developer => (1, 1),
AzureApiManagementSku.Basic => (1, 2),
AzureApiManagementSku.BasicV2 => (1, 10),
AzureApiManagementSku.Standard => (1, 4),
AzureApiManagementSku.StandardV2 => (1, 10),
AzureApiManagementSku.Premium => (1, 12),
AzureApiManagementSku.PremiumV2 => (1, 30),
_ => throw new UnreachableException(),
};
if (capacity < minimum || capacity > maximum)
{
throw new ArgumentException(
$"Capacity for SKU '{sku}' must be between {minimum} and {maximum}.",
nameof(capacity));
}
}
private static BicepValue<ApiManagementServiceSkuType> GetProvisioningSku(AzureApiManagementSku sku) =>
sku switch
{
AzureApiManagementSku.Consumption => ApiManagementServiceSkuType.Consumption,
AzureApiManagementSku.Developer => ApiManagementServiceSkuType.Developer,
AzureApiManagementSku.Basic => ApiManagementServiceSkuType.Basic,
AzureApiManagementSku.BasicV2 => ApiManagementServiceSkuType.BasicV2,
AzureApiManagementSku.Standard => ApiManagementServiceSkuType.Standard,
AzureApiManagementSku.StandardV2 => ApiManagementServiceSkuType.StandardV2,
AzureApiManagementSku.Premium => ApiManagementServiceSkuType.Premium,
// The published SDK enum predates PremiumV2; emit the ARM value without duplicating its SKU model.
// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/2025-03-01-preview/service#apimanagementserviceskuproperties
AzureApiManagementSku.PremiumV2 => new StringLiteralExpression("PremiumV2"),
_ => throw new UnreachableException(),
};
}