From 22252a97979d7e87823b23bd6f91884dca05fd5b Mon Sep 17 00:00:00 2001 From: "Dris.S" <53239451+DrisDary@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:35:50 +0100 Subject: [PATCH 1/5] Add an API Management and Function App sample (gateway, subscription keys, policies, named values) --- README.md | 6 + run-samples.sh | 8 + .../python/README.md | 198 +++++++++ .../python/apim/inventory-api-policy.xml | 53 +++ .../python/apim/openapi.json | 89 ++++ .../python/bicep/README.md | 25 ++ .../python/bicep/deploy.sh | 148 +++++++ .../python/bicep/main.bicep | 206 +++++++++ .../python/bicep/main.bicepparam | 7 + .../python/function/function_app.py | 81 ++++ .../python/function/host.json | 7 + .../python/function/requirements.txt | 1 + .../python/scripts/README.md | 19 + .../python/scripts/call-api.sh | 87 ++++ .../python/scripts/deploy.sh | 409 ++++++++++++++++++ .../python/scripts/validate.sh | 248 +++++++++++ .../python/terraform/README.md | 29 ++ .../python/terraform/deploy.sh | 123 ++++++ .../python/terraform/main.tf | 162 +++++++ .../python/terraform/outputs.tf | 31 ++ .../python/terraform/providers.tf | 30 ++ .../python/terraform/terraform.tfvars | 3 + .../python/terraform/variables.tf | 47 ++ 23 files changed, 2017 insertions(+) create mode 100644 samples/api-management-function-app/python/README.md create mode 100644 samples/api-management-function-app/python/apim/inventory-api-policy.xml create mode 100644 samples/api-management-function-app/python/apim/openapi.json create mode 100644 samples/api-management-function-app/python/bicep/README.md create mode 100755 samples/api-management-function-app/python/bicep/deploy.sh create mode 100644 samples/api-management-function-app/python/bicep/main.bicep create mode 100644 samples/api-management-function-app/python/bicep/main.bicepparam create mode 100644 samples/api-management-function-app/python/function/function_app.py create mode 100644 samples/api-management-function-app/python/function/host.json create mode 100644 samples/api-management-function-app/python/function/requirements.txt create mode 100644 samples/api-management-function-app/python/scripts/README.md create mode 100755 samples/api-management-function-app/python/scripts/call-api.sh create mode 100755 samples/api-management-function-app/python/scripts/deploy.sh create mode 100755 samples/api-management-function-app/python/scripts/validate.sh create mode 100644 samples/api-management-function-app/python/terraform/README.md create mode 100755 samples/api-management-function-app/python/terraform/deploy.sh create mode 100644 samples/api-management-function-app/python/terraform/main.tf create mode 100644 samples/api-management-function-app/python/terraform/outputs.tf create mode 100644 samples/api-management-function-app/python/terraform/providers.tf create mode 100644 samples/api-management-function-app/python/terraform/terraform.tfvars create mode 100644 samples/api-management-function-app/python/terraform/variables.tf diff --git a/README.md b/README.md index dbc575b..a510bf3 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,12 @@ Each sample is a self-contained project with its own README, Azure CLI scripts a | Sample | Description | |--------|-------------| | [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. | +<<<<<<< HEAD | [Function App and Front Door (Python)](./samples/function-app-front-door/python/README.md) | Two Python Function Apps serving a small *Catalog* API, published through an Azure Front Door (Standard) endpoint: the edge picks an origin by priority, matches the more specific of two routes, caches what the origin allows, and runs a rule set that stamps a response header, rewrites `/shop` to `/catalog` and redirects a retired path without calling the origin at all. | +======= +| [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. | +>>>>>>> d35cb2e (Add an API Management and Function App sample (gateway, subscription keys, policies, named values)) | [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 +88,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 ee54b33..bd3a1a1 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -32,7 +32,12 @@ SAMPLES=( "samples/servicebus/java|bash scripts/deploy.sh" "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" +<<<<<<< HEAD "samples/function-app-front-door/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-front-door.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" +>>>>>>> d35cb2e (Add an API Management and Function App sample (gateway, subscription keys, policies, named values)) "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 +66,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 +92,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 +145,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 + + +
*
+
+ +
X-Served-By
+
+
+ + + + + + {{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" +} From 2f72a2a0a5e42dc24825d5bef9ba4a17e45d8140 Mon Sep 17 00:00:00 2001 From: "Dris.S" <53239451+DrisDary@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:36:20 +0100 Subject: [PATCH 2/5] Address review: operationId casing, query-parameter key strip, Always On, backend scheme as an input --- .../python/README.md | 8 ++--- .../python/apim/inventory-api-policy.xml | 5 +++ .../python/bicep/main.bicep | 16 +++++++-- .../python/function/function_app.py | 5 ++- .../python/scripts/call-api.sh | 6 ++-- .../python/scripts/deploy.sh | 4 ++- .../python/scripts/validate.sh | 33 ++++++++++++++++--- .../python/terraform/main.tf | 6 +++- .../python/terraform/variables.tf | 11 +++++++ 9 files changed, 78 insertions(+), 16 deletions(-) diff --git a/samples/api-management-function-app/python/README.md b/samples/api-management-function-app/python/README.md index af7f098..c6a91b9 100644 --- a/samples/api-management-function-app/python/README.md +++ b/samples/api-management-function-app/python/README.md @@ -15,7 +15,7 @@ The solution is composed of the following Azure resources: 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 [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 and tell the backend which subscription is calling (`@(context.Subscription.Id)`), a pair that strips the subscription key in both forms a client may send it -- the header and the `subscription-key` query parameter -- and an outbound header marking responses that came through the gateway. - 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 @@ -108,10 +108,10 @@ 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. +2. The OpenAPI import produced the three operations. Matched case-insensitively: API Management normalises `operationId` into the operation's name and lower-cases it, so real Azure stores `listitems`/`getitem`/`whoami` while the emulator keeps the document's casing. 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`. +5. `whoAmI` shows what the backend received: the injected secret, the calling subscription in `X-Caller-Subscription`, and no `Ocp-Apim-Subscription-Key`. The same holds for a key passed as the `subscription-key` query parameter: it authenticates the call and is stripped from the URL the function sees. 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. @@ -181,7 +181,7 @@ az apim deletedservice purge --service-name local-inventory-apim-test --location ## 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. +- **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. Terraform and Bicep take it as an input that defaults to the emulator's `http`, so a real deployment overrides one value rather than editing the template — `terraform apply -var backend_scheme=https` or `az deployment group create --parameters backendScheme=https`. The Bicep variant derives the Function App's `httpsOnly` from the same parameter, so the app stops accepting plain HTTP in the same step. - **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. 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 index 0f4c9c8..0e029a5 100644 --- a/samples/api-management-function-app/python/apim/inventory-api-policy.xml +++ b/samples/api-management-function-app/python/apim/inventory-api-policy.xml @@ -37,6 +37,11 @@ @(context.Subscription.Id) + + diff --git a/samples/api-management-function-app/python/bicep/main.bicep b/samples/api-management-function-app/python/bicep/main.bicep index 7ea6dcc..ce59463 100644 --- a/samples/api-management-function-app/python/bicep/main.bicep +++ b/samples/api-management-function-app/python/bicep/main.bicep @@ -29,6 +29,13 @@ param publisherName string = 'LocalStack' @description('E-mail address API Management sends its notifications to.') param publisherEmail string = 'noreply@localstack.cloud' +@description('Scheme API Management uses to call the Function App. The emulator serves it over plain HTTP; use https on real Azure.') +@allowed([ + 'http' + 'https' +]) +param backendScheme string = 'http' + @description('Shared secret the gateway adds to every backend call; generated per run by deploy.sh.') @secure() param backendSecret string @@ -82,10 +89,15 @@ resource functionApp 'Microsoft.Web/sites@2024-11-01' = { location: location kind: 'functionapp,linux' properties: { - httpsOnly: false + // Plain HTTP while the gateway calls this app over http:// -- the emulator serves the Function + // App that way; with backendScheme = https the app accepts only HTTPS, as on real Azure. + httpsOnly: backendScheme == 'https' reserved: true serverFarmId: appServicePlan.id siteConfig: { + // On a Dedicated (App Service) plan the Functions host goes idle without this, and a gateway + // call then waits for a cold start. Both sibling Function App samples set it. + alwaysOn: true linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}') appSettings: [ { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' } @@ -144,7 +156,7 @@ resource inventoryApi 'Microsoft.ApiManagement/service/apis@2024-05-01' = { 'https' ] subscriptionRequired: true - serviceUrl: 'http://${functionApp.properties.defaultHostName}/api' + serviceUrl: '${backendScheme}://${functionApp.properties.defaultHostName}/api' format: 'openapi+json' value: loadTextContent('../apim/openapi.json') } diff --git a/samples/api-management-function-app/python/function/function_app.py b/samples/api-management-function-app/python/function/function_app.py index 6aafb25..9df0469 100644 --- a/samples/api-management-function-app/python/function/function_app.py +++ b/samples/api-management-function-app/python/function/function_app.py @@ -27,7 +27,10 @@ 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): + # Encoded before comparing: compare_digest refuses str operands that are not ASCII-only, and a + # client can put any bytes in the header, so comparing the strings turns an intended 401 into an + # unhandled 500. + if expected and hmac.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8")): return None return json_response( { diff --git a/samples/api-management-function-app/python/scripts/call-api.sh b/samples/api-management-function-app/python/scripts/call-api.sh index 820f0f4..cd76b7b 100755 --- a/samples/api-management-function-app/python/scripts/call-api.sh +++ b/samples/api-management-function-app/python/scripts/call-api.sh @@ -8,8 +8,10 @@ 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' +# mktemp rather than fixed paths, as in validate.sh: parallel-safe, and cleaned up on exit. +BODY_FILE="$(mktemp "${TMPDIR:-/tmp}/inventory_call.XXXXXX.json")" +HEADERS_FILE="$(mktemp "${TMPDIR:-/tmp}/inventory_call_headers.XXXXXX.txt")" +trap 'rm -f "$BODY_FILE" "$HEADERS_FILE"' EXIT # Retrieve the API Management service echo "Retrieving the [$APIM_NAME] API Management service..." diff --git a/samples/api-management-function-app/python/scripts/deploy.sh b/samples/api-management-function-app/python/scripts/deploy.sh index a89d582..59cda5c 100755 --- a/samples/api-management-function-app/python/scripts/deploy.sh +++ b/samples/api-management-function-app/python/scripts/deploy.sh @@ -309,7 +309,9 @@ fi # * 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}}') +# rawxml, matching bicep/main.bicep: the shared document is uploaded verbatim, so a policy +# expression containing &&, < or & needs no XML escaping and cannot break this variant alone. +POLICY_BODY=$(jq -n --rawfile xml "$APIM_DIR/inventory-api-policy.xml" '{properties: {format: "rawxml", value: $xml}}') az rest --method get --url "$POLICY_URL" &>/dev/null if [[ $? == 0 ]]; then diff --git a/samples/api-management-function-app/python/scripts/validate.sh b/samples/api-management-function-app/python/scripts/validate.sh index ac49cd9..452e478 100755 --- a/samples/api-management-function-app/python/scripts/validate.sh +++ b/samples/api-management-function-app/python/scripts/validate.sh @@ -11,8 +11,11 @@ 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' +# mktemp rather than fixed paths: two runs of this script must not write the same file, and +# `curl -o` would follow a symlink a third party had left at a predictable name. +BODY_FILE="$(mktemp "${TMPDIR:-/tmp}/inventory_body.XXXXXX.json")" +HEADERS_FILE="$(mktemp "${TMPDIR:-/tmp}/inventory_headers.XXXXXX.txt")" +trap 'rm -f "$BODY_FILE" "$HEADERS_FILE"' EXIT FAILED=0 @@ -69,8 +72,13 @@ OPERATIONS=$(az apim api operation list \ --query "[].name" \ --output tsv | sort | tr '\n' ' ') echo "Operations: $OPERATIONS" -for OPERATION in getItem listItems whoAmI; do - if ! echo "$OPERATIONS" | grep -qw "$OPERATION"; then +# API Management normalises operationId into the operation's resource name, and the first rule is +# "convert to lower case", so a real import of apim/openapi.json produces listitems/getitem/whoami +# while the emulator preserves the document's casing. Matched case-insensitively so the same check +# holds on both. +# https://learn.microsoft.com/azure/api-management/api-management-api-import-restrictions +for OPERATION in getitem listitems whoami; do + if ! echo "$OPERATIONS" | grep -qwi "$OPERATION"; then echo "Operation [$OPERATION] was not imported" FAILED=1 fi @@ -180,6 +188,19 @@ else FAILED=1 fi +# 8b. The key passed as the query parameter is also stripped before the backend. The policy +# deletes both forms, so neither reaches the function nor its logs. +echo "Calling [$API_URL/whoami] with the key as the subscription-key query parameter..." +QUERY_KEY_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" "$API_URL/whoami?subscription-key=$KEY") +FORWARDED_URL=$(jq -r '.url // ""' "$BODY_FILE" 2>/dev/null) +echo "HTTP $QUERY_KEY_STATUS, backend saw: $FORWARDED_URL" +if [[ "$QUERY_KEY_STATUS" == "200" && "$FORWARDED_URL" != *"subscription-key"* ]]; then + echo "The query-parameter key authenticated the call and was stripped before the backend" +else + echo "Expected 200 with no subscription-key in the URL the backend received" + 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, @@ -206,7 +227,9 @@ fi 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 +# Case-insensitive like the two 401 checks above: Microsoft's own troubleshooting page renders +# this as "Resource Not Found" while the JSON body is conventionally lower case. +if [[ "$UNKNOWN_STATUS" == "404" ]] && grep -qi "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" diff --git a/samples/api-management-function-app/python/terraform/main.tf b/samples/api-management-function-app/python/terraform/main.tf index 6d9261a..1da5a25 100644 --- a/samples/api-management-function-app/python/terraform/main.tf +++ b/samples/api-management-function-app/python/terraform/main.tf @@ -57,6 +57,10 @@ resource "azurerm_linux_function_app" "inventory" { functions_extension_version = "~4" site_config { + # On a Dedicated (App Service) plan the Functions host goes idle without this, and a gateway + # call then waits for a cold start. Both sibling Function App samples set it. + always_on = true + application_stack { python_version = var.python_version } @@ -112,7 +116,7 @@ resource "azurerm_api_management_api" "inventory" { 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" + service_url = "${var.backend_scheme}://${azurerm_linux_function_app.inventory.default_hostname}/api" subscription_required = true import { diff --git a/samples/api-management-function-app/python/terraform/variables.tf b/samples/api-management-function-app/python/terraform/variables.tf index d2b710f..c7dc6d6 100644 --- a/samples/api-management-function-app/python/terraform/variables.tf +++ b/samples/api-management-function-app/python/terraform/variables.tf @@ -28,6 +28,17 @@ variable "python_version" { default = "3.11" } +variable "backend_scheme" { + description = "Scheme API Management uses to call the Function App. The emulator serves it over plain HTTP; use https on real Azure." + type = string + default = "http" + + validation { + condition = contains(["http", "https"], var.backend_scheme) + error_message = "backend_scheme must be http or https." + } +} + variable "apim_sku_name" { description = "SKU of the API Management service, as _. Consumption provisions in minutes; the classic tiers take much longer." type = string From 7d7b1241714edece7ddef89513afbcaf886b6e60 Mon Sep 17 00:00:00 2001 From: "Dris.S" <53239451+DrisDary@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:59:54 +0100 Subject: [PATCH 3/5] Make all three provisioning paths work against real Azure --- .../python/README.md | 2 +- .../python/bicep/deploy.sh | 43 +++++++++++++++---- .../python/bicep/main.bicepparam | 8 +++- .../python/scripts/call-api.sh | 7 ++- .../python/scripts/deploy.sh | 7 ++- .../python/scripts/validate.sh | 16 ++++--- .../python/terraform/README.md | 7 ++- .../python/terraform/deploy.sh | 24 +++++++++-- .../python/terraform/providers.tf | 19 +++++--- 9 files changed, 100 insertions(+), 33 deletions(-) diff --git a/samples/api-management-function-app/python/README.md b/samples/api-management-function-app/python/README.md index c6a91b9..9171787 100644 --- a/samples/api-management-function-app/python/README.md +++ b/samples/api-management-function-app/python/README.md @@ -108,7 +108,7 @@ 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. Matched case-insensitively: API Management normalises `operationId` into the operation's name and lower-cases it, so real Azure stores `listitems`/`getitem`/`whoami` while the emulator keeps the document's casing. +2. The OpenAPI import produced the three operations, matched case-insensitively. API Management normalises `operationId` into the operation's name — it replaces characters that are not allowed and truncates at 76 — and Microsoft's import-restrictions page also lists lower-casing, though a deployment to real Azure kept the casing (`getItems` stayed `getItems`, `Get Items` became `Get-Items`). The check does not depend on either behaviour. 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`. The same holds for a key passed as the `subscription-key` query parameter: it authenticates the call and is stripped from the URL the function sees. diff --git a/samples/api-management-function-app/python/bicep/deploy.sh b/samples/api-management-function-app/python/bicep/deploy.sh index c80d16f..97dddea 100755 --- a/samples/api-management-function-app/python/bicep/deploy.sh +++ b/samples/api-management-function-app/python/bicep/deploy.sh @@ -1,8 +1,11 @@ #!/bin/bash # Variables -PREFIX='local' -SUFFIX='test' +# Overridable so the same scripts can deploy to real Azure, where the API Management service, the +# storage account and the Function App all need globally unique names: +# PREFIX=apimdemo SUFFIX=$RANDOM bash deploy.sh +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" LOCATION='westeurope' RESOURCE_GROUP_NAME="${PREFIX}-rg" DEPLOYMENT_NAME='api-management-function-app' @@ -60,6 +63,17 @@ if [[ $? != 0 ]]; then fi fi +# main.bicepparam reads all four of these from the environment. The scheme is picked the way +# scripts/deploy.sh picks it: the emulator serves the Function App over plain HTTP, real Azure over +# HTTPS, and the template derives the app's httpsOnly from the same value. +ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv) +if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then + export BACKEND_SCHEME='http' +else + export BACKEND_SCHEME='https' +fi +export PREFIX SUFFIX + # 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. @@ -82,12 +96,12 @@ fi # Deploy the Bicep template echo "Deploying the [$TEMPLATE] Bicep template..." -DEPLOYMENT_OUTPUTS=$(az deployment group create \ +az deployment group create \ --name $DEPLOYMENT_NAME \ --resource-group $RESOURCE_GROUP_NAME \ --template-file $TEMPLATE \ --parameters $PARAMETERS \ - --query properties.outputs) + --output none if [[ $? == 0 ]]; then echo "[$TEMPLATE] Bicep template successfully deployed" @@ -96,11 +110,22 @@ else 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) +# Each output is read back on its own rather than parsed out of the create's stdout: the Azure CLI +# prefixes that stdout with lines such as "Bicep CLI is already installed at ...", which is not JSON, +# so piping it into jq fails and every name comes back empty. Seen on real Azure, not on the emulator. +deployment_output() { + az deployment group show \ + --name $DEPLOYMENT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "properties.outputs.$1.value" \ + --output tsv +} + +FUNCTION_APP_NAME=$(deployment_output functionAppName) +APIM_NAME=$(deployment_output apimName) +API_PATH=$(deployment_output apiPath) +APIM_SUBSCRIPTION_ID=$(deployment_output subscriptionName) +GATEWAY_URL=$(deployment_output gatewayUrl) if [[ -z "$FUNCTION_APP_NAME" || -z "$APIM_NAME" ]]; then echo "Function App Name or API Management Name is empty. Exiting." diff --git a/samples/api-management-function-app/python/bicep/main.bicepparam b/samples/api-management-function-app/python/bicep/main.bicepparam index 8152cf4..950fd99 100644 --- a/samples/api-management-function-app/python/bicep/main.bicepparam +++ b/samples/api-management-function-app/python/bicep/main.bicepparam @@ -1,7 +1,11 @@ using 'main.bicep' -param prefix = 'local' -param suffix = 'test' +// deploy.sh exports these; the defaults are what an emulator run uses, and a cloud run overrides +// PREFIX/SUFFIX because the API Management service, storage account and Function App names are +// globally unique on Azure. +param prefix = readEnvironmentVariable('PREFIX', 'local') +param suffix = readEnvironmentVariable('SUFFIX', 'test') +param backendScheme = readEnvironmentVariable('BACKEND_SCHEME', 'http') // 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/scripts/call-api.sh b/samples/api-management-function-app/python/scripts/call-api.sh index cd76b7b..355a027 100755 --- a/samples/api-management-function-app/python/scripts/call-api.sh +++ b/samples/api-management-function-app/python/scripts/call-api.sh @@ -1,8 +1,11 @@ #!/bin/bash # Variables -PREFIX='local' -SUFFIX='test' +# Overridable so the same scripts can deploy to real Azure, where the API Management service, the +# storage account and the Function App all need globally unique names: +# PREFIX=apimdemo SUFFIX=$RANDOM bash scripts/deploy.sh +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" RESOURCE_GROUP_NAME="${PREFIX}-rg" APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}" APIM_API_VERSION='2022-08-01' diff --git a/samples/api-management-function-app/python/scripts/deploy.sh b/samples/api-management-function-app/python/scripts/deploy.sh index 59cda5c..99c085b 100755 --- a/samples/api-management-function-app/python/scripts/deploy.sh +++ b/samples/api-management-function-app/python/scripts/deploy.sh @@ -1,8 +1,11 @@ #!/bin/bash # Variables -PREFIX='local' -SUFFIX='test' +# Overridable so the same scripts can deploy to real Azure, where the API Management service, the +# storage account and the Function App all need globally unique names: +# PREFIX=apimdemo SUFFIX=$RANDOM bash scripts/deploy.sh +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" LOCATION='westeurope' RESOURCE_GROUP_NAME="${PREFIX}-rg" STORAGE_ACCOUNT_NAME="${PREFIX}invstorage${SUFFIX}" diff --git a/samples/api-management-function-app/python/scripts/validate.sh b/samples/api-management-function-app/python/scripts/validate.sh index 452e478..e6025e2 100755 --- a/samples/api-management-function-app/python/scripts/validate.sh +++ b/samples/api-management-function-app/python/scripts/validate.sh @@ -1,8 +1,11 @@ #!/bin/bash # Variables -PREFIX='local' -SUFFIX='test' +# Overridable so the same scripts can deploy to real Azure, where the API Management service, the +# storage account and the Function App all need globally unique names: +# PREFIX=apimdemo SUFFIX=$RANDOM bash scripts/deploy.sh +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" RESOURCE_GROUP_NAME="${PREFIX}-rg" FUNCTION_APP_NAME="${PREFIX}-inventory-functionapp-${SUFFIX}" APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}" @@ -72,10 +75,11 @@ OPERATIONS=$(az apim api operation list \ --query "[].name" \ --output tsv | sort | tr '\n' ' ') echo "Operations: $OPERATIONS" -# API Management normalises operationId into the operation's resource name, and the first rule is -# "convert to lower case", so a real import of apim/openapi.json produces listitems/getitem/whoami -# while the emulator preserves the document's casing. Matched case-insensitively so the same check -# holds on both. +# API Management normalises operationId into the operation's resource name: it replaces characters +# that are not allowed and truncates at 76. Microsoft's import-restrictions page also lists "convert +# to lower case", but a probe against real Azure kept the casing -- getItems stayed getItems, and +# "Get Items" became "Get-Items" -- so this is matched case-insensitively rather than depending on +# either behaviour. # https://learn.microsoft.com/azure/api-management/api-management-api-import-restrictions for OPERATION in getitem listitems whoami; do if ! echo "$OPERATIONS" | grep -qwi "$OPERATION"; then diff --git a/samples/api-management-function-app/python/terraform/README.md b/samples/api-management-function-app/python/terraform/README.md index cbd74c9..be6d4cb 100644 --- a/samples/api-management-function-app/python/terraform/README.md +++ b/samples/api-management-function-app/python/terraform/README.md @@ -17,7 +17,12 @@ This directory contains the Terraform configuration for the sample. For details 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. +The script runs `terraform init`, `plan` and `apply`, then deploys the function from a zip package with the Azure CLI. It picks its target from `az account show`: against the emulator it exports `ARM_METADATA_HOSTNAME` and `ARM_SUBSCRIPTION_ID` so the provider talks to LocalStack, and against real Azure it leaves them unset and uses the subscription the CLI is logged in to, with `backend_scheme=https`. Running `terraform` directly rather than through this script needs those two variables exported for an emulator deployment: + +```bash +export ARM_METADATA_HOSTNAME=localhost.localstack.cloud:4566 +export ARM_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 +``` 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 diff --git a/samples/api-management-function-app/python/terraform/deploy.sh b/samples/api-management-function-app/python/terraform/deploy.sh index ac1609d..f5367fe 100755 --- a/samples/api-management-function-app/python/terraform/deploy.sh +++ b/samples/api-management-function-app/python/terraform/deploy.sh @@ -1,8 +1,11 @@ #!/bin/bash # Variables -PREFIX='local' -SUFFIX='test' +# Overridable so the same scripts can deploy to real Azure, where the API Management service, the +# storage account and the Function App all need globally unique names: +# PREFIX=apimdemo SUFFIX=$RANDOM bash deploy.sh +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" LOCATION='westeurope' RESOURCE_GROUP_NAME="${PREFIX}-rg" APIM_NAME="${PREFIX}-inventory-apim-${SUFFIX}" @@ -13,6 +16,20 @@ CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" # Change the current directory to the script's directory cd "$CURRENT_DIR" || exit +# The emulator serves the Function App over plain HTTP under its own hostname; real Azure serves +# *.azurewebsites.net over HTTPS. Picked here the way scripts/deploy.sh picks it, so neither +# deployment needs the file edited. +ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv) +if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then + BACKEND_SCHEME='http' + # What points the azurerm provider at the emulator instead of the real Azure endpoints. + export ARM_METADATA_HOSTNAME='localhost.localstack.cloud:4566' + export ARM_SUBSCRIPTION_ID='00000000-0000-0000-0000-000000000000' +else + BACKEND_SCHEME='https' + export ARM_SUBSCRIPTION_ID=$(az account show --query id --output tsv) +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 Terraform's create # fail with a conflict. Purge it first, unless the instance is live and is simply being updated. @@ -43,7 +60,8 @@ echo "Planning Terraform deployment..." terraform plan -out=tfplan \ -var "prefix=$PREFIX" \ -var "suffix=$SUFFIX" \ - -var "location=$LOCATION" + -var "location=$LOCATION" \ + -var "backend_scheme=$BACKEND_SCHEME" if [[ $? != 0 ]]; then echo "Terraform plan failed. Exiting." diff --git a/samples/api-management-function-app/python/terraform/providers.tf b/samples/api-management-function-app/python/terraform/providers.tf index 95bbc9d..7fc1c96 100644 --- a/samples/api-management-function-app/python/terraform/providers.tf +++ b/samples/api-management-function-app/python/terraform/providers.tf @@ -20,11 +20,16 @@ provider "azurerm" { } } - # 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" + # Neither the metadata host nor the subscription is pinned here, so this one configuration + # deploys to both targets: + # + # * emulator - deploy.sh exports ARM_METADATA_HOSTNAME=localhost.localstack.cloud:4566 and + # ARM_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 when `az account show` reports the + # LocalStack cloud, which is what points the provider at the emulator and stops it calling the + # real Azure endpoints; + # * real Azure - both are left unset and the provider uses the subscription the Azure CLI is + # logged in to. + # + # Running `terraform` directly rather than through deploy.sh needs those two variables exported + # for an emulator deployment. } From 08c1dac2cdfe93729591a799b58de7530f26c632 Mon Sep 17 00:00:00 2001 From: "Dris.S" <53239451+DrisDary@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:55:43 +0100 Subject: [PATCH 4/5] Clear an inherited ARM_METADATA_HOSTNAME when targeting real Azure --- .../api-management-function-app/python/terraform/README.md | 2 +- .../api-management-function-app/python/terraform/deploy.sh | 6 ++++++ .../python/terraform/providers.tf | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/samples/api-management-function-app/python/terraform/README.md b/samples/api-management-function-app/python/terraform/README.md index be6d4cb..96dcb2e 100644 --- a/samples/api-management-function-app/python/terraform/README.md +++ b/samples/api-management-function-app/python/terraform/README.md @@ -17,7 +17,7 @@ This directory contains the Terraform configuration for the sample. For details bash deploy.sh ``` -The script runs `terraform init`, `plan` and `apply`, then deploys the function from a zip package with the Azure CLI. It picks its target from `az account show`: against the emulator it exports `ARM_METADATA_HOSTNAME` and `ARM_SUBSCRIPTION_ID` so the provider talks to LocalStack, and against real Azure it leaves them unset and uses the subscription the CLI is logged in to, with `backend_scheme=https`. Running `terraform` directly rather than through this script needs those two variables exported for an emulator deployment: +The script runs `terraform init`, `plan` and `apply`, then deploys the function from a zip package with the Azure CLI. It picks its target from `az account show`: against the emulator it exports `ARM_METADATA_HOSTNAME` and `ARM_SUBSCRIPTION_ID` so the provider talks to LocalStack. Against real Azure it *clears* `ARM_METADATA_HOSTNAME` — so a value inherited from an earlier emulator run in the same shell cannot redirect the provider — and exports `ARM_SUBSCRIPTION_ID` from the account the CLI is logged in to, since the pinned provider requires a subscription, with `backend_scheme=https`. Running `terraform` directly rather than through this script needs those two variables exported for an emulator deployment: ```bash export ARM_METADATA_HOSTNAME=localhost.localstack.cloud:4566 diff --git a/samples/api-management-function-app/python/terraform/deploy.sh b/samples/api-management-function-app/python/terraform/deploy.sh index f5367fe..9b5a249 100755 --- a/samples/api-management-function-app/python/terraform/deploy.sh +++ b/samples/api-management-function-app/python/terraform/deploy.sh @@ -27,6 +27,12 @@ if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then export ARM_SUBSCRIPTION_ID='00000000-0000-0000-0000-000000000000' else BACKEND_SCHEME='https' + # Cleared rather than merely not set: a value inherited from an earlier emulator deployment in + # the same shell would keep the provider doing metadata discovery against LocalStack while the + # CLI is on real Azure. + unset ARM_METADATA_HOSTNAME + # The pinned provider needs a subscription, and on real Azure it is whichever one the CLI is + # logged in to. export ARM_SUBSCRIPTION_ID=$(az account show --query id --output tsv) fi diff --git a/samples/api-management-function-app/python/terraform/providers.tf b/samples/api-management-function-app/python/terraform/providers.tf index 7fc1c96..fb9d8cc 100644 --- a/samples/api-management-function-app/python/terraform/providers.tf +++ b/samples/api-management-function-app/python/terraform/providers.tf @@ -27,8 +27,9 @@ provider "azurerm" { # ARM_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 when `az account show` reports the # LocalStack cloud, which is what points the provider at the emulator and stops it calling the # real Azure endpoints; - # * real Azure - both are left unset and the provider uses the subscription the Azure CLI is - # logged in to. + # * real Azure - deploy.sh clears ARM_METADATA_HOSTNAME, so the provider uses the public + # cloud's metadata endpoint, and exports ARM_SUBSCRIPTION_ID from the account the Azure CLI is + # logged in to, because the pinned provider version requires a subscription. # # Running `terraform` directly rather than through deploy.sh needs those two variables exported # for an emulator deployment. From 4193d67c3684ee1ccb9f700be8503e66ffdba674 Mon Sep 17 00:00:00 2001 From: "Dris.S" <53239451+DrisDary@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:35:36 +0100 Subject: [PATCH 5/5] Resolve the rebase conflict: keep main's Front Door rewrite and the API Management entries --- README.md | 4 ---- run-samples.sh | 4 ---- 2 files changed, 8 deletions(-) diff --git a/README.md b/README.md index a510bf3..320ec58 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,8 @@ Each sample is a self-contained project with its own README, Azure CLI scripts a | Sample | Description | |--------|-------------| | [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. | -<<<<<<< HEAD | [Function App and Front Door (Python)](./samples/function-app-front-door/python/README.md) | Two Python Function Apps serving a small *Catalog* API, published through an Azure Front Door (Standard) endpoint: the edge picks an origin by priority, matches the more specific of two routes, caches what the origin allows, and runs a rule set that stamps a response header, rewrites `/shop` to `/catalog` and redirects a retired path without calling the origin at all. | -======= -| [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. | ->>>>>>> d35cb2e (Add an API Management and Function App sample (gateway, subscription keys, policies, named values)) | [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. | diff --git a/run-samples.sh b/run-samples.sh index bd3a1a1..38498fa 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -32,12 +32,8 @@ SAMPLES=( "samples/servicebus/java|bash scripts/deploy.sh" "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" -<<<<<<< HEAD "samples/function-app-front-door/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-front-door.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" ->>>>>>> d35cb2e (Add an API Management and Function App sample (gateway, subscription keys, policies, named values)) "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"