A pattern I like a lot for teams running Azure API Management in front of Azure Functions is treating APIM as the single source of truth for the API contract, not the function app itself. The catch is keeping that contract in sync as the function code changes. Doing this by hand, exporting a swagger file and pasting it into APIM every release, is exactly the kind of manual step that gets skipped under deadline pressure and quietly drifts out of date.
This walkthrough covers generating the OpenAPI document straight from a running Azure Functions app inside a GitHub Actions workflow, and pushing it into API Management automatically using Bicep, so the contract stays current every time the pipeline runs.
Generating the OpenAPI Document with the Right Server URL
The Azure Functions OpenAPI extension can generate a swagger document from a running function app. The trick is that starting the function app locally inside the CI runner produces a document pointing at localhost, which is useless once the API is actually deployed. The extension exposes two environment variables specifically to fix this without needing a real deployment first.
- name: Generate OpenAPI document
shell: pwsh
env:
OpenApi__HostNames: 'https://<azure-functions-app>.azurewebsites.net/api'
AZURE_FUNCTIONS_ENVIRONMENT: 'Production'
run: |
cd MyFunctionApp
mkdir outputs
# local.settings.json is normally gitignored, but the function
# host needs it present to start up as a background process
cp ./local.settings.sample.json ./local.settings.json
Start-Process -NoNewWindow func @("start","--verbose","false")
Start-Sleep -s 60
Invoke-RestMethod -Method Get -Uri http://localhost:7071/api/swagger.json |
ConvertTo-Json -Depth 100 | Out-File -FilePath outputs/swagger.json -Force
cd ..
OpenApi__HostNames adds the real deployed URL on top of localhost in the generated document, and AZURE_FUNCTIONS_ENVIRONMENT set to Production tells the extension to drop localhost from the output entirely and render only the real server URL, as if the function app were already running on Azure. Between the two, you get a correct, deployment-ready OpenAPI document from a function app that was only ever started locally inside the CI runner.
The 60 second sleep after starting the function host looks arbitrary, and to some extent it is, cold start time varies by app size and runtime. If your generation step starts failing intermittently with an empty or missing swagger.json, that sleep duration is usually the first thing worth increasing before looking anywhere else.
One more detail that trips people up: local.settings.json is normally excluded from source control on purpose, since it can carry local secrets. The workaround here of copying a checked-in local.settings.sample.json works, but only if that sample file’s contents are genuinely safe to commit. At minimum it needs FUNCTIONS_WORKER_RUNTIME set correctly for your app, or the function host will not start at all.
{
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet"
}
}
Once this is right, the generated document reflects the actual deployed URL, both for OpenAPI v2 and v3 output.
// OpenAPI v3
{
"openapi": "3.0.1",
"servers": [
{ "url": "https://<azure-functions-app>.azurewebsites.net/api" }
]
}
Declaring the API in APIM Using Bicep
With a correct OpenAPI document in hand, the next step is getting it into API Management. Bicep handles this cleanly, referencing the existing APIM instance and declaring a new API resource against it.
// azuredeploy.bicep
param servicename string
resource apim 'Microsoft.ApiManagement/service@2021-08-01' existing = {
name: servicename
}
param openapidoc string
resource apimapi 'Microsoft.ApiManagement/service/apis@2021-08-01' = {
name: '${apim.name}/my-api'
properties: {
type: 'http'
displayName: 'My API'
description: 'This is my API.'
path: 'myapi'
subscriptionRequired: true
format: 'openapi+json-link'
value: openapidoc
}
}
The format and value properties are where most of the confusion happens, since Bicep supports two genuinely different ways of feeding APIM an OpenAPI spec, and the naming does not make the distinction obvious at a glance. You can either inline the actual OpenAPI JSON as a string using swagger-json or openapi+json as the format, or point at a publicly accessible URL hosting the document using swagger-link-json or openapi+json-link.
Inlining the JSON string is technically possible but not worth the trouble, escaping a full OpenAPI document into a Bicep string parameter is fragile and painful to debug when it breaks. Uploading the generated document to Azure Blob Storage right after the generation step, and pointing the link format at that blob URL, is the cleaner path and the one I would default to.
$servicename = "<my_apim_service_name>"
$openapidoc = "https://<my_blob_storage_name>.blob.core.windows.net/<container>/openapi.json"
az deployment group create `
-g <resource_group_name> `
-n <deployment_name> `
-f ./azuredeploy.bicep `
-p servicename=$servicename `
-p openapidoc=$openapidoc
Run this deployment step right after the OpenAPI generation step in the same pipeline, and every push that changes the function’s API surface automatically refreshes the contract published in APIM. No one has to remember to update it separately, because there is no separate manual step left to forget.
Where to Take This Next
This pipeline keeps the APIM contract current, but it does not verify that the new contract is still compatible with whatever is already calling the API. A function app change that renames a field or tightens a required parameter will publish just as happily as a safe, additive change, and APIM will not stop you.
The natural extension is a contract-testing step between generation and publishing, diffing the newly generated OpenAPI document against the previously published one and failing the pipeline on breaking changes, removed fields, changed types, newly required parameters, before it ever reaches APIM. That check belongs as its own pipeline stage with its own gate, not bolted onto the publish step, since you want a clear failure reason when a contract check blocks a release rather than a generic deployment error.
Whether you gate that on every push or only on pushes to a release branch depends on how much friction your team can tolerate on routine commits versus how much protection you want before something reaches consumers. I would start by gating only the deployment stage that promotes to APIM, and leave earlier stages free to fail fast on build and unit tests instead.
Leave a Reply