Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions run-samples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
198 changes: 198 additions & 0 deletions samples/api-management-function-app/python/README.md
Original file line number Diff line number Diff line change
@@ -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<br/>Inventory Partners product"]
policy["Inventory API policy<br/>cors · rate-limit · set-header"]
nv["Named value<br/>backend-secret (secret)"]
keycheck --> policy
nv -.->|"substituted into the policy"| policy
end

subgraph functionapp["Function App (Python)"]
routes["GET /api/items<br/>GET /api/items/{id}<br/>GET /api/whoami"]
end

client -->|"1: GET /inventory/items<br/>Ocp-Apim-Subscription-Key"| keycheck
policy -->|"2: + X-Backend-Secret<br/>+ X-Caller-Subscription<br/>- 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=<your_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://<name>.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://<name>.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://<function app host>/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/)
Loading
Loading