Skip to content

Add an API Management and Function App sample (gateway, subscription keys, policies, named values - #123

Merged
DrisDary merged 5 commits into
mainfrom
feat/api-management-sample
Sep 22, 2026
Merged

DrisDary merged 5 commits into
mainfrom
feat/api-management-sample

Conversation

@DrisDary

@DrisDary DrisDary commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • 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 DrisDary self-assigned this Sep 18, 2026
@DrisDary
DrisDary force-pushed the feat/api-management-sample branch from 36d927a to d35cb2e Compare September 18, 2026 15:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The query-string subscription key remains exposed to the backend despite the policy’s stated credential-stripping behavior.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds an API Management gateway sample backed by a secured Python Function App.

Changes:

  • Implements subscription-key authorization, policies, named values, CORS, and rate limiting.
  • Adds Azure CLI, Terraform, and Bicep deployment paths with validation.
  • Registers the sample in CI and documentation.
File summaries
File Description
README.md Registers and documents the sample.
run-samples.sh Adds all deployment variants to CI.
samples/api-management-function-app/python/README.md Documents architecture and usage.
apim/openapi.json Defines the Inventory API.
apim/inventory-api-policy.xml Configures gateway policies.
function/function_app.py Implements the secured backend.
function/host.json Configures the Functions host.
function/requirements.txt Adds the Functions dependency.
scripts/deploy.sh Provides Azure CLI deployment.
scripts/validate.sh Validates gateway behavior.
scripts/call-api.sh Provides a smoke test.
scripts/README.md Documents CLI scripts.
terraform/main.tf Defines Terraform resources.
terraform/providers.tf Configures Terraform providers.
terraform/variables.tf Defines Terraform inputs.
terraform/outputs.tf Exposes deployment outputs.
terraform/terraform.tfvars Supplies default values.
terraform/deploy.sh Automates Terraform deployment.
terraform/README.md Documents Terraform usage.
bicep/main.bicep Defines Bicep resources.
bicep/main.bicepparam Supplies Bicep parameters.
bicep/deploy.sh Automates Bicep deployment.
bicep/README.md Documents Bicep usage.
Review details
  • Files reviewed: 23/23 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@paolosalvatori

Copy link
Copy Markdown
Contributor

@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!

@paolosalvatori paolosalvatori left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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/common/ - coding-style.md, hooks.md, patterns.md, security.md, testing.md
  • .claude/rules/azure/python/ - coding-style.md, hooks.md, patterns.md, security.md, testing.md, cloud-pipeline.md
  • .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).

3. Clean files

README.md (root), run-samples.sh, and under samples/api-management-function-app/python/: README.md, apim/inventory-api-policy.xml, apim/openapi.json, bicep/README.md, bicep/deploy.sh, bicep/main.bicepparam, function/host.json, function/requirements.txt, scripts/README.md, scripts/call-api.sh, terraform/README.md, terraform/deploy.sh, terraform/outputs.tf, terraform/providers.tf, terraform/terraform.tfvars, terraform/variables.tf.

4. What I checked and found correct

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").

Comment thread samples/api-management-function-app/python/scripts/validate.sh Outdated
Comment thread samples/api-management-function-app/python/scripts/validate.sh Outdated
Comment thread samples/api-management-function-app/python/scripts/validate.sh Outdated
Comment thread samples/api-management-function-app/python/function/function_app.py Outdated
Comment thread samples/api-management-function-app/python/scripts/deploy.sh Outdated
Comment thread samples/api-management-function-app/python/terraform/main.tf
Comment thread samples/api-management-function-app/python/terraform/main.tf Outdated
Comment thread samples/api-management-function-app/python/bicep/main.bicep
Comment thread samples/api-management-function-app/python/bicep/main.bicep Outdated
@DrisDary

Copy link
Copy Markdown
Contributor Author

@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).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The CLI deployment omits Always On, and Terraform can retain an inherited custom metadata endpoint when targeting Azure.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity · 2 Low severity

Open (3)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Enable Always On for the script-created Function App

samples/​api-management-function-app/​python/​scripts/​deploy.sh:131

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.

Comment thread samples/api-management-function-app/python/terraform/deploy.sh
Comment thread samples/api-management-function-app/python/terraform/README.md Outdated
Comment thread samples/api-management-function-app/python/terraform/providers.tf Outdated

@paolosalvatori paolosalvatori left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks 🙏

This branch was successfully deployed

1 active deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants