You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The emulator's API Management service was re-implemented in localstack-pro#8872, including the
gateway: requests to an instance's hostname are matched to an operation, authorised against a
subscription key, run through the API's policies and forwarded to the backend. Nothing in this
repository exercised any of that. Every other emulated service that customers build on has a sample
here that deploys a realistic application three ways and asserts observable behaviour; API Management
had none, so a regression in the gateway would not have shown up anywhere in the samples CI.
Changes
A new sample, samples/api-management-function-app/python/, puts Azure API Management (Consumption)
in front of an Azure Function App:
The Function App (function/, Python v2 model) serves a small Inventory API and refuses every
request that does not carry a shared X-Backend-Secret header, so the gateway is the only way in.
API Management holds that secret in a secret named value and adds it by policy; clients call
the gateway with a product-scoped subscription key. The API is imported from an OpenAPI
document (apim/openapi.json) with the Function App as its serviceUrl. The API policy
(apim/inventory-api-policy.xml) has cors, rate-limit (10 calls a minute per subscription), set-header policies that inject the secret and name the calling subscription, a pair that strips
the subscription key in both forms a client may send it (the header and the subscription-key
query parameter), and an outbound X-Served-By. Those files are shared by the three deployment
variants.
Three deployments with identical resource names, so one scripts/validate.sh serves all of them: scripts/deploy.sh (idempotent and re-runnable: it re-reads the secret with az apim nv show-secret
and re-applies the policy with If-Match: *), terraform/ (azurerm 5.1.0) and bicep/. All three
upload the shared policy as rawxml, so an expression containing && or < cannot break one
variant alone.
scripts/validate.sh asserts twelve things: direct backend call → 401; the three imported
operations (matched case-insensitively, see below); keyless and wrong-key calls → Azure's two 401
messages; listItems/getItem answered by the function with the outbound header; a backend 404
passing through; whoami showing the injected secret, the caller subscription and neither form of
the subscription key; the gateway's own 404 for an unknown path; and the eleventh call → 429 with Retry-After. scripts/call-api.sh is the smoke test and honours the Retry-After.
Every deploy variant purges a soft-deleted instance of its own name before creating: a deleted
API Management instance keeps its name reserved (on Azure and on the emulator), so without this the
scripts → terraform → bicep sequence against one emulator fails on the second variant.
The backend scheme is an input in Terraform (backend_scheme) and Bicep (backendScheme),
defaulting to the emulator's http, so a real deployment overrides one value instead of editing the
template; Bicep derives the Function App's httpsOnly from it. Both IaC variants set Always On,
as the sibling Function App samples do, because the Functions host on a Dedicated plan idles out
without it.
Registered in run-samples.sh (SAMPLES, TERRAFORM_SAMPLES, BICEP_SAMPLES, and ARM64_SAMPLE_DIRS: the gateway runs inside the emulator and the Function App image is multi-arch)
and in both tables of the root README.
Two things worth knowing, both documented in the sample's README rather than hidden:
operationId casing — the review's HIGH did not reproduce. The finding (and Microsoft's
import-restrictions page) say API Management lower-cases operationId on import. A deployment to
real Azure stored getItem listItems whoAmI, exactly as the emulator does. A probe with five varied
ids, on two independent instances, shows Azure normalises but does not lower-case: Get Items → Get-Items, GET_items! → GET_items, a 97-character id truncated to 76, casing preserved
throughout. validate.sh still matches case-insensitively, because that is correct either way, but
the comment now states what was observed. The emulator is correct on casing; whether it
reproduces the other normalisations is untested.
CORS. LocalStack answers CORS for every hostname it serves, the API Management gateway included,
so the API's cors policy cannot answer a browser preflight there today. validate.sh asserts the
preflight against Azure only, marked with a TODO, and the README points browser apps at EXTRA_CORS_ALLOWED_ORIGINS. Letting the gateway declare self_managed_cors, as Container Apps
ingress and Storage do, is a proposed emulator follow-up.
Tests
Against real Azure (AzureCloud, a Consumption instance per run, each torn down and purged):
all three provisioning paths deploy and validate. Two of them needed fixes to get there, both in this
PR and neither visible on the emulator:
Terraform could not target Azure at all: providers.tf pinned metadata_host to the emulator,
so plan failed with connection refused before creating anything. The target is now selected
through ARM_METADATA_HOSTNAME/ARM_SUBSCRIPTION_ID, which deploy.sh exports only when az account show reports the LocalStack cloud. (This pin is repo-wide house style, so the same
limitation applies to the sibling samples.)
The Bicep script parsed deployment outputs out of the create's stdout, which on Azure carries a
leading Bicep CLI is already installed at ... line; jq rejected it and every name came back
empty. Each output is now read back with az deployment group show. The url-shortener sample has
the same latent pattern.
Three names must be globally unique on Azure (API Management service, storage account, Function
App), so PREFIX/SUFFIX now take an environment override with the defaults unchanged: PREFIX=apimdemo SUFFIX=$RANDOM bash scripts/deploy.sh.
Against the emulator, all three variants run end to end, twice as a full scripts → terraform →
bicep sequence, after every change above: scripts/deploy.sh → validate.sh → call-api.sh, terraform/deploy.sh → ../scripts/validate.sh,
and bicep/deploy.sh → ../scripts/validate.sh. A second scripts/deploy.sh on an existing
deployment takes every "already exists" path and validate.sh passes again.
Static gates: bash -n on all five scripts, terraform fmt -check and terraform validate, az bicep build (the compiled template shows httpsOnly derived from backendScheme, alwaysOn
true and the parameterised serviceUrl), jq and an XML parse of the shared API documents, and ./run-samples.sh --list with the ARM64_SAMPLE_DIRS guard.
Related
localstack/localstack-pro#8872 — the API Management re-implementation this sample exercises, which
is in the published image from 2026.9.0.dev310 onwards.
The localstack-docs API Management page links this sample from its Samples section.
@DrisDary did you try the three provisioning processes (Azure CLI, Bicep, and Terraform) against Azure? We need to make sure that every deployment process works as expected on Azure as well as on the emulator. I read in the **Tests section that you could not try out the sample using the latest version of the Docker image containing the API Management changes. This is why, before creating any sample, I make sure that wait for the successful run of the az_main.yml workflow against the main branch. Not a problem, you can run tests on Monday. When you have finished testing the sample, please ask Claude Code to create the same sample for .NET, just like I did for the Vacation Planner. If it works, please change the main README.md with the reference to the two versions. Have a great weekend and thanks for the sample!
The reason will be displayed to describe this comment to others. Learn more.
Review: API Management and Function App sample
Draft PR, 23 files, +2009/-0. Reviewed file by file against the ingested rules, with every Azure behaviour claim checked against Microsoft Learn, the local azure-cli source and az bicep build rather than from memory. Extra guidance for this run: "make sure this sample is compliant with Azure best practices."
Verdict: Request changes. One HIGH. API Management lower-cases operationId when it imports an OpenAPI document, so validate.sh's case-sensitive check for getItem/listItems/whoAmI fails against real Azure for all three deployment variants. Everything else is MEDIUM or LOW, and the sample is otherwise well built, with an unusually honest README.
Counts: 1 HIGH, 7 MEDIUM, 1 LOW, all posted inline.
1. Rules applied
localstack-pro-azure/CLAUDE.md (read in full, from origin/main)
.claude/rules/azure/scripts/ (for the six *.sh files) - all five files
.claude/rules/azure/bicep/ (for main.bicep, main.bicepparam) - all five files
.claude/rules/azure/terraform/ (for *.tf, *.tfvars) - all five files
Notes on ingestion:
The runbook's mapping table points *.sh/*.bats at .claude/rules/azure/shell/. That directory does not exist; the shell rules live in .claude/rules/azure/scripts/, which is what I read.
The local localstack-pro clone was 2 commits behind origin/main, so every rule file was read from origin/main.
Interpretation applied: every ingested rule is path-scoped to localstack-pro-azure/**. This PR is in a different repository, so I applied the rules as engineering guidance and deferred to this repo's own conventions where they conflict. Three would-be violations were therefore not reported, because they are house style in every existing sample here: missing set -euo pipefail (0 of 8 sibling deploy.sh files have it), the hardcoded metadata_host / all-zeros subscription_id in providers.tf (22 of 24 sibling provider files), and the absent --only-show-errors flags (mixed across siblings).
2. Existing comments
None found. No prior reviews, inline comments or issue comments, from humans or bots (Copilot and claude-code-action included).
Recording these so they are not re-litigated. Each was a plausible defect that the documentation or a local check cleared:
rate-limit in the Consumption tier. Supported. The policy reference lists Consumption as "Yes" for Limit call rate by subscription; it is rate-limit-by-key and quota-by-key that are unavailable there. The sample picked the right policy.
cors placed before <base /> at API scope. Correct, and deliberately so: the docs warn that "you may experience unexpected behavior if the cors policy is not the first policy in the inbound section", and API scope (not product scope) is what makes a header-based subscription key work with CORS.
Keyless CORS preflight expected to return 200. Correct: "Only the cors policy is evaluated on the OPTIONS request during preflight."
The two 401 assertions.validate.sh greps only missing subscription key / invalid subscription key. Microsoft publishes two different tails for the missing-key message ("requests to an API" vs "requests to this API"), so pinning only the stable substring is exactly right.
Bicep child-resource naming.az bicep build compiles partnersProductApi.name: inventoryApi.name to format('{0}/{1}/{2}', apimName, productId, apiId), three segments, correct; output subscriptionName likewise resolves to the short name. No BCP081 warnings, so Microsoft.Storage/storageAccounts@2025-01-01, Microsoft.Web/{serverfarms,sites}@2024-11-01 and Microsoft.ApiManagement/*@2024-05-01 are all valid.
az apim api import --subscription-required exists (_params.py), and apim_api_import forwards protocols=None (custom.py), so the CLI variant gets APIM's own https default and matches the explicit protocols: ['https'] in Bicep and Terraform.
The rate-limit call budget in validate.sh. Roughly four keyed calls precede step 11, whose loop runs RATE_LIMIT_CALLS + 2 = 12 times, so the 429 arrives whether or not the earlier calls have aged out of the 60s sliding window.
Soft-delete purge across variants.run-samples.sh deletes every resource group after each sample, soft-deleting the APIM instance and reserving its name, which is precisely the case each variant's deploy.sh purges before creating. The scripts -> terraform -> bicep sequence holds.
random provider =3.9.0 matches the url-shortener sibling, so the pin resolves.
Secret handling. The generated secret is never echoed; validate.sh prints only the key's length (${#KEY} characters); app settings are written with stdout suppressed.
Storage account hardening. I initially flagged the missing minimumTlsVersion / allowBlobPublicAccess / supportsHttpsTrafficOnly, then withdrew it: Azure Blob Storage stopped supporting TLS 1.0/1.1 on 3 February 2026, and modern ARM API versions already default the other two safely.
5. Service parity gaps (emulator, not this PR)
The sample documents these rather than hiding them, which is the right call. Listing them so they are tracked against the emulator rather than the sample:
CORS. LocalStack answers CORS for every hostname it serves, so a browser preflight never reaches the API's cors policy; an origin outside the allow-list gets a bodiless 403 first. validate.sh asserts the preflight on Azure only and carries a TODO to make it unconditional.
gatewayUrl DNS. The emulator reports Azure's https://<name>.azure-api.net, which only resolves with LocalStack's DNS in front of the machine, so every script substitutes http://<name>.apim.azure.localhost.localstack.cloud:4566.
operationId normalization. Implied by the HIGH finding: if the emulator preserves the OpenAPI casing on import instead of lower-casing it, that is an emulator parity bug worth filing separately. Real APIM lower-cases, replaces non-alphanumeric runs with a single dash, trims dashes, and truncates to 76 characters.
Rate-limit counting. The emulator's counts are exact; Azure documents throttling as approximate ("rate limiting is never completely accurate").
@DrisDary did you try the three provisioning processes (Azure CLI, Bicep, and Terraform) against Azure? We need to make sure that every deployment process works as expected on Azure as well as on the emulator. I read in the **Tests section that you could not try out the sample using the latest version of the Docker image containing the API Management changes. This is why, before creating any sample, I make sure that wait for the successful run of the az_main.yml workflow against the main branch. Not a problem, you can run tests on Monday. When you have finished testing the sample, please ask Claude Code to create the same sample for .NET, just like I did for the Vacation Planner. If it works, please change the main README.md with the reference to the two versions. Have a great weekend and thanks for the sample!
Yes thanks fo this suggestion i deployed it against the real azure and all three work (Azure CLI, Bicep, and Terraform).
The script-created Function App never enables Always On. Because it runs on the B1 Dedicated plan, the Functions host can idle after inactivity, unlike the Terraform and Bicep variants, so later gateway calls can stall on a cold start. Configure --always-on true after creation (and on reruns) before deploying the package.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The emulator's API Management service was re-implemented in localstack-pro#8872, including the
gateway: requests to an instance's hostname are matched to an operation, authorised against a
subscription key, run through the API's policies and forwarded to the backend. Nothing in this
repository exercised any of that. Every other emulated service that customers build on has a sample
here that deploys a realistic application three ways and asserts observable behaviour; API Management
had none, so a regression in the gateway would not have shown up anywhere in the samples CI.
Changes
A new sample,
samples/api-management-function-app/python/, puts Azure API Management (Consumption)in front of an Azure Function App:
function/, Python v2 model) serves a small Inventory API and refuses everyrequest that does not carry a shared
X-Backend-Secretheader, so the gateway is the only way in.the gateway with a product-scoped subscription key. The API is imported from an OpenAPI
document (
apim/openapi.json) with the Function App as itsserviceUrl. The API policy(
apim/inventory-api-policy.xml) hascors,rate-limit(10 calls a minute per subscription),set-headerpolicies that inject the secret and name the calling subscription, a pair that stripsthe subscription key in both forms a client may send it (the header and the
subscription-keyquery parameter), and an outbound
X-Served-By. Those files are shared by the three deploymentvariants.
scripts/validate.shserves all of them:scripts/deploy.sh(idempotent and re-runnable: it re-reads the secret withaz apim nv show-secretand re-applies the policy with
If-Match: *),terraform/(azurerm 5.1.0) andbicep/. All threeupload the shared policy as
rawxml, so an expression containing&&or<cannot break onevariant alone.
scripts/validate.shasserts twelve things: direct backend call → 401; the three importedoperations (matched case-insensitively, see below); keyless and wrong-key calls → Azure's two 401
messages;
listItems/getItemanswered by the function with the outbound header; a backend 404passing through;
whoamishowing the injected secret, the caller subscription and neither form ofthe subscription key; the gateway's own 404 for an unknown path; and the eleventh call → 429 with
Retry-After.scripts/call-api.shis the smoke test and honours theRetry-After.API Management instance keeps its name reserved (on Azure and on the emulator), so without this the
scripts → terraform → bicep sequence against one emulator fails on the second variant.
backend_scheme) and Bicep (backendScheme),defaulting to the emulator's
http, so a real deployment overrides one value instead of editing thetemplate; Bicep derives the Function App's
httpsOnlyfrom it. Both IaC variants set Always On,as the sibling Function App samples do, because the Functions host on a Dedicated plan idles out
without it.
run-samples.sh(SAMPLES,TERRAFORM_SAMPLES,BICEP_SAMPLES, andARM64_SAMPLE_DIRS: the gateway runs inside the emulator and the Function App image is multi-arch)and in both tables of the root README.
Two things worth knowing, both documented in the sample's README rather than hidden:
operationIdcasing — the review's HIGH did not reproduce. The finding (and Microsoft'simport-restrictions page) say API Management lower-cases
operationIdon import. A deployment toreal Azure stored
getItem listItems whoAmI, exactly as the emulator does. A probe with five variedids, on two independent instances, shows Azure normalises but does not lower-case:
Get Items→Get-Items,GET_items!→GET_items, a 97-character id truncated to 76, casing preservedthroughout.
validate.shstill matches case-insensitively, because that is correct either way, butthe comment now states what was observed. The emulator is correct on casing; whether it
reproduces the other normalisations is untested.
so the API's
corspolicy cannot answer a browser preflight there today.validate.shasserts thepreflight against Azure only, marked with a TODO, and the README points browser apps at
EXTRA_CORS_ALLOWED_ORIGINS. Letting the gateway declareself_managed_cors, as Container Appsingress and Storage do, is a proposed emulator follow-up.
Tests
Against real Azure (
AzureCloud, a Consumption instance per run, each torn down and purged):all three provisioning paths deploy and validate. Two of them needed fixes to get there, both in this
PR and neither visible on the emulator:
providers.tfpinnedmetadata_hostto the emulator,so
planfailed withconnection refusedbefore creating anything. The target is now selectedthrough
ARM_METADATA_HOSTNAME/ARM_SUBSCRIPTION_ID, whichdeploy.shexports only whenaz account showreports the LocalStack cloud. (This pin is repo-wide house style, so the samelimitation applies to the sibling samples.)
leading
Bicep CLI is already installed at ...line;jqrejected it and every name came backempty. Each output is now read back with
az deployment group show. The url-shortener sample hasthe same latent pattern.
App), so
PREFIX/SUFFIXnow take an environment override with the defaults unchanged:PREFIX=apimdemo SUFFIX=$RANDOM bash scripts/deploy.sh.Against the emulator, all three variants run end to end, twice as a full scripts → terraform →
bicep sequence, after every change above:
scripts/deploy.sh→validate.sh→call-api.sh,terraform/deploy.sh→../scripts/validate.sh,and
bicep/deploy.sh→../scripts/validate.sh. A secondscripts/deploy.shon an existingdeployment takes every "already exists" path and
validate.shpasses again.Static gates:
bash -non all five scripts,terraform fmt -checkandterraform validate,az bicep build(the compiled template showshttpsOnlyderived frombackendScheme,alwaysOntrue and the parameterised
serviceUrl),jqand an XML parse of the shared API documents, and./run-samples.sh --listwith theARM64_SAMPLE_DIRSguard.Related
is in the published image from
2026.9.0.dev310onwards.localstack-docsAPI Management page links this sample from its Samples section.