diff --git a/README.md b/README.md
index 71db00d..1df6fcb 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ Each sample is a self-contained project with its own README, Azure CLI scripts a
|--------|-------------|
| [Function App and Storage (.NET)](./samples/function-app-storage-http/dotnet/README.md) | A gaming scoreboard built on Azure Functions (isolated worker): HTTP triggers record player scores in Table Storage, publish messages to Queue Storage and write game-session summaries to Blob Storage, all against the emulated storage account. |
| [Function App and Front Door (Python)](./samples/function-app-front-door/python/README.md) | A minimal Python Function App answering `/{name}`, published behind an Azure Front Door (Standard) profile so requests reach the function through the Front Door endpoint; deployable to real Azure or to the emulator. |
+| [API Management and Function App (Python)](./samples/api-management-function-app/python/README.md) | A Python Function App serving a small *Inventory* API, published through an Azure API Management (Consumption) gateway: clients present a product-scoped subscription key, and the API policy injects a shared secret from a secret named value, enforces a rate limit, strips the key and answers CORS preflights before the request reaches the function; deployable with Azure CLI scripts, Terraform and Bicep. |
| [Function App and Managed Identities (Python)](./samples/function-app-managed-identity/python/README.md) | A serverless text processor: an Azure Functions app reads text blobs from an `input` container, converts them to uppercase and writes the result to an `output` container, authenticating to the storage account with a managed identity instead of keys. |
| [Function App and Service Bus (.NET)](./samples/function-app-service-bus/dotnet/README.md) | An Azure Functions app on an App Service plan that exchanges messages through Service Bus queues: an HTTP trigger sends greetings and a queue trigger consumes them, connecting with either a connection string or a managed identity. |
| Web App and CosmosDB for MongoDB API ([Python](./samples/web-app-cosmosdb-mongodb-api/python/README.md), [.NET](./samples/web-app-cosmosdb-mongodb-api/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App with regional VNet integration, storing activities in the `activities` collection of an Azure Cosmos DB for MongoDB account reached through a private endpoint. |
@@ -83,6 +84,7 @@ container images Microsoft publishes for `amd64` alone, so there is no `arm64` i
| Sample | Native amd64 | Native arm64 | Backing image |
| --- | :---: | :---: | --- |
| `function-app-*` | ✅ | ✅ | built from a multi-arch `python` / `node` / `dotnet` base |
+| `api-management-function-app` | ✅ | ✅ | the API Management gateway runs inside the emulator; its Function App backend is built from the multi-arch `python` base |
| `web-app-custom-image` | ✅ | ✅ | the image the sample builds itself |
| `aci-blob-storage` | ✅ | ✅ | the image the sample builds itself |
| `container-apps-blob-storage` | ✅ | ✅ | the image the sample builds itself |
diff --git a/run-samples.sh b/run-samples.sh
index 62c9f25..f966a7c 100755
--- a/run-samples.sh
+++ b/run-samples.sh
@@ -33,6 +33,7 @@ SAMPLES=(
"samples/eventhubs/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/run-pipeline.sh"
"samples/eventhubs-eventgrid/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/run-pipeline.sh"
"samples/function-app-front-door/python|bash scripts/deploy_all.sh --name-prefix testafd|"
+ "samples/api-management-function-app/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-api.sh"
"samples/function-app-managed-identity/python|bash scripts/user-managed-identity.sh|bash scripts/validate.sh && bash scripts/test.sh"
"samples/function-app-service-bus/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-http-trigger.sh"
"samples/function-app-storage-http/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-http-triggers.sh"
@@ -61,6 +62,7 @@ TERRAFORM_SAMPLES=(
"samples/servicebus/java/terraform|bash deploy.sh"
"samples/eventhubs/python/terraform|bash deploy.sh|bash ../scripts/validate.sh"
"samples/eventhubs-eventgrid/python/terraform|bash deploy.sh|bash ../scripts/validate.sh"
+ "samples/api-management-function-app/python/terraform|bash deploy.sh|bash ../scripts/validate.sh"
"samples/function-app-managed-identity/python/terraform|bash deploy.sh"
"samples/function-app-service-bus/dotnet/terraform|bash deploy.sh"
"samples/function-app-storage-http/dotnet/terraform|bash deploy.sh"
@@ -86,6 +88,7 @@ BICEP_SAMPLES=(
"samples/servicebus/java/bicep|bash deploy.sh"
"samples/eventhubs/python/bicep|bash deploy.sh|bash ../scripts/validate.sh"
"samples/eventhubs-eventgrid/python/bicep|bash deploy.sh|bash ../scripts/validate.sh"
+ "samples/api-management-function-app/python/bicep|bash deploy.sh|bash ../scripts/validate.sh"
#"samples/web-app-sql-database/python/bicep|bash deploy.sh"
"samples/function-app-managed-identity/python/bicep|bash deploy.sh"
"samples/function-app-service-bus/dotnet/bicep|bash deploy.sh"
@@ -138,6 +141,7 @@ TOTAL=${#ALL_SAMPLES[@]}
# test_deploy_zip_without_basic_auth) are still marked @only_on_amd64.
ARM64_SAMPLE_DIRS=(
"samples/aci-blob-storage/python"
+ "samples/api-management-function-app/python"
"samples/container-apps-blob-storage/python"
"samples/function-app-front-door/python"
"samples/function-app-managed-identity/python"
diff --git a/samples/api-management-function-app/python/README.md b/samples/api-management-function-app/python/README.md
new file mode 100644
index 0000000..af7f098
--- /dev/null
+++ b/samples/api-management-function-app/python/README.md
@@ -0,0 +1,198 @@
+# API Management and Function App: an Azure Function published through an API gateway
+
+This sample demonstrates [Azure API Management](https://learn.microsoft.com/en-us/azure/api-management/api-management-key-concepts) in front of an [Azure Function App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview). The Function App serves a small *Inventory* API, but answers nothing unless the request carries a shared secret, and the only party holding that secret is the API Management gateway: it keeps it in a secret [named value](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-properties) and adds it to every backend call through a [policy](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-policies). Clients never see the backend. They call the gateway with a subscription key, and the gateway authorises the call, enforces a rate limit, answers browser preflights, and forwards what remains to the function.
+
+The sample exercises both halves of API Management on the LocalStack Azure emulator: the control plane (service instance, OpenAPI import, product, subscription, named value, policy) and the gateway (subscription-key authorisation, policy execution, rate limiting, CORS).
+
+## Architecture
+
+The solution is composed of the following Azure resources:
+
+1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): A logical container scoping all resources in this sample.
+2. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): The Function App's runtime storage (`AzureWebJobsStorage`).
+3. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans) (Linux, B1): Hosts the Function App.
+4. [Azure Function App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview) (Python v2 model): The *Inventory* backend, with three HTTP-triggered routes: `GET /api/items`, `GET /api/items/{id}` and `GET /api/whoami`. Every route refuses a request that does not carry a valid `X-Backend-Secret` header, so the Function App can only be reached through the gateway.
+5. [Azure API Management](https://learn.microsoft.com/en-us/azure/api-management/api-management-key-concepts) (Consumption tier):
+ - The **Inventory API**, imported from [`apim/openapi.json`](./apim/openapi.json) and published under `/inventory`, with the Function App as its backend (`serviceUrl`).
+ - The **backend-secret** [named value](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-properties), marked secret, holding the shared secret.
+ - The [API policy](./apim/inventory-api-policy.xml): a [`cors`](https://learn.microsoft.com/en-us/azure/api-management/cors-policy) policy that answers preflights at the gateway, a [`rate-limit`](https://learn.microsoft.com/en-us/azure/api-management/rate-limit-policy) of ten calls a minute per subscription, [`set-header`](https://learn.microsoft.com/en-us/azure/api-management/set-header-policy) policies that inject the secret from the named value, tell the backend which subscription is calling (`@(context.Subscription.Id)`) and strip the subscription key before the request leaves the gateway, and an outbound header marking responses that came through it.
+ - The **Inventory Partners** [product](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-add-products) containing the API, and the **partner-subscription** [subscription](https://learn.microsoft.com/en-us/azure/api-management/api-management-subscriptions) whose key clients present.
+
+```mermaid
+%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 70}}}%%
+flowchart LR
+ client((Client))
+
+ subgraph apim["API Management (Consumption)"]
+ direction TB
+ keycheck["Subscription key check
Inventory Partners product"]
+ policy["Inventory API policy
cors · rate-limit · set-header"]
+ nv["Named value
backend-secret (secret)"]
+ keycheck --> policy
+ nv -.->|"substituted into the policy"| policy
+ end
+
+ subgraph functionapp["Function App (Python)"]
+ routes["GET /api/items
GET /api/items/{id}
GET /api/whoami"]
+ end
+
+ client -->|"1: GET /inventory/items
Ocp-Apim-Subscription-Key"| keycheck
+ policy -->|"2: + X-Backend-Secret
+ X-Caller-Subscription
- Ocp-Apim-Subscription-Key"| routes
+ routes -->|"3: 200 + items"| policy
+ policy -->|"4: + X-Served-By"| client
+ client -. "direct call without the secret: 401" .-> routes
+
+ style apim fill:#ffffff,stroke:#999999,color:#333333
+ style functionapp fill:#ffffff,stroke:#999999,color:#333333
+```
+
+The life of a request: the client calls `GET /inventory/items` on the gateway with an `Ocp-Apim-Subscription-Key` header → the gateway checks the key against the product's subscriptions → the API policy runs → the request is forwarded to the Function App's `/api/items` with `X-Backend-Secret` and `X-Caller-Subscription` added and the subscription key removed → the Function App verifies the secret and answers → the gateway adds `X-Served-By` and returns the response. A call without a key, with a wrong key, or beyond ten calls a minute never reaches the function.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/)
+- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
+- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
+- [jq](https://jqlang.org/), `zip` and `openssl`
+- [Terraform](https://developer.hashicorp.com/terraform/downloads) (for the Terraform deployment)
+- [Bicep](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install) (for the Bicep deployment)
+- A LocalStack account with a valid `LOCALSTACK_AUTH_TOKEN` (see the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/))
+
+## Setup
+
+Start the LocalStack Azure emulator and route the Azure CLI to it:
+
+```bash
+export LOCALSTACK_AUTH_TOKEN=
+IMAGE_NAME=localstack/localstack-azure localstack start -d
+localstack wait -t 60
+lstk az start-interception
+az login --service-principal -u any-app -p any-pass --tenant any-tenant
+```
+
+## Deployment
+
+### Azure CLI scripts
+
+```bash
+bash scripts/deploy.sh
+```
+
+The script provisions all resources idempotently: it creates the Function App and deploys it from a zip package, creates the API Management instance, stores the generated shared secret both as the Function App's `BACKEND_SECRET` setting and as the secret named value, imports the API from the OpenAPI document with the Function App as its backend, applies the policy, and creates the product and the subscription. It ends by printing the gateway URL and the command that reads the subscription key. A re-run reuses the stored secret, so the gateway and the Function App stay in agreement.
+
+### Terraform
+
+```bash
+cd terraform
+bash deploy.sh
+```
+
+The Terraform variant provisions the same resources declaratively and then deploys the function from a zip package with the Azure CLI. The API is imported from the same `apim/openapi.json`, and the policy is read from the same `apim/inventory-api-policy.xml`.
+
+### Bicep
+
+```bash
+cd bicep
+bash deploy.sh
+```
+
+The Bicep variant validates and deploys `main.bicep` into the resource group (generating the shared secret per run) and then deploys the function from a zip package with the Azure CLI. It shares the OpenAPI document and the policy with the other two variants through `loadTextContent`.
+
+## Testing
+
+```bash
+bash scripts/validate.sh
+bash scripts/call-api.sh
+```
+
+`validate.sh` walks the whole chain and exits non-zero on any failure:
+
+1. The Function App refuses a direct call without the shared secret (401): the gateway is the only way in.
+2. The OpenAPI import produced the three operations.
+3. A keyless call is refused with Azure's *missing subscription key* message, and a wrong key with its *invalid subscription key* message.
+4. With the subscription key, `listItems` and `getItem` are authorised, matched (including the `{id}` template parameter) and answered by the function; a 404 from the backend passes through untouched; every response carries the outbound `X-Served-By` header.
+5. `whoAmI` shows what the backend received: the injected secret, the calling subscription in `X-Caller-Subscription`, and no `Ocp-Apim-Subscription-Key`.
+6. A CORS preflight is answered by the gateway itself, from the `cors` policy (asserted on Azure only; see the LocalStack notes).
+7. A path that matches no operation gets the gateway's own 404.
+8. The eleventh call within a minute is refused with a 429 and a `Retry-After` header.
+
+`call-api.sh` is the user-level smoke test: it reads the key, lists the items and reads one of them. Run right after `validate.sh` it may be told to wait: the rate limit is still in force for the rest of the minute, and the script honours the `Retry-After` the gateway sends.
+
+### Calling the API by hand
+
+```bash
+APIM_ID=$(az apim show --name local-inventory-apim-test --resource-group local-rg --query id --output tsv)
+KEY=$(az rest --method post \
+ --url "$APIM_ID/subscriptions/partner-subscription/listSecrets?api-version=2022-08-01" \
+ --query primaryKey --output tsv)
+GATEWAY=http://local-inventory-apim-test.apim.azure.localhost.localstack.cloud:4566
+
+# Refused by the gateway
+curl -s "$GATEWAY/inventory/items"
+```
+
+```json
+{"statusCode": 401, "message": "Access denied due to missing subscription key. Make sure to include subscription key when making requests to an API."}
+```
+
+```bash
+# Forwarded to the Function App
+curl -s -H "Ocp-Apim-Subscription-Key: $KEY" "$GATEWAY/inventory/items/2"
+```
+
+```json
+{"id": 2, "sku": "APIM-002", "name": "Gateway sticker pack", "quantity": 500}
+```
+
+```bash
+# What the backend received: the caller's subscription, no subscription key
+curl -s -H "Ocp-Apim-Subscription-Key: $KEY" "$GATEWAY/inventory/whoami" | jq .headers
+```
+
+```json
+{
+ "x-caller-subscription": "partner-subscription",
+ "accept": "*/*",
+ "user-agent": "curl/7.81.0",
+ "host": "local-inventory-functionapp-test.azurewebsites.azure.localhost.localstack.cloud:4566"
+}
+```
+
+The eleventh call within a minute is refused before it reaches the function:
+
+```json
+{"statusCode": 429, "message": "Rate limit is exceeded. Try again in 59 seconds."}
+```
+
+The key can also be passed as the `subscription-key` query parameter.
+
+## Cleanup
+
+```bash
+az group delete --name local-rg --yes
+```
+
+Deleting an API Management instance soft-deletes it: the name stays reserved until the instance is purged or the retention period ends. To free the name straight away:
+
+```bash
+az apim deletedservice purge --service-name local-inventory-apim-test --location westeurope
+```
+
+## LocalStack notes
+
+- **Gateway address.** API Management reports Azure's gateway address, `https://.azure-api.net`, in `gatewayUrl`. The emulator claims that name too, but it only resolves once LocalStack's DNS is in front of the machine, so the scripts call the gateway through its local alias, `http://.apim.azure.localhost.localstack.cloud:4566`, whenever the Azure CLI is pointed at the emulator (`az account show --query environmentName` is `LocalStack`). On Azure they use `gatewayUrl`.
+- **Backend over plain HTTP.** The emulator serves the Function App under its own hostname (the `defaultHostName` it reports) over HTTP, so the API's `serviceUrl` is `http:///api` there and `https://...` on Azure. The Azure CLI scripts pick the scheme from the environment; the Terraform and Bicep variants target the emulator and hardcode `http://`, so switch them to `https://` for a real deployment.
+- **Shared secret rather than a function key.** On Azure the usual way to lock a Function App to its gateway is the function's host key, injected the same way (an `x-functions-key` header from a secret named value). This sample has the function check a secret of its own instead, so the same code, policy and deployment run unchanged on the emulator and on Azure without listing host keys.
+- **CORS is answered by the emulator, not by the policy.** LocalStack enforces CORS for every hostname it serves, the API Management gateway included: a browser origin outside its allow-list gets a bodiless 403 before the gateway sees the request, and an allowed origin gets the emulator's own preflight answer and response headers rather than those of the API's `cors` policy. To call the emulated gateway from a browser app, allow its origin with `EXTRA_CORS_ALLOWED_ORIGINS=http://localhost:3000` (or `DISABLE_CORS_CHECKS=1`) when starting LocalStack. The `cors` policy in this sample is what answers preflights on Azure, and `validate.sh` asserts it there only.
+- **Consumption tier.** It provisions in minutes on Azure and has no per-instance health probe; the emulator reproduces both. The rate limit is enforced per subscription and the counts are exact on the emulator, while Azure documents them as approximate, so `validate.sh` keeps calling until it sees the 429 rather than asserting the exact call at which it happens.
+- **Updating API Management entities needs an `If-Match` header**, which is why `scripts/deploy.sh` only applies the policy with `If-Match: *` when it already exists and skips entities that are already there.
+
+## References
+
+- [Azure API Management Documentation](https://learn.microsoft.com/en-us/azure/api-management/)
+- [API Management policy reference](https://learn.microsoft.com/en-us/azure/api-management/api-management-policies)
+- [Azure Functions Documentation](https://learn.microsoft.com/en-us/azure/azure-functions/)
+- [Import an Azure Function App as an API](https://learn.microsoft.com/en-us/azure/api-management/import-function-app-as-api)
+- [LocalStack for Azure](https://docs.localstack.cloud/azure/)
+- [LocalStack for Azure: API Management](https://docs.localstack.cloud/azure/services/api-management/)
+- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
diff --git a/samples/api-management-function-app/python/apim/inventory-api-policy.xml b/samples/api-management-function-app/python/apim/inventory-api-policy.xml
new file mode 100644
index 0000000..0f4c9c8
--- /dev/null
+++ b/samples/api-management-function-app/python/apim/inventory-api-policy.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ *
+
+
+ GET
+ OPTIONS
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{backend-secret}}
+
+
+
+ @(context.Subscription.Id)
+
+
+
+
+
+
+
+
+
+ Azure API Management
+
+
+
+
+
+
diff --git a/samples/api-management-function-app/python/apim/openapi.json b/samples/api-management-function-app/python/apim/openapi.json
new file mode 100644
index 0000000..5549dda
--- /dev/null
+++ b/samples/api-management-function-app/python/apim/openapi.json
@@ -0,0 +1,89 @@
+{
+ "openapi": "3.0.1",
+ "info": {
+ "title": "Inventory API",
+ "description": "Stock levels served by an Azure Function App and published through Azure API Management.",
+ "version": "1.0"
+ },
+ "paths": {
+ "/items": {
+ "get": {
+ "operationId": "listItems",
+ "summary": "List the items in stock",
+ "responses": {
+ "200": {
+ "description": "The items in stock",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ItemList" }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/items/{id}": {
+ "get": {
+ "operationId": "getItem",
+ "summary": "Read one item",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The numeric id of the item",
+ "schema": { "type": "integer" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The item",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Item" }
+ }
+ }
+ },
+ "404": {
+ "description": "No item has that id"
+ }
+ }
+ }
+ },
+ "/whoami": {
+ "get": {
+ "operationId": "whoAmI",
+ "summary": "Echo the request as the backend received it",
+ "description": "Shows the headers the gateway's policies added and removed on the way to the backend.",
+ "responses": {
+ "200": {
+ "description": "The request as the backend saw it"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "Item": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "sku": { "type": "string" },
+ "name": { "type": "string" },
+ "quantity": { "type": "integer" }
+ }
+ },
+ "ItemList": {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/Item" }
+ },
+ "count": { "type": "integer" }
+ }
+ }
+ }
+ }
+}
diff --git a/samples/api-management-function-app/python/bicep/README.md b/samples/api-management-function-app/python/bicep/README.md
new file mode 100644
index 0000000..4fb5dd9
--- /dev/null
+++ b/samples/api-management-function-app/python/bicep/README.md
@@ -0,0 +1,25 @@
+# Bicep Deployment
+
+This directory contains the Bicep template for the sample. For details about the sample application, see [API Management and Function App](../README.md).
+
+## Prerequisites
+
+- [LocalStack for Azure](https://docs.localstack.cloud/azure/)
+- [Docker](https://docs.docker.com/get-docker/)
+- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) with [Bicep](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install)
+- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
+- [jq](https://jqlang.org/), `zip` and `openssl`
+
+## Deployment
+
+```bash
+bash deploy.sh
+```
+
+The script creates the resource group, generates the shared secret (read by `main.bicepparam` from the environment), validates and deploys `main.bicep`, and then deploys the function from a zip package with the Azure CLI. The template imports the API from `../apim/openapi.json` and reads the policy from `../apim/inventory-api-policy.xml` through `loadTextContent`, so all three deployment variants publish the same API with the same policy.
+
+## Cleanup
+
+```bash
+az group delete --name local-rg --yes
+```
diff --git a/samples/api-management-function-app/python/bicep/deploy.sh b/samples/api-management-function-app/python/bicep/deploy.sh
new file mode 100755
index 0000000..c80d16f
--- /dev/null
+++ b/samples/api-management-function-app/python/bicep/deploy.sh
@@ -0,0 +1,148 @@
+#!/bin/bash
+
+# Variables
+PREFIX='local'
+SUFFIX='test'
+LOCATION='westeurope'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+DEPLOYMENT_NAME='api-management-function-app'
+TEMPLATE="main.bicep"
+PARAMETERS="main.bicepparam"
+APIM_API_VERSION='2022-08-01'
+FUNCTION_ZIPFILE='inventory_function.zip'
+CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Change the current directory to the script's directory
+cd "$CURRENT_DIR" || exit
+
+# Get the current subscription
+SUBSCRIPTION_NAME=$(az account show --query name --output tsv)
+
+# Check if the resource group already exists
+echo "Checking if [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription..."
+az group show --name $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription"
+ echo "Creating [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription..."
+
+ az group create --name $RESOURCE_GROUP_NAME --location "$LOCATION" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$RESOURCE_GROUP_NAME] resource group successfully created in the [$SUBSCRIPTION_NAME] subscription"
+ else
+ echo "Failed to create [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription"
+ exit 1
+ fi
+else
+ echo "[$RESOURCE_GROUP_NAME] resource group already exists in the [$SUBSCRIPTION_NAME] subscription"
+fi
+
+# A deleted API Management instance is soft-deleted and keeps its name reserved until it is
+# purged, so an earlier run's instance (the scripts variant's, say) would make the deployment
+# fail with a conflict. Purge it first, unless the instance is live and is simply being updated.
+APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}"
+az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ az apim deletedservice show --service-name $APIM_NAME --location "$LOCATION" &>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "Purging the soft-deleted [$APIM_NAME] API Management service that still holds the name..."
+ az apim deletedservice purge --service-name $APIM_NAME --location "$LOCATION" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "Soft-deleted [$APIM_NAME] API Management service successfully purged"
+ else
+ echo "Failed to purge the soft-deleted [$APIM_NAME] API Management service"
+ exit 1
+ fi
+ fi
+fi
+
+# Generate the shared secret the gateway adds to every backend call; main.bicepparam reads it
+# from the environment, and the template stores it both as the Function App's setting and as the
+# secret named value the policy references.
+export BACKEND_SECRET=$(openssl rand -hex 16)
+
+# Validate the Bicep template
+echo "Validating the [$TEMPLATE] Bicep template..."
+az deployment group validate \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --template-file $TEMPLATE \
+ --parameters $PARAMETERS \
+ --output none
+
+if [[ $? == 0 ]]; then
+ echo "[$TEMPLATE] Bicep template successfully validated"
+else
+ echo "Failed to validate the [$TEMPLATE] Bicep template"
+ exit 1
+fi
+
+# Deploy the Bicep template
+echo "Deploying the [$TEMPLATE] Bicep template..."
+DEPLOYMENT_OUTPUTS=$(az deployment group create \
+ --name $DEPLOYMENT_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --template-file $TEMPLATE \
+ --parameters $PARAMETERS \
+ --query properties.outputs)
+
+if [[ $? == 0 ]]; then
+ echo "[$TEMPLATE] Bicep template successfully deployed"
+else
+ echo "Failed to deploy the [$TEMPLATE] Bicep template"
+ exit 1
+fi
+
+FUNCTION_APP_NAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .functionAppName.value)
+APIM_NAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .apimName.value)
+API_PATH=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .apiPath.value)
+APIM_SUBSCRIPTION_ID=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .subscriptionName.value)
+GATEWAY_URL=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .gatewayUrl.value)
+
+if [[ -z "$FUNCTION_APP_NAME" || -z "$APIM_NAME" ]]; then
+ echo "Function App Name or API Management Name is empty. Exiting."
+ exit 1
+fi
+
+# Create the zip package of the function app
+cd "$CURRENT_DIR/../function" || exit
+if [ -f "$FUNCTION_ZIPFILE" ]; then
+ rm "$FUNCTION_ZIPFILE"
+fi
+echo "Creating zip package of the function app..."
+zip -r "$FUNCTION_ZIPFILE" function_app.py host.json requirements.txt
+
+# Deploy the function app
+echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$FUNCTION_ZIPFILE]..."
+az functionapp deploy \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --name "$FUNCTION_APP_NAME" \
+ --src-path "$FUNCTION_ZIPFILE" \
+ --type zip \
+ --async true 1>/dev/null
+
+if [[ $? == 0 ]]; then
+ echo "Function app [$FUNCTION_APP_NAME] deployed successfully"
+else
+ echo "Failed to deploy function app [$FUNCTION_APP_NAME]"
+ exit 1
+fi
+rm -f "$FUNCTION_ZIPFILE"
+
+# Where the gateway answers. The emulator also reports Azure's *.azure-api.net address in
+# gatewayUrl, but that name only resolves once LocalStack's DNS is in front of the machine, so
+# the local alias is printed instead.
+ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ GATEWAY_URL="http://${APIM_NAME}.apim.azure.localhost.localstack.cloud:4566"
+fi
+APIM_ID=$(az apim show --name "$APIM_NAME" --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
+
+echo "Deployment completed. The Inventory API is available at: $GATEWAY_URL/$API_PATH"
+echo "Read the subscription key with:"
+echo " az rest --method post --url \"$APIM_ID/subscriptions/$APIM_SUBSCRIPTION_ID/listSecrets?api-version=$APIM_API_VERSION\" --query primaryKey --output tsv"
+echo "Then call the API with:"
+echo " curl -H \"Ocp-Apim-Subscription-Key: \" \"$GATEWAY_URL/$API_PATH/items\""
diff --git a/samples/api-management-function-app/python/bicep/main.bicep b/samples/api-management-function-app/python/bicep/main.bicep
new file mode 100644
index 0000000..7ea6dcc
--- /dev/null
+++ b/samples/api-management-function-app/python/bicep/main.bicep
@@ -0,0 +1,206 @@
+//********************************************
+// Parameters
+//********************************************
+
+@description('Prefix applied to every resource name in this sample.')
+param prefix string = 'local'
+
+@description('Suffix applied to every resource name in this sample.')
+param suffix string = 'test'
+
+@description('Location for all resources.')
+param location string = resourceGroup().location
+
+@description('Language runtime of the Function App.')
+param runtimeName string = 'python'
+
+@description('Runtime version of the Function App.')
+param runtimeVersion string = '3.11'
+
+@description('SKU of the Linux App Service plan hosting the Function App.')
+param appServicePlanSku string = 'B1'
+
+@description('SKU of the API Management service. Consumption provisions in minutes; the classic tiers take much longer.')
+param apimSkuName string = 'Consumption'
+
+@description('Name of the organisation publishing the APIs.')
+param publisherName string = 'LocalStack'
+
+@description('E-mail address API Management sends its notifications to.')
+param publisherEmail string = 'noreply@localstack.cloud'
+
+@description('Shared secret the gateway adds to every backend call; generated per run by deploy.sh.')
+@secure()
+param backendSecret string
+
+//********************************************
+// Variables
+//********************************************
+
+var storageAccountName = '${prefix}invstorage${suffix}'
+var appServicePlanName = '${prefix}-inventory-app-service-plan-${suffix}'
+var functionAppName = '${prefix}-inventory-functionapp-${suffix}'
+var apimName = '${prefix}-inventory-apim-${suffix}'
+var apiId = 'inventory-api'
+var apiPath = 'inventory'
+var productId = 'inventory-partners'
+var apimSubscriptionId = 'partner-subscription'
+var namedValueId = 'backend-secret'
+// Consumption has no capacity units; every other tier starts at one.
+var apimSkuCapacity = apimSkuName == 'Consumption' ? 0 : 1
+
+//********************************************
+// Function App: the Inventory backend
+//********************************************
+
+resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = {
+ name: storageAccountName
+ location: location
+ sku: {
+ name: 'Standard_LRS'
+ }
+ kind: 'StorageV2'
+}
+
+resource appServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = {
+ name: appServicePlanName
+ location: location
+ kind: 'linux'
+ sku: {
+ name: appServicePlanSku
+ }
+ properties: {
+ reserved: true
+ }
+}
+
+var storageAccountKey = storageAccount.listKeys().keys[0].value
+var storageEndpoints = storageAccount.properties.primaryEndpoints
+
+resource functionApp 'Microsoft.Web/sites@2024-11-01' = {
+ name: functionAppName
+ location: location
+ kind: 'functionapp,linux'
+ properties: {
+ httpsOnly: false
+ reserved: true
+ serverFarmId: appServicePlan.id
+ siteConfig: {
+ linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}')
+ appSettings: [
+ { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
+ { name: 'FUNCTIONS_WORKER_RUNTIME', value: runtimeName }
+ { name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' }
+ { name: 'ENABLE_ORYX_BUILD', value: 'true' }
+ // Explicit endpoints rather than an EndpointSuffix: the Functions host's storage clients
+ // cannot parse a suffix that carries the emulator's port.
+ { name: 'AzureWebJobsStorage', value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${storageAccountKey};BlobEndpoint=${storageEndpoints.blob};QueueEndpoint=${storageEndpoints.queue};TableEndpoint=${storageEndpoints.table}' }
+ // The same secret the gateway injects from its named value.
+ { name: 'BACKEND_SECRET', value: backendSecret }
+ ]
+ }
+ }
+}
+
+//********************************************
+// API Management
+//********************************************
+
+resource apim 'Microsoft.ApiManagement/service@2024-05-01' = {
+ name: apimName
+ location: location
+ sku: {
+ name: apimSkuName
+ capacity: apimSkuCapacity
+ }
+ properties: {
+ publisherName: publisherName
+ publisherEmail: publisherEmail
+ }
+}
+
+// The shared secret lives in a secret named value; the policy refers to it as {{backend-secret}}.
+resource backendSecretNamedValue 'Microsoft.ApiManagement/service/namedValues@2024-05-01' = {
+ parent: apim
+ name: namedValueId
+ properties: {
+ displayName: namedValueId
+ secret: true
+ value: backendSecret
+ }
+}
+
+// Imported from the OpenAPI document shared with the other deployment variants, so its operations
+// come from the document. The backend is the Function App: the emulator answers on plain HTTP under
+// its own hostname (use https:// on real Azure).
+resource inventoryApi 'Microsoft.ApiManagement/service/apis@2024-05-01' = {
+ parent: apim
+ name: apiId
+ properties: {
+ displayName: 'Inventory API'
+ description: 'Stock levels served by an Azure Function App and published through Azure API Management.'
+ path: apiPath
+ protocols: [
+ 'https'
+ ]
+ subscriptionRequired: true
+ serviceUrl: 'http://${functionApp.properties.defaultHostName}/api'
+ format: 'openapi+json'
+ value: loadTextContent('../apim/openapi.json')
+ }
+}
+
+// Policies are validated when they are saved, and this one references the named value, so the
+// named value has to exist first.
+resource inventoryApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2024-05-01' = {
+ parent: inventoryApi
+ name: 'policy'
+ properties: {
+ format: 'rawxml'
+ value: loadTextContent('../apim/inventory-api-policy.xml')
+ }
+ dependsOn: [
+ backendSecretNamedValue
+ ]
+}
+
+resource partnersProduct 'Microsoft.ApiManagement/service/products@2024-05-01' = {
+ parent: apim
+ name: productId
+ properties: {
+ displayName: 'Inventory Partners'
+ description: 'Partners reading stock levels through the Inventory API'
+ subscriptionRequired: true
+ approvalRequired: false
+ state: 'published'
+ }
+}
+
+resource partnersProductApi 'Microsoft.ApiManagement/service/products/apis@2024-05-01' = {
+ parent: partnersProduct
+ name: inventoryApi.name
+}
+
+// The subscription's key is what clients present. Its scope is the product, so the key opens every
+// API the product contains and nothing else.
+resource partnerSubscription 'Microsoft.ApiManagement/service/subscriptions@2024-05-01' = {
+ parent: apim
+ name: apimSubscriptionId
+ properties: {
+ displayName: 'Partner subscription'
+ scope: partnersProduct.id
+ state: 'active'
+ }
+}
+
+//********************************************
+// Outputs
+//********************************************
+
+output resourceGroupName string = resourceGroup().name
+output functionAppName string = functionApp.name
+output functionAppHostName string = functionApp.properties.defaultHostName
+output apimName string = apim.name
+output gatewayUrl string = apim.properties.gatewayUrl
+output apiPath string = apiPath
+output subscriptionName string = partnerSubscription.name
diff --git a/samples/api-management-function-app/python/bicep/main.bicepparam b/samples/api-management-function-app/python/bicep/main.bicepparam
new file mode 100644
index 0000000..8152cf4
--- /dev/null
+++ b/samples/api-management-function-app/python/bicep/main.bicepparam
@@ -0,0 +1,7 @@
+using 'main.bicep'
+
+param prefix = 'local'
+param suffix = 'test'
+
+// The shared secret is generated per run by deploy.sh and passed via the environment.
+param backendSecret = readEnvironmentVariable('BACKEND_SECRET')
diff --git a/samples/api-management-function-app/python/function/function_app.py b/samples/api-management-function-app/python/function/function_app.py
new file mode 100644
index 0000000..6aafb25
--- /dev/null
+++ b/samples/api-management-function-app/python/function/function_app.py
@@ -0,0 +1,81 @@
+import hmac
+import json
+import os
+
+import azure.functions as func
+
+app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
+
+# The header API Management adds from its secret named value. Nothing is answered without it, so
+# the only way to this backend is through the gateway.
+SECRET_HEADER = "X-Backend-Secret"
+
+ITEMS = [
+ {"id": 1, "sku": "APIM-001", "name": "Developer portal mug", "quantity": 42},
+ {"id": 2, "sku": "APIM-002", "name": "Gateway sticker pack", "quantity": 500},
+ {"id": 3, "sku": "APIM-003", "name": "Policy expression poster", "quantity": 7},
+]
+
+
+def json_response(body, status_code=200):
+ return func.HttpResponse(
+ json.dumps(body, indent=2), status_code=status_code, mimetype="application/json"
+ )
+
+
+def reject_without_secret(req):
+ """401 unless the request carries the shared secret the gateway injects."""
+ expected = os.environ.get("BACKEND_SECRET", "")
+ supplied = req.headers.get(SECRET_HEADER, "")
+ if expected and hmac.compare_digest(supplied, expected):
+ return None
+ return json_response(
+ {
+ "error": f"Missing or invalid {SECRET_HEADER} header. "
+ "Call this API through API Management."
+ },
+ status_code=401,
+ )
+
+
+@app.route(route="items", methods=["GET"])
+def list_items(req: func.HttpRequest) -> func.HttpResponse:
+ rejected = reject_without_secret(req)
+ if rejected is not None:
+ return rejected
+ return json_response({"items": ITEMS, "count": len(ITEMS)})
+
+
+@app.route(route="items/{id}", methods=["GET"])
+def get_item(req: func.HttpRequest) -> func.HttpResponse:
+ rejected = reject_without_secret(req)
+ if rejected is not None:
+ return rejected
+ wanted = req.route_params.get("id")
+ for item in ITEMS:
+ if str(item["id"]) == wanted:
+ return json_response(item)
+ return json_response({"error": f"No item with id {wanted}"}, status_code=404)
+
+
+@app.route(route="whoami", methods=["GET"])
+def whoami(req: func.HttpRequest) -> func.HttpResponse:
+ """Echo the request as the backend received it, so the gateway's policies can be seen at work."""
+ rejected = reject_without_secret(req)
+ if rejected is not None:
+ return rejected
+ headers = {
+ name.lower(): value
+ for name, value in req.headers.items()
+ # The secret itself is never echoed back.
+ if name.lower() != SECRET_HEADER.lower()
+ }
+ return json_response(
+ {
+ "method": req.method,
+ "url": req.url,
+ "backend": os.environ.get("WEBSITE_HOSTNAME", "unknown"),
+ "backend_secret_received": True,
+ "headers": headers,
+ }
+ )
diff --git a/samples/api-management-function-app/python/function/host.json b/samples/api-management-function-app/python/function/host.json
new file mode 100644
index 0000000..b7e5ad1
--- /dev/null
+++ b/samples/api-management-function-app/python/function/host.json
@@ -0,0 +1,7 @@
+{
+ "version": "2.0",
+ "extensionBundle": {
+ "id": "Microsoft.Azure.Functions.ExtensionBundle",
+ "version": "[4.*, 5.0.0)"
+ }
+}
diff --git a/samples/api-management-function-app/python/function/requirements.txt b/samples/api-management-function-app/python/function/requirements.txt
new file mode 100644
index 0000000..75db2c4
--- /dev/null
+++ b/samples/api-management-function-app/python/function/requirements.txt
@@ -0,0 +1 @@
+azure-functions
diff --git a/samples/api-management-function-app/python/scripts/README.md b/samples/api-management-function-app/python/scripts/README.md
new file mode 100644
index 0000000..07d7d20
--- /dev/null
+++ b/samples/api-management-function-app/python/scripts/README.md
@@ -0,0 +1,19 @@
+# Azure CLI Deployment
+
+This directory contains Bash scripts for deploying and validating the sample using the `lstk` CLI. For details about the sample application, see [API Management and Function App](../README.md).
+
+## Prerequisites
+
+- [LocalStack for Azure](https://docs.localstack.cloud/azure/)
+- [Docker](https://docs.docker.com/get-docker/)
+- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
+- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
+- [jq](https://jqlang.org/), `zip` and `openssl`
+
+## Scripts
+
+| Script | Purpose |
+|--------|---------|
+| `deploy.sh` | Idempotently provisions the Function App and the API Management instance, deploys the function from a zip package, imports the API, applies the policy, and creates the named value, product and subscription. Prints the gateway URL and the command that reads the subscription key. |
+| `validate.sh` | Walks the whole chain (direct backend refusal, import, key checks, forwarding, policy effects, CORS preflight, 404, rate limit) and exits non-zero on any failure. |
+| `call-api.sh` | Quick user-level smoke test: reads the key, lists the items and reads one of them, honouring a `Retry-After` if the rate limit is still in force. |
diff --git a/samples/api-management-function-app/python/scripts/call-api.sh b/samples/api-management-function-app/python/scripts/call-api.sh
new file mode 100755
index 0000000..820f0f4
--- /dev/null
+++ b/samples/api-management-function-app/python/scripts/call-api.sh
@@ -0,0 +1,87 @@
+#!/bin/bash
+
+# Variables
+PREFIX='local'
+SUFFIX='test'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}"
+APIM_API_VERSION='2022-08-01'
+API_PATH='inventory'
+APIM_SUBSCRIPTION_ID='partner-subscription'
+BODY_FILE='/tmp/inventory_call.json'
+HEADERS_FILE='/tmp/inventory_call_headers.txt'
+
+# Retrieve the API Management service
+echo "Retrieving the [$APIM_NAME] API Management service..."
+APIM_ID=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
+
+if [[ -n "$APIM_ID" ]]; then
+ echo "[$APIM_NAME] API Management service successfully retrieved"
+else
+ echo "Failed to retrieve the [$APIM_NAME] API Management service"
+ exit 1
+fi
+
+# Where the gateway answers: Azure's gatewayUrl, or the emulator's local alias (the emulator also
+# claims the *.azure-api.net name, but it only resolves once LocalStack's DNS is in front of the machine)
+ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ GATEWAY_URL="http://${APIM_NAME}.apim.azure.localhost.localstack.cloud:4566"
+else
+ GATEWAY_URL=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query gatewayUrl --output tsv)
+fi
+API_URL="$GATEWAY_URL/$API_PATH"
+
+# Retrieve the subscription key
+echo "Reading the [$APIM_SUBSCRIPTION_ID] subscription key..."
+KEY=$(az rest --method post \
+ --url "$APIM_ID/subscriptions/$APIM_SUBSCRIPTION_ID/listSecrets?api-version=$APIM_API_VERSION" \
+ --query primaryKey \
+ --output tsv)
+
+if [[ -n "$KEY" ]]; then
+ echo "[$APIM_SUBSCRIPTION_ID] subscription key successfully retrieved"
+else
+ echo "Failed to retrieve the [$APIM_SUBSCRIPTION_ID] subscription key"
+ exit 1
+fi
+
+# Call the gateway. The API allows ten calls a minute per subscription, so a run right after
+# validate.sh may be told to wait: honour the Retry-After the gateway sends and try once more.
+call_api() {
+ local url="$1"
+ STATUS=$(curl -s -m 20 -o "$BODY_FILE" -D "$HEADERS_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$url")
+ if [[ "$STATUS" == "429" ]]; then
+ RETRY_AFTER=$(grep -i "^retry-after:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+ RETRY_AFTER=${RETRY_AFTER:-5}
+ [[ $RETRY_AFTER -lt 1 ]] && RETRY_AFTER=1
+ [[ $RETRY_AFTER -gt 60 ]] && RETRY_AFTER=60
+ echo "Rate limited by the gateway; waiting $RETRY_AFTER seconds as asked..."
+ sleep "$RETRY_AFTER"
+ STATUS=$(curl -s -m 20 -o "$BODY_FILE" -D "$HEADERS_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$url")
+ fi
+}
+
+echo "Calling [$API_URL/items]..."
+call_api "$API_URL/items"
+
+if [[ "$STATUS" == "200" ]]; then
+ echo "[$API_URL/items] successfully returned [$STATUS]:"
+ jq . "$BODY_FILE"
+else
+ echo "[$API_URL/items] returned [$STATUS]: $(cat "$BODY_FILE")"
+ exit 1
+fi
+
+echo "Calling [$API_URL/items/2]..."
+call_api "$API_URL/items/2"
+
+if [[ "$STATUS" == "200" ]]; then
+ echo "[$API_URL/items/2] successfully returned [$STATUS]:"
+ jq . "$BODY_FILE"
+else
+ echo "[$API_URL/items/2] returned [$STATUS]: $(cat "$BODY_FILE")"
+ exit 1
+fi
+
+echo "Response header added by the gateway's outbound policy: $(grep -i "^x-served-by:" "$HEADERS_FILE" | tr -d '\r')"
diff --git a/samples/api-management-function-app/python/scripts/deploy.sh b/samples/api-management-function-app/python/scripts/deploy.sh
new file mode 100755
index 0000000..a89d582
--- /dev/null
+++ b/samples/api-management-function-app/python/scripts/deploy.sh
@@ -0,0 +1,409 @@
+#!/bin/bash
+
+# Variables
+PREFIX='local'
+SUFFIX='test'
+LOCATION='westeurope'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+STORAGE_ACCOUNT_NAME="${PREFIX}invstorage${SUFFIX}"
+APP_SERVICE_PLAN_NAME="${PREFIX}-inventory-app-service-plan-${SUFFIX}"
+APP_SERVICE_PLAN_SKU='B1'
+FUNCTION_APP_NAME="${PREFIX}-inventory-functionapp-${SUFFIX}"
+RUNTIME='python'
+RUNTIME_VERSION='3.11'
+FUNCTIONS_VERSION='4'
+APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}"
+APIM_SKU='Consumption'
+APIM_PUBLISHER_NAME='LocalStack'
+APIM_PUBLISHER_EMAIL='noreply@localstack.cloud'
+APIM_API_VERSION='2022-08-01'
+API_ID='inventory-api'
+API_DISPLAY_NAME='Inventory API'
+API_PATH='inventory'
+PRODUCT_ID='inventory-partners'
+PRODUCT_NAME='Inventory Partners'
+APIM_SUBSCRIPTION_ID='partner-subscription'
+NAMED_VALUE_ID='backend-secret'
+FUNCTION_ZIPFILE='inventory_function.zip'
+CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)"
+APIM_DIR="$CURRENT_DIR/../apim"
+
+# Get the current subscription
+SUBSCRIPTION_NAME=$(az account show --query name --output tsv)
+
+# The emulator's Function App answers on plain HTTP under its own hostname, while real Azure
+# serves *.azurewebsites.net over HTTPS. Everything else below is the same on both.
+ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ BACKEND_SCHEME='http'
+else
+ BACKEND_SCHEME='https'
+fi
+
+# Check if the resource group already exists
+echo "Checking if [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription..."
+az group show --name $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription"
+ echo "Creating [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription..."
+
+ az group create --name $RESOURCE_GROUP_NAME --location "$LOCATION" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$RESOURCE_GROUP_NAME] resource group successfully created in the [$SUBSCRIPTION_NAME] subscription"
+ else
+ echo "Failed to create [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription"
+ exit 1
+ fi
+else
+ echo "[$RESOURCE_GROUP_NAME] resource group already exists in the [$SUBSCRIPTION_NAME] subscription"
+fi
+
+# Check if the storage account already exists
+echo "Checking if [$STORAGE_ACCOUNT_NAME] storage account actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$STORAGE_ACCOUNT_NAME] storage account actually exists in the [$RESOURCE_GROUP_NAME] resource group"
+ echo "Creating [$STORAGE_ACCOUNT_NAME] storage account in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ az storage account create \
+ --name $STORAGE_ACCOUNT_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --location "$LOCATION" \
+ --sku Standard_LRS 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$STORAGE_ACCOUNT_NAME] storage account successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "Failed to create [$STORAGE_ACCOUNT_NAME] storage account in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+else
+ echo "[$STORAGE_ACCOUNT_NAME] storage account already exists in the [$RESOURCE_GROUP_NAME] resource group"
+fi
+
+# Check if the app service plan already exists
+echo "Checking if [$APP_SERVICE_PLAN_NAME] app service plan actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+az appservice plan show --name $APP_SERVICE_PLAN_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$APP_SERVICE_PLAN_NAME] app service plan actually exists in the [$RESOURCE_GROUP_NAME] resource group"
+ echo "Creating [$APP_SERVICE_PLAN_NAME] app service plan in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ az appservice plan create \
+ --name $APP_SERVICE_PLAN_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --location "$LOCATION" \
+ --sku $APP_SERVICE_PLAN_SKU \
+ --is-linux 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$APP_SERVICE_PLAN_NAME] app service plan successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "Failed to create [$APP_SERVICE_PLAN_NAME] app service plan in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+else
+ echo "[$APP_SERVICE_PLAN_NAME] app service plan already exists in the [$RESOURCE_GROUP_NAME] resource group"
+fi
+
+# Check if the function app already exists
+echo "Checking if [$FUNCTION_APP_NAME] function app actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+az functionapp show --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$FUNCTION_APP_NAME] function app actually exists in the [$RESOURCE_GROUP_NAME] resource group"
+ echo "Creating [$FUNCTION_APP_NAME] function app in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ az functionapp create \
+ --name $FUNCTION_APP_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --plan $APP_SERVICE_PLAN_NAME \
+ --storage-account $STORAGE_ACCOUNT_NAME \
+ --runtime $RUNTIME \
+ --runtime-version $RUNTIME_VERSION \
+ --functions-version $FUNCTIONS_VERSION \
+ --os-type Linux 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$FUNCTION_APP_NAME] function app successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "Failed to create [$FUNCTION_APP_NAME] function app in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+else
+ echo "[$FUNCTION_APP_NAME] function app already exists in the [$RESOURCE_GROUP_NAME] resource group"
+fi
+
+# Check if the API Management service already exists
+echo "Checking if [$APIM_NAME] API Management service actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$APIM_NAME] API Management service actually exists in the [$RESOURCE_GROUP_NAME] resource group"
+
+ # A deleted instance is soft-deleted and keeps its name reserved until it is purged, so an
+ # earlier run's instance would make the create below fail with a conflict. Purge it first.
+ az apim deletedservice show --service-name $APIM_NAME --location "$LOCATION" &>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "Purging the soft-deleted [$APIM_NAME] API Management service that still holds the name..."
+ az apim deletedservice purge --service-name $APIM_NAME --location "$LOCATION" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "Soft-deleted [$APIM_NAME] API Management service successfully purged"
+ else
+ echo "Failed to purge the soft-deleted [$APIM_NAME] API Management service"
+ exit 1
+ fi
+ fi
+
+ echo "Creating [$APIM_NAME] API Management service in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ # The Consumption tier provisions in minutes on Azure; the classic tiers take the better
+ # part of an hour.
+ az apim create \
+ --name $APIM_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --location "$LOCATION" \
+ --sku-name $APIM_SKU \
+ --publisher-name "$APIM_PUBLISHER_NAME" \
+ --publisher-email "$APIM_PUBLISHER_EMAIL" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$APIM_NAME] API Management service successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "Failed to create [$APIM_NAME] API Management service in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+else
+ echo "[$APIM_NAME] API Management service already exists in the [$RESOURCE_GROUP_NAME] resource group"
+fi
+
+APIM_ID=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
+
+# The shared secret the gateway adds to every backend call lives in a secret named value, so the
+# policy document refers to it as {{backend-secret}} and never contains it. A re-run reuses the
+# stored value, which keeps the gateway and the Function App in agreement.
+echo "Checking if [$NAMED_VALUE_ID] named value actually exists in the [$APIM_NAME] API Management service..."
+az apim nv show --named-value-id $NAMED_VALUE_ID --service-name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$NAMED_VALUE_ID] named value actually exists in the [$APIM_NAME] API Management service"
+ echo "Creating [$NAMED_VALUE_ID] named value in the [$APIM_NAME] API Management service..."
+ BACKEND_SECRET=$(openssl rand -hex 16)
+
+ az apim nv create \
+ --named-value-id $NAMED_VALUE_ID \
+ --display-name $NAMED_VALUE_ID \
+ --service-name $APIM_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --secret true \
+ --value "$BACKEND_SECRET" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$NAMED_VALUE_ID] named value successfully created in the [$APIM_NAME] API Management service"
+ else
+ echo "Failed to create [$NAMED_VALUE_ID] named value in the [$APIM_NAME] API Management service"
+ exit 1
+ fi
+else
+ echo "[$NAMED_VALUE_ID] named value already exists in the [$APIM_NAME] API Management service"
+ BACKEND_SECRET=$(az apim nv show-secret \
+ --named-value-id $NAMED_VALUE_ID \
+ --service-name $APIM_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --query value \
+ --output tsv)
+
+ if [[ -z "$BACKEND_SECRET" ]]; then
+ echo "Failed to read the [$NAMED_VALUE_ID] named value from the [$APIM_NAME] API Management service"
+ exit 1
+ fi
+fi
+
+# Configure the function app settings: the same secret the gateway injects
+echo "Setting app settings for the [$FUNCTION_APP_NAME] function app..."
+az functionapp config appsettings set \
+ --name $FUNCTION_APP_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --settings \
+ FUNCTIONS_WORKER_RUNTIME="$RUNTIME" \
+ SCM_DO_BUILD_DURING_DEPLOYMENT='true' \
+ ENABLE_ORYX_BUILD='true' \
+ BACKEND_SECRET="$BACKEND_SECRET" 1>/dev/null
+
+if [[ $? == 0 ]]; then
+ echo "App settings for the [$FUNCTION_APP_NAME] function app successfully set"
+else
+ echo "Failed to set app settings for the [$FUNCTION_APP_NAME] function app"
+ exit 1
+fi
+
+# Create the zip package of the function app
+cd "$CURRENT_DIR/../function" || exit
+if [ -f "$FUNCTION_ZIPFILE" ]; then
+ rm "$FUNCTION_ZIPFILE"
+fi
+echo "Creating zip package of the function app..."
+zip -r "$FUNCTION_ZIPFILE" function_app.py host.json requirements.txt
+
+# Deploy the function app
+echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$FUNCTION_ZIPFILE]..."
+az functionapp deploy \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --name $FUNCTION_APP_NAME \
+ --src-path "$FUNCTION_ZIPFILE" \
+ --type zip \
+ --async true 1>/dev/null
+
+if [[ $? == 0 ]]; then
+ echo "Function app [$FUNCTION_APP_NAME] deployed successfully"
+else
+ echo "Failed to deploy function app [$FUNCTION_APP_NAME]"
+ exit 1
+fi
+rm -f "$FUNCTION_ZIPFILE"
+
+# The backend address of the API: the Function App's hostname plus the Functions route prefix
+FUNCTION_APP_HOSTNAME=$(az functionapp show --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query defaultHostName --output tsv)
+BACKEND_URL="$BACKEND_SCHEME://$FUNCTION_APP_HOSTNAME/api"
+echo "Backend URL = $BACKEND_URL"
+
+# Check if the API already exists
+echo "Checking if [$API_ID] API actually exists in the [$APIM_NAME] API Management service..."
+az apim api show --api-id $API_ID --service-name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$API_ID] API actually exists in the [$APIM_NAME] API Management service"
+ echo "Importing [$API_ID] API from [openapi.json] into the [$APIM_NAME] API Management service..."
+
+ # The operations come from the OpenAPI document; the backend address is given here because
+ # it is only known once the Function App exists.
+ az apim api import \
+ --api-id $API_ID \
+ --display-name "$API_DISPLAY_NAME" \
+ --path $API_PATH \
+ --service-name $APIM_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --specification-format OpenApiJson \
+ --specification-path "$APIM_DIR/openapi.json" \
+ --service-url "$BACKEND_URL" \
+ --subscription-required true 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$API_ID] API successfully imported into the [$APIM_NAME] API Management service"
+ else
+ echo "Failed to import [$API_ID] API into the [$APIM_NAME] API Management service"
+ exit 1
+ fi
+else
+ echo "[$API_ID] API already exists in the [$APIM_NAME] API Management service"
+fi
+
+# Apply the API policy. Policies are validated when they are saved, so a mistake in the document
+# is reported here rather than at the first request. The body is built with jq so the XML needs
+# no escaping by hand. Updating an existing API Management entity needs an If-Match header, and
+# * accepts whatever ETag the entity currently has.
+echo "Applying the API policy to the [$API_ID] API..."
+POLICY_URL="$APIM_ID/apis/$API_ID/policies/policy?api-version=$APIM_API_VERSION"
+POLICY_BODY=$(jq -n --rawfile xml "$APIM_DIR/inventory-api-policy.xml" '{properties: {format: "xml", value: $xml}}')
+az rest --method get --url "$POLICY_URL" &>/dev/null
+
+if [[ $? == 0 ]]; then
+ az rest --method put --url "$POLICY_URL" --headers "If-Match=*" --body "$POLICY_BODY" --output none
+else
+ az rest --method put --url "$POLICY_URL" --body "$POLICY_BODY" --output none
+fi
+
+if [[ $? == 0 ]]; then
+ echo "API policy successfully applied to the [$API_ID] API"
+else
+ echo "Failed to apply the API policy to the [$API_ID] API"
+ exit 1
+fi
+
+# Check if the product already exists
+echo "Checking if [$PRODUCT_ID] product actually exists in the [$APIM_NAME] API Management service..."
+az apim product show --product-id $PRODUCT_ID --service-name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$PRODUCT_ID] product actually exists in the [$APIM_NAME] API Management service"
+ echo "Creating [$PRODUCT_ID] product in the [$APIM_NAME] API Management service..."
+
+ az apim product create \
+ --product-id $PRODUCT_ID \
+ --product-name "$PRODUCT_NAME" \
+ --description "Partners reading stock levels through the Inventory API" \
+ --service-name $APIM_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --subscription-required true \
+ --approval-required false \
+ --state published 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$PRODUCT_ID] product successfully created in the [$APIM_NAME] API Management service"
+ else
+ echo "Failed to create [$PRODUCT_ID] product in the [$APIM_NAME] API Management service"
+ exit 1
+ fi
+else
+ echo "[$PRODUCT_ID] product already exists in the [$APIM_NAME] API Management service"
+fi
+
+# Add the API to the product (a PUT, so re-runs are harmless)
+echo "Adding the [$API_ID] API to the [$PRODUCT_ID] product..."
+az apim product api add \
+ --product-id $PRODUCT_ID \
+ --api-id $API_ID \
+ --service-name $APIM_NAME \
+ --resource-group $RESOURCE_GROUP_NAME 1>/dev/null
+
+if [[ $? == 0 ]]; then
+ echo "[$API_ID] API successfully added to the [$PRODUCT_ID] product"
+else
+ echo "Failed to add the [$API_ID] API to the [$PRODUCT_ID] product"
+ exit 1
+fi
+
+# Check if the subscription already exists. The az CLI has no subscription commands, so this goes
+# through az rest. The scope is the product: the key opens every API the product contains, and
+# nothing else.
+echo "Checking if [$APIM_SUBSCRIPTION_ID] subscription actually exists in the [$APIM_NAME] API Management service..."
+SUBSCRIPTION_URL="$APIM_ID/subscriptions/$APIM_SUBSCRIPTION_ID?api-version=$APIM_API_VERSION"
+az rest --method get --url "$SUBSCRIPTION_URL" &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$APIM_SUBSCRIPTION_ID] subscription actually exists in the [$APIM_NAME] API Management service"
+ echo "Creating [$APIM_SUBSCRIPTION_ID] subscription in the [$APIM_NAME] API Management service..."
+ PRODUCT_RESOURCE_ID=$(az apim product show --product-id $PRODUCT_ID --service-name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
+ SUBSCRIPTION_BODY=$(jq -n --arg scope "$PRODUCT_RESOURCE_ID" '{properties: {displayName: "Partner subscription", scope: $scope, state: "active"}}')
+
+ az rest --method put --url "$SUBSCRIPTION_URL" --body "$SUBSCRIPTION_BODY" --output none
+
+ if [[ $? == 0 ]]; then
+ echo "[$APIM_SUBSCRIPTION_ID] subscription successfully created in the [$APIM_NAME] API Management service"
+ else
+ echo "Failed to create [$APIM_SUBSCRIPTION_ID] subscription in the [$APIM_NAME] API Management service"
+ exit 1
+ fi
+else
+ echo "[$APIM_SUBSCRIPTION_ID] subscription already exists in the [$APIM_NAME] API Management service"
+fi
+
+# Where the gateway answers. The emulator also reports Azure's *.azure-api.net address in
+# gatewayUrl, but that name only resolves once LocalStack's DNS is in front of the machine, so
+# the local alias is printed instead.
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ GATEWAY_URL="http://${APIM_NAME}.apim.azure.localhost.localstack.cloud:4566"
+else
+ GATEWAY_URL=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query gatewayUrl --output tsv)
+fi
+
+echo "Deployment completed. The Inventory API is available at: $GATEWAY_URL/$API_PATH"
+echo "Read the subscription key with:"
+echo " az rest --method post --url \"$APIM_ID/subscriptions/$APIM_SUBSCRIPTION_ID/listSecrets?api-version=$APIM_API_VERSION\" --query primaryKey --output tsv"
+echo "Then call the API with:"
+echo " curl -H \"Ocp-Apim-Subscription-Key: \" \"$GATEWAY_URL/$API_PATH/items\""
diff --git a/samples/api-management-function-app/python/scripts/validate.sh b/samples/api-management-function-app/python/scripts/validate.sh
new file mode 100755
index 0000000..ac49cd9
--- /dev/null
+++ b/samples/api-management-function-app/python/scripts/validate.sh
@@ -0,0 +1,248 @@
+#!/bin/bash
+
+# Variables
+PREFIX='local'
+SUFFIX='test'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+FUNCTION_APP_NAME="${PREFIX}-inventory-functionapp-${SUFFIX}"
+APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}"
+APIM_API_VERSION='2022-08-01'
+API_ID='inventory-api'
+API_PATH='inventory'
+APIM_SUBSCRIPTION_ID='partner-subscription'
+RATE_LIMIT_CALLS=10
+BODY_FILE='/tmp/inventory_body.json'
+HEADERS_FILE='/tmp/inventory_headers.txt'
+
+FAILED=0
+
+# Retrieve the API Management service
+APIM_ID=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
+
+if [[ -z "$APIM_ID" ]]; then
+ echo "Failed to retrieve the [$APIM_NAME] API Management service; run scripts/deploy.sh first. Exiting."
+ exit 1
+fi
+
+# Resolve the two hosts. The emulator's Function App and gateway answer on plain HTTP under their
+# own hostnames; on Azure both are HTTPS. The emulator also reports Azure's *.azure-api.net address
+# in gatewayUrl, but that name only resolves once LocalStack's DNS is in front of the machine, so
+# the gateway is called through its local alias instead.
+ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
+FUNCTION_APP_HOSTNAME=$(az functionapp show --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query defaultHostName --output tsv)
+
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ BACKEND_URL="http://$FUNCTION_APP_HOSTNAME/api"
+ GATEWAY_URL="http://${APIM_NAME}.apim.azure.localhost.localstack.cloud:4566"
+else
+ BACKEND_URL="https://$FUNCTION_APP_HOSTNAME/api"
+ GATEWAY_URL=$(az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME --query gatewayUrl --output tsv)
+fi
+API_URL="$GATEWAY_URL/$API_PATH"
+echo "Backend URL: $BACKEND_URL"
+echo "Gateway URL: $API_URL"
+
+# 1. Wait for the Function App: the zip deployment is asynchronous and the host takes a moment to
+# start. A 401 is the answer we want from a direct call: the backend refuses anything that does
+# not carry the shared secret, so the gateway is the only way in.
+echo "Waiting for the [$FUNCTION_APP_NAME] function app to answer..."
+BACKEND_STATUS=""
+for i in $(seq 1 60); do
+ BACKEND_STATUS=$(curl -s -m 10 -o /dev/null -w "%{http_code}" "$BACKEND_URL/items")
+ [[ "$BACKEND_STATUS" == "401" ]] && break
+ sleep 5
+done
+echo "Direct call to the backend without the shared secret: HTTP $BACKEND_STATUS"
+if [[ "$BACKEND_STATUS" == "401" ]]; then
+ echo "The backend refuses direct calls"
+else
+ echo "Expected the backend to answer 401 to a direct call"
+ FAILED=1
+fi
+
+# 2. The OpenAPI import produced the operations.
+echo "Listing the operations of the [$API_ID] API..."
+OPERATIONS=$(az apim api operation list \
+ --api-id $API_ID \
+ --service-name $APIM_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --query "[].name" \
+ --output tsv | sort | tr '\n' ' ')
+echo "Operations: $OPERATIONS"
+for OPERATION in getItem listItems whoAmI; do
+ if ! echo "$OPERATIONS" | grep -qw "$OPERATION"; then
+ echo "Operation [$OPERATION] was not imported"
+ FAILED=1
+ fi
+done
+
+# 3. A call without a subscription key is refused with Azure's own message.
+echo "Calling [$API_URL/items] without a subscription key..."
+NO_KEY_STATUS=$(curl -s -m 10 -o "$BODY_FILE" -w "%{http_code}" "$API_URL/items")
+echo "HTTP $NO_KEY_STATUS: $(cat "$BODY_FILE")"
+if [[ "$NO_KEY_STATUS" == "401" ]] && grep -q "missing subscription key" "$BODY_FILE"; then
+ echo "Keyless calls are refused"
+else
+ echo "Expected 401 with the missing-subscription-key message"
+ FAILED=1
+fi
+
+# 4. Take the partner subscription's key from the control plane. It is the only credential the
+# client needs; the backend secret stays inside API Management.
+echo "Reading the [$APIM_SUBSCRIPTION_ID] subscription key..."
+KEY=$(az rest --method post \
+ --url "$APIM_ID/subscriptions/$APIM_SUBSCRIPTION_ID/listSecrets?api-version=$APIM_API_VERSION" \
+ --query primaryKey \
+ --output tsv)
+if [[ -z "$KEY" ]]; then
+ echo "Failed to read the [$APIM_SUBSCRIPTION_ID] subscription key. Exiting."
+ exit 1
+fi
+echo "Subscription key retrieved (${#KEY} characters)"
+
+# 5. A wrong key is refused too, with a different message.
+echo "Calling [$API_URL/items] with a wrong subscription key..."
+BAD_KEY_STATUS=$(curl -s -m 10 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: not-a-real-key" "$API_URL/items")
+echo "HTTP $BAD_KEY_STATUS: $(cat "$BODY_FILE")"
+if [[ "$BAD_KEY_STATUS" == "401" ]] && grep -q "invalid subscription key" "$BODY_FILE"; then
+ echo "Wrong keys are refused"
+else
+ echo "Expected 401 with the invalid-subscription-key message"
+ FAILED=1
+fi
+
+# 6. listItems: the request is authorised, the policies run, and the Function App answers.
+echo "Calling [$API_URL/items] with the subscription key..."
+LIST_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -D "$HEADERS_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items")
+cat "$BODY_FILE"
+echo
+COUNT=$(jq -r '.count' "$BODY_FILE" 2>/dev/null)
+if [[ "$LIST_STATUS" == "200" && "$COUNT" == "3" ]]; then
+ echo "listItems answered 200 with $COUNT items"
+else
+ echo "Expected 200 with 3 items (got HTTP $LIST_STATUS, count=$COUNT)"
+ FAILED=1
+fi
+if grep -qi "^x-served-by: Azure API Management" "$HEADERS_FILE"; then
+ echo "The outbound policy added the X-Served-By header"
+else
+ echo "The X-Served-By header is missing from the response"
+ FAILED=1
+fi
+
+# 7. getItem: the {id} template parameter is matched by the gateway and read by the backend. A
+# 404 from the backend passes through the gateway untouched.
+echo "Calling [$API_URL/items/2]..."
+ITEM_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items/2")
+SKU=$(jq -r '.sku' "$BODY_FILE" 2>/dev/null)
+echo "HTTP $ITEM_STATUS: $(jq -c . "$BODY_FILE" 2>/dev/null || cat "$BODY_FILE")"
+if [[ "$ITEM_STATUS" == "200" && "$SKU" == "APIM-002" ]]; then
+ echo "getItem answered 200 with the requested item"
+else
+ echo "Expected 200 with sku APIM-002 (got HTTP $ITEM_STATUS, sku=$SKU)"
+ FAILED=1
+fi
+echo "Calling [$API_URL/items/99]..."
+MISSING_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items/99")
+echo "HTTP $MISSING_STATUS: $(jq -c . "$BODY_FILE" 2>/dev/null || cat "$BODY_FILE")"
+if [[ "$MISSING_STATUS" == "404" ]] && grep -q "No item with id 99" "$BODY_FILE"; then
+ echo "The backend's 404 passes through the gateway"
+else
+ echo "Expected the backend's 404 for a missing item (got HTTP $MISSING_STATUS)"
+ FAILED=1
+fi
+
+# 8. whoAmI: what reached the backend shows the policies at work. The shared secret arrived, the
+# subscription key did not, and the backend was told which subscription is calling.
+echo "Calling [$API_URL/whoami]..."
+WHOAMI_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/whoami")
+cat "$BODY_FILE"
+echo
+SECRET_RECEIVED=$(jq -r '.backend_secret_received' "$BODY_FILE" 2>/dev/null)
+FORWARDED_KEY=$(jq -r '.headers["ocp-apim-subscription-key"] // "absent"' "$BODY_FILE" 2>/dev/null)
+CALLER=$(jq -r '.headers["x-caller-subscription"] // ""' "$BODY_FILE" 2>/dev/null)
+if [[ "$WHOAMI_STATUS" == "200" && "$SECRET_RECEIVED" == "true" ]]; then
+ echo "The gateway injected the backend secret from the named value"
+else
+ echo "Expected the backend to receive the shared secret (got HTTP $WHOAMI_STATUS, backend_secret_received=$SECRET_RECEIVED)"
+ FAILED=1
+fi
+if [[ "$FORWARDED_KEY" == "absent" ]]; then
+ echo "The subscription key was stripped before the backend"
+else
+ echo "The subscription key reached the backend"
+ FAILED=1
+fi
+if [[ "$CALLER" == "$APIM_SUBSCRIPTION_ID" ]]; then
+ echo "The backend was told the calling subscription: $CALLER"
+else
+ echo "Expected X-Caller-Subscription to be [$APIM_SUBSCRIPTION_ID] (got: $CALLER)"
+ FAILED=1
+fi
+
+# 9. A browser preflight is answered by the gateway itself, from the cors policy, without a key.
+# On the emulator this is not yet observable: LocalStack answers CORS for every hostname it serves
+# (its own origin allow-list, see EXTRA_CORS_ALLOWED_ORIGINS), the API Management gateway included,
+# so the preflight never reaches the API's cors policy. Asserted against Azure only.
+# TODO: assert this unconditionally once the emulator lets the gateway's cors policy answer.
+echo "Sending a CORS preflight to [$API_URL/items]..."
+PREFLIGHT_STATUS=$(curl -s -m 10 -o /dev/null -D "$HEADERS_FILE" -w "%{http_code}" \
+ -X OPTIONS \
+ -H "Origin: http://localhost:3000" \
+ -H "Access-Control-Request-Method: GET" \
+ "$API_URL/items")
+ALLOW_ORIGIN=$(grep -i "^access-control-allow-origin:" "$HEADERS_FILE" | tr -d '\r')
+echo "HTTP $PREFLIGHT_STATUS ${ALLOW_ORIGIN:-(no Access-Control-Allow-Origin header)}"
+if [[ "$PREFLIGHT_STATUS" == "200" && -n "$ALLOW_ORIGIN" ]]; then
+ echo "The preflight is answered by the gateway's cors policy"
+elif [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ echo "Not asserted on the emulator: LocalStack's own CORS layer answered before the gateway's cors policy could"
+else
+ echo "Expected a 200 preflight response with Access-Control-Allow-Origin"
+ FAILED=1
+fi
+
+# 10. A path that matches no operation gets Azure's 404 body.
+echo "Calling [$API_URL/nothing-here]..."
+UNKNOWN_STATUS=$(curl -s -m 10 -o "$BODY_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/nothing-here")
+echo "HTTP $UNKNOWN_STATUS: $(cat "$BODY_FILE")"
+if [[ "$UNKNOWN_STATUS" == "404" ]] && grep -q "Resource not found" "$BODY_FILE"; then
+ echo "Unknown operations get the gateway's 404"
+else
+ echo "Expected the gateway's 404 for an unknown operation"
+ FAILED=1
+fi
+
+# 11. The rate limit. The policy allows RATE_LIMIT_CALLS calls a minute per subscription; the calls
+# above count towards it, and the loop below runs until the gateway answers 429 with a Retry-After.
+# This check runs last on purpose: the subscription stays limited for the rest of the window.
+echo "Calling [$API_URL/items] until the rate limit trips (limit: $RATE_LIMIT_CALLS calls a minute)..."
+LIMITED=0
+for i in $(seq 1 $((RATE_LIMIT_CALLS + 2))); do
+ STATUS=$(curl -s -m 10 -o "$BODY_FILE" -D "$HEADERS_FILE" -w "%{http_code}" -H "Ocp-Apim-Subscription-Key: $KEY" "$API_URL/items")
+ echo " call $i: HTTP $STATUS"
+ if [[ "$STATUS" == "429" ]]; then
+ LIMITED=1
+ break
+ fi
+done
+if [[ $LIMITED == 1 ]]; then
+ echo "Rate limit response: $(cat "$BODY_FILE")"
+ RETRY_AFTER=$(grep -i "^retry-after:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+ if [[ -n "$RETRY_AFTER" ]]; then
+ echo "The rate limit tripped with Retry-After: $RETRY_AFTER seconds"
+ else
+ echo "The 429 response carries no Retry-After header"
+ FAILED=1
+ fi
+else
+ echo "The rate limit did not trip after $((RATE_LIMIT_CALLS + 2)) calls"
+ FAILED=1
+fi
+
+if [[ $FAILED == 0 ]]; then
+ echo "All validation checks passed"
+else
+ echo "Some validation checks failed"
+fi
+exit $FAILED
diff --git a/samples/api-management-function-app/python/terraform/README.md b/samples/api-management-function-app/python/terraform/README.md
new file mode 100644
index 0000000..cbd74c9
--- /dev/null
+++ b/samples/api-management-function-app/python/terraform/README.md
@@ -0,0 +1,29 @@
+# Terraform Deployment
+
+This directory contains the Terraform configuration for the sample. For details about the sample application, see [API Management and Function App](../README.md).
+
+## Prerequisites
+
+- [LocalStack for Azure](https://docs.localstack.cloud/azure/)
+- [Docker](https://docs.docker.com/get-docker/)
+- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
+- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
+- [Terraform](https://developer.hashicorp.com/terraform/downloads)
+- `zip`
+
+## Deployment
+
+```bash
+bash deploy.sh
+```
+
+The script runs `terraform init`, `plan` and `apply`, then deploys the function from a zip package with the Azure CLI. The API is imported from `../apim/openapi.json` and the policy is read from `../apim/inventory-api-policy.xml`, the same files the other deployment variants use; the shared secret is generated by Terraform (`random_password`) and stored both as the Function App's setting and as the secret named value. Terraform state stays local, matching the sibling samples.
+
+## Cleanup
+
+```bash
+terraform destroy \
+ -var "prefix=local" \
+ -var "suffix=test" \
+ -var "location=westeurope"
+```
diff --git a/samples/api-management-function-app/python/terraform/deploy.sh b/samples/api-management-function-app/python/terraform/deploy.sh
new file mode 100755
index 0000000..ac1609d
--- /dev/null
+++ b/samples/api-management-function-app/python/terraform/deploy.sh
@@ -0,0 +1,123 @@
+#!/bin/bash
+
+# Variables
+PREFIX='local'
+SUFFIX='test'
+LOCATION='westeurope'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}"
+APIM_API_VERSION='2022-08-01'
+FUNCTION_ZIPFILE='inventory_function.zip'
+CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Change the current directory to the script's directory
+cd "$CURRENT_DIR" || exit
+
+# A deleted API Management instance is soft-deleted and keeps its name reserved until it is
+# purged, so an earlier run's instance (the scripts variant's, say) would make Terraform's create
+# fail with a conflict. Purge it first, unless the instance is live and is simply being updated.
+az apim show --name $APIM_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ az apim deletedservice show --service-name $APIM_NAME --location "$LOCATION" &>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "Purging the soft-deleted [$APIM_NAME] API Management service that still holds the name..."
+ az apim deletedservice purge --service-name $APIM_NAME --location "$LOCATION" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "Soft-deleted [$APIM_NAME] API Management service successfully purged"
+ else
+ echo "Failed to purge the soft-deleted [$APIM_NAME] API Management service"
+ exit 1
+ fi
+ fi
+fi
+
+# Initialize Terraform
+echo "Initializing Terraform..."
+terraform init -upgrade
+
+# Run terraform plan and check for errors
+echo "Planning Terraform deployment..."
+terraform plan -out=tfplan \
+ -var "prefix=$PREFIX" \
+ -var "suffix=$SUFFIX" \
+ -var "location=$LOCATION"
+
+if [[ $? != 0 ]]; then
+ echo "Terraform plan failed. Exiting."
+ exit 1
+fi
+
+# Apply the Terraform configuration
+echo "Applying Terraform configuration..."
+terraform apply -auto-approve tfplan
+
+if [[ $? != 0 ]]; then
+ echo "Terraform apply failed. Exiting."
+ exit 1
+fi
+
+# Get the output values
+RESOURCE_GROUP_NAME=$(terraform output -raw resource_group_name)
+FUNCTION_APP_NAME=$(terraform output -raw function_app_name)
+APIM_NAME=$(terraform output -raw apim_name)
+API_PATH=$(terraform output -raw api_path)
+APIM_SUBSCRIPTION_ID=$(terraform output -raw subscription_id)
+
+# Check if output values are empty
+if [[ -z "$FUNCTION_APP_NAME" || -z "$APIM_NAME" ]]; then
+ echo "Function App Name or API Management Name is empty. Exiting."
+ exit 1
+fi
+
+# Change current directory to the function folder
+cd "$CURRENT_DIR/../function" || exit
+
+# Remove any existing zip package of the function app
+if [ -f "$FUNCTION_ZIPFILE" ]; then
+ rm "$FUNCTION_ZIPFILE"
+fi
+
+# Create the zip package of the function app
+echo "Creating zip package of the function app..."
+zip -r "$FUNCTION_ZIPFILE" function_app.py host.json requirements.txt
+
+# Deploy the function app
+echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$FUNCTION_ZIPFILE]..."
+az functionapp deploy \
+ --resource-group "$RESOURCE_GROUP_NAME" \
+ --name "$FUNCTION_APP_NAME" \
+ --src-path "$FUNCTION_ZIPFILE" \
+ --type zip \
+ --async true 1>/dev/null
+
+if [ $? -eq 0 ]; then
+ echo "Function app [$FUNCTION_APP_NAME] deployed successfully."
+else
+ echo "Failed to deploy function app [$FUNCTION_APP_NAME]."
+ exit 1
+fi
+
+# Remove the zip package of the function app
+if [ -f "$FUNCTION_ZIPFILE" ]; then
+ rm "$FUNCTION_ZIPFILE"
+fi
+
+# Where the gateway answers. The emulator also reports Azure's *.azure-api.net address in
+# gatewayUrl, but that name only resolves once LocalStack's DNS is in front of the machine, so
+# the local alias is printed instead.
+ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ GATEWAY_URL="http://${APIM_NAME}.apim.azure.localhost.localstack.cloud:4566"
+else
+ GATEWAY_URL=$(cd "$CURRENT_DIR" && terraform output -raw gateway_url)
+fi
+APIM_ID=$(az apim show --name "$APIM_NAME" --resource-group "$RESOURCE_GROUP_NAME" --query id --output tsv)
+
+echo "Deployment completed. The Inventory API is available at: $GATEWAY_URL/$API_PATH"
+echo "Read the subscription key with:"
+echo " az rest --method post --url \"$APIM_ID/subscriptions/$APIM_SUBSCRIPTION_ID/listSecrets?api-version=$APIM_API_VERSION\" --query primaryKey --output tsv"
+echo "Then call the API with:"
+echo " curl -H \"Ocp-Apim-Subscription-Key: \" \"$GATEWAY_URL/$API_PATH/items\""
diff --git a/samples/api-management-function-app/python/terraform/main.tf b/samples/api-management-function-app/python/terraform/main.tf
new file mode 100644
index 0000000..6d9261a
--- /dev/null
+++ b/samples/api-management-function-app/python/terraform/main.tf
@@ -0,0 +1,162 @@
+# Local Variables
+locals {
+ resource_group_name = "${var.prefix}-rg"
+ storage_account_name = "${var.prefix}invstorage${var.suffix}"
+ app_service_plan = "${var.prefix}-inventory-app-service-plan-${var.suffix}"
+ function_app_name = "${var.prefix}-inventory-functionapp-${var.suffix}"
+ apim_name = "${var.prefix}-inventory-apim-${var.suffix}"
+ api_id = "inventory-api"
+ api_path = "inventory"
+ product_id = "inventory-partners"
+ subscription_id = "partner-subscription"
+ named_value_id = "backend-secret"
+}
+
+# The shared secret the gateway adds to every backend call. The Function App checks it, and the
+# gateway reads it from a secret named value, so neither the policy document nor any client ever
+# contains it.
+resource "random_password" "backend_secret" {
+ length = 32
+ special = false
+}
+
+# ---------------------------------------------------------------------------
+# Resource group
+# ---------------------------------------------------------------------------
+resource "azurerm_resource_group" "main" {
+ name = local.resource_group_name
+ location = var.location
+}
+
+# ---------------------------------------------------------------------------
+# Function App: the Inventory backend
+# ---------------------------------------------------------------------------
+resource "azurerm_storage_account" "main" {
+ name = local.storage_account_name
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_resource_group.main.location
+ account_tier = "Standard"
+ account_replication_type = "LRS"
+}
+
+resource "azurerm_service_plan" "main" {
+ name = local.app_service_plan
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_resource_group.main.location
+ os_type = "Linux"
+ sku_name = var.app_service_plan_sku
+}
+
+resource "azurerm_linux_function_app" "inventory" {
+ name = local.function_app_name
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_resource_group.main.location
+ service_plan_id = azurerm_service_plan.main.id
+ storage_account_name = azurerm_storage_account.main.name
+ storage_account_access_key = azurerm_storage_account.main.primary_access_key
+ functions_extension_version = "~4"
+
+ site_config {
+ application_stack {
+ python_version = var.python_version
+ }
+ }
+
+ app_settings = {
+ FUNCTIONS_WORKER_RUNTIME = "python"
+ SCM_DO_BUILD_DURING_DEPLOYMENT = "true"
+ ENABLE_ORYX_BUILD = "true"
+ # The Functions host parses AzureWebJobsStorage with the strict .NET storage clients, which
+ # cannot parse the EndpointSuffix-with-port string the provider would compose on the
+ # emulator - so the provider default is overridden with an explicit-endpoints connection,
+ # as the url-shortener sibling does.
+ AzureWebJobsStorage = "DefaultEndpointsProtocol=https;AccountName=${azurerm_storage_account.main.name};AccountKey=${azurerm_storage_account.main.primary_access_key};BlobEndpoint=${azurerm_storage_account.main.primary_blob_endpoint};QueueEndpoint=${azurerm_storage_account.main.primary_queue_endpoint};TableEndpoint=${azurerm_storage_account.main.primary_table_endpoint}"
+ # The same secret the gateway injects from its named value.
+ BACKEND_SECRET = random_password.backend_secret.result
+ }
+}
+
+# ---------------------------------------------------------------------------
+# API Management
+# ---------------------------------------------------------------------------
+# The Consumption tier provisions in minutes on Azure; the classic tiers take the better part of
+# an hour.
+resource "azurerm_api_management" "main" {
+ name = local.apim_name
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_resource_group.main.location
+ publisher_name = var.publisher_name
+ publisher_email = var.publisher_email
+ sku_name = var.apim_sku_name
+}
+
+# The shared secret lives in a secret named value; the policy refers to it as {{backend-secret}}.
+resource "azurerm_api_management_named_value" "backend_secret" {
+ name = local.named_value_id
+ display_name = local.named_value_id
+ resource_group_name = azurerm_resource_group.main.name
+ api_management_name = azurerm_api_management.main.name
+ secret = true
+ value = random_password.backend_secret.result
+}
+
+# The API is imported from the OpenAPI document shared with the other deployment variants, so its
+# operations come from the document. The backend is the Function App: the emulator answers on
+# plain HTTP under its own hostname (use https:// on real Azure).
+resource "azurerm_api_management_api" "inventory" {
+ name = local.api_id
+ resource_group_name = azurerm_resource_group.main.name
+ api_management_name = azurerm_api_management.main.name
+ revision = "1"
+ display_name = "Inventory API"
+ description = "Stock levels served by an Azure Function App and published through Azure API Management."
+ path = local.api_path
+ protocols = ["https"]
+ service_url = "http://${azurerm_linux_function_app.inventory.default_hostname}/api"
+ subscription_required = true
+
+ import {
+ content_format = "openapi+json"
+ content_value = file("${path.module}/../apim/openapi.json")
+ }
+}
+
+# Policies are validated when they are saved, and this one references the named value, so the
+# named value has to exist first.
+resource "azurerm_api_management_api_policy" "inventory" {
+ api_name = azurerm_api_management_api.inventory.name
+ api_management_name = azurerm_api_management.main.name
+ resource_group_name = azurerm_resource_group.main.name
+ xml_content = file("${path.module}/../apim/inventory-api-policy.xml")
+
+ depends_on = [azurerm_api_management_named_value.backend_secret]
+}
+
+resource "azurerm_api_management_product" "partners" {
+ product_id = local.product_id
+ api_management_name = azurerm_api_management.main.name
+ resource_group_name = azurerm_resource_group.main.name
+ display_name = "Inventory Partners"
+ description = "Partners reading stock levels through the Inventory API"
+ subscription_required = true
+ approval_required = false
+ published = true
+}
+
+resource "azurerm_api_management_product_api" "partners_inventory" {
+ api_name = azurerm_api_management_api.inventory.name
+ product_id = azurerm_api_management_product.partners.product_id
+ api_management_name = azurerm_api_management.main.name
+ resource_group_name = azurerm_resource_group.main.name
+}
+
+# The subscription's key is what clients present. Its scope is the product, so the key opens
+# every API the product contains and nothing else.
+resource "azurerm_api_management_subscription" "partner" {
+ api_management_name = azurerm_api_management.main.name
+ resource_group_name = azurerm_resource_group.main.name
+ subscription_id = local.subscription_id
+ display_name = "Partner subscription"
+ product_id = azurerm_api_management_product.partners.id
+ state = "active"
+}
diff --git a/samples/api-management-function-app/python/terraform/outputs.tf b/samples/api-management-function-app/python/terraform/outputs.tf
new file mode 100644
index 0000000..8aa509c
--- /dev/null
+++ b/samples/api-management-function-app/python/terraform/outputs.tf
@@ -0,0 +1,31 @@
+output "resource_group_name" {
+ value = azurerm_resource_group.main.name
+}
+
+output "function_app_name" {
+ value = azurerm_linux_function_app.inventory.name
+}
+
+output "function_app_host" {
+ value = azurerm_linux_function_app.inventory.default_hostname
+}
+
+output "apim_name" {
+ value = azurerm_api_management.main.name
+}
+
+output "gateway_url" {
+ value = azurerm_api_management.main.gateway_url
+}
+
+output "api_path" {
+ value = azurerm_api_management_api.inventory.path
+}
+
+output "product_id" {
+ value = azurerm_api_management_product.partners.product_id
+}
+
+output "subscription_id" {
+ value = azurerm_api_management_subscription.partner.subscription_id
+}
diff --git a/samples/api-management-function-app/python/terraform/providers.tf b/samples/api-management-function-app/python/terraform/providers.tf
new file mode 100644
index 0000000..95bbc9d
--- /dev/null
+++ b/samples/api-management-function-app/python/terraform/providers.tf
@@ -0,0 +1,30 @@
+terraform {
+ required_version = ">=1.0"
+
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "=5.1.0"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "=3.9.0"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {
+ resource_group {
+ prevent_deletion_if_contains_resources = false
+ }
+ }
+
+ # Set the hostname of the Azure Metadata Service (for example management.azure.com)
+ # used to obtain the Cloud Environment when using LocalStack's Azure emulator.
+ # This allows the provider to correctly identify the environment and avoid making calls to the real Azure endpoints.
+ metadata_host = "localhost.localstack.cloud:4566"
+
+ # Set the subscription ID to a dummy value when using LocalStack's Azure emulator.
+ subscription_id = "00000000-0000-0000-0000-000000000000"
+}
diff --git a/samples/api-management-function-app/python/terraform/terraform.tfvars b/samples/api-management-function-app/python/terraform/terraform.tfvars
new file mode 100644
index 0000000..f1d7e7c
--- /dev/null
+++ b/samples/api-management-function-app/python/terraform/terraform.tfvars
@@ -0,0 +1,3 @@
+prefix = "local"
+suffix = "test"
+location = "westeurope"
diff --git a/samples/api-management-function-app/python/terraform/variables.tf b/samples/api-management-function-app/python/terraform/variables.tf
new file mode 100644
index 0000000..d2b710f
--- /dev/null
+++ b/samples/api-management-function-app/python/terraform/variables.tf
@@ -0,0 +1,47 @@
+variable "prefix" {
+ description = "Prefix applied to every resource name in this sample."
+ type = string
+ default = "local"
+}
+
+variable "suffix" {
+ description = "Suffix applied to every resource name in this sample."
+ type = string
+ default = "test"
+}
+
+variable "location" {
+ description = "Azure region for all resources."
+ type = string
+ default = "westeurope"
+}
+
+variable "app_service_plan_sku" {
+ description = "SKU of the Linux App Service plan hosting the Function App."
+ type = string
+ default = "B1"
+}
+
+variable "python_version" {
+ description = "Python version of the Function App."
+ type = string
+ default = "3.11"
+}
+
+variable "apim_sku_name" {
+ description = "SKU of the API Management service, as _. Consumption provisions in minutes; the classic tiers take much longer."
+ type = string
+ default = "Consumption_0"
+}
+
+variable "publisher_name" {
+ description = "Name of the organisation publishing the APIs."
+ type = string
+ default = "LocalStack"
+}
+
+variable "publisher_email" {
+ description = "E-mail address API Management sends its notifications to."
+ type = string
+ default = "noreply@localstack.cloud"
+}