Managing Azure API Versions Dynamically Using Terraform

When developing APIs for consumption from many callers you can’t just throw in new major or minor updates without impacting how they are calling the API. Doing this can cause major issues so you would normally develop APIs in things like Azure Web Applications with versioning. The callers of the APIs can then reference these versions to be certain they will not shift. When the caller is ready to update, they can change the version in a controlled manner. However, there is a problem when doing this in Azure API Management Service (APIM) with Terraform. In this post I will talk through the issues and how my solution can resolve these.

The Problem with APIM Versioning in Terraform

When developing the API for APIM you can reference a version, which will be part of the APIs URL. However, when you implement this in Terraform it holds the version in the state file. Therefore, when you change this version Terraform will try to delete the current version and redeploy.

resource "azurerm_api_management_api_version" "example" { 

  name                = "example-api-v1" 
  resource_group_name = azurerm_resource_group.example.name 
  api_management_name = azurerm_api_management.example.name
  api_name            = azurerm_api_management_api.example.name 

  version             = "v1" 
  display_name        = "Example API v1" 

  path                = "example/v1" 
  protocols           = ["https"] 

  import { 
    content_format = "swagger-link-json" 
    content_value  = "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json" 
  } 
}

Solution

To solve this we could have a replication of this resource per version, but you would be managing this manually and doesn’t create an easy approach to maintain it. Instead the design I have developed will not only be dynamically changed, but can work in line with the branching strategies versioning as well. This creates a binding between what is deployed in the APIM API version, to the source codes commit ID.

This is all achieved by using the azapi Terraform Provider to call into the APIM. As an overview, we use this provider to list out the existing API versions, then use this to calculate out what versions to retains and which to remove, before adding the new version each time. Let’s walk through…

First we will put in the default component we need beginning with the inputs and the Version Set. For this example I have put the inputs as locals, but this could also be Terraform Variables as well. We have declared the APIM default, the name prefix of the specific API that will be used in the filter, the new API version to set and finally how many versions to retain not including the new version.

## Input Examples
locals {
  apim_name = "cpexampleapim"
  apim_resource_group_name = "cp-example-rg"
  api_name_prefix       = "cp-api-example"
  api_version = "1.6.0"
  version_count_to_keep = 5
}

data "azurerm_api_management" "apim" {
  name                = local.apim_name
  resource_group_name = local.apim_resource_group_name
}

resource "azurerm_api_management_api_version_set" "main" {
  name                = "cp-example-set"
  resource_group_name = local.apim_resource_group_name
  api_management_name = local.apim_name
  display_name        = "CP-Example-Set"
  versioning_scheme   = "Query"
  version_query_name  = "api-version"
}

Using the APIM data resource and the azapi provider we can create a data resource to list all the APIs in the APIM. However, the API response contains all APIs with all their versions, which means we only want the API we are looking for so need to filter it down using the API name prefix.

data "azapi_resource_list" "apis" {
  type      = "Microsoft.ApiManagement/service/apis@2024-05-01"
  parent_id = data.azurerm_api_management.apim.id
  query_parameters = {
    "$filter" = ["contains(name ,'${local.api_name_prefix}')"]
  }
}

Next, we use local variables to perform the calculations that determine which versions to retain, delete, and create. I’ll break these down below, but you can find the complete version at the end.

We start with creating the new API versions object that will make merging easier later. We also convert the response from the azapi call into the same format

   new_version = {
    "${local.api_version}" = {
      name    = "${local.api_name_prefix}"
      version = "${local.api_version}"
    }
  }
  current_versions = flatten([
    for api in data.azapi_resource_list.apis.output.value : api.properties.apiVersion
  ])

Now this logic all works off the version being a complete Sematic Version (SemVer) value e.g. Major.Minor.Path = 1.2.3. With the version in this format we can use the SemVer Terraform provider that contains SemVer functions for these values. The function we are using is the sort, which will order the versions from latest to oldest correctly. From this we can then use the slice function to cut down to the version list we want to keep in the APIM.

latest_versions = slice(provider::semvers::sort(local.current_versions), 0, local.version_count_to_keep) 

To bring it together we can iterate over all the existing APIs data from the azapi data resource, using the filtered version list to only pull the API versions we want. This is then merged with the object we created before with the new API versions details.

  tostay_api_versions = {
    for api in data.azapi_resource_list.apis.output.value :
    api.properties.apiVersion => api
    if contains(local.latest_versions, api.properties.apiVersion)
  }

  todeploy_api_versions = merge(local.tostay_api_versions, local.new_version)

Finally we create the API, which I have left some required properties out as they are not part of the example. In this we are looping over all the API versions, setting the name and the revision. For the version we are using the Key which would be set as the SemVer number we created in the previous local variables, with the Version Set ID.

Something to note technically as the values for things like Import are fixed for the new versions values, it means Terraform will also try to update all the existing versions values as well. This means we need to add the Terraform Lifecycle to ignore update actions on the properties. This will result in the existing APIs values not changing and only the new API being created.

resource "azurerm_api_management_api" "api" {
  for_each              = local.todeploy_api_versions
  
  name                  = "${local.api_name_prefix}-${replace(each.key, ".", "-")}"
  revision              = "1"

  version        = each.key
  version_set_id = azurerm_api_management_api_version_set.set.id

  import {
    content_format = "openapi+json"
    content_value  = file("./api-swagger.json")
  }

  depends_on = [azurerm_api_management_api_version_set.set]
  lifecycle {
    ignore_changes = [
      import,
    ]
  }
}

Now with all the above, we have a solution that retains a defined number of API versions in APIM, fully controlled by Terraform. These versions dynamically shift as new ones are added, allowing seamless integration with CI/CD pipelines and enabling the use of repository tag values as version identifiers. This creates a direct binding between the API version in APIM and the corresponding code commit in the repository.

This approach offers several key benefits: it avoids Terraform state conflicts, supports semantic versioning, reduces manual overhead, and ensures a clean, automated lifecycle for API versions. It also enhances traceability, improves deployment consistency, and aligns infrastructure changes with source control practices.

Below is the end-to-end full version of the implementation.

## Input Examples
locals {
  apim_name = "cpexampleapim"
  apim_resource_group_name = "cp-example-rg"
  api_name_prefix       = "cp-api-example"
  api_version = "1.6.0"
  version_count_to_keep = 5
}

data "azurerm_api_management" "apim" {
  name                = local.apim_name
  resource_group_name = local.apim_resource_group_name
}

resource "azurerm_api_management_api_version_set" "main" {
  name                = "cp-example-set"
  resource_group_name = local.apim_resource_group_name
  api_management_name = local.apim_name
  display_name        = "CP-Example-Set"
  versioning_scheme   = "Query"
  version_query_name  = "api-version"
}

data "azapi_resource_list" "apis" {
  type      = "Microsoft.ApiManagement/service/apis@2024-05-01"
  parent_id = data.azurerm_api_management.apim.id
  query_parameters = {
    "$filter" = ["contains(name ,'${local.api_name_prefix}')"]
  }
}

locals {
  new_version = {
    "${local.api_version}" = {
      name    = "${local.api_name_prefix}"
      version = "${local.api_version}"
    }
  }
  current_versions = flatten([
    for api in data.azapi_resource_list.apis.output.value : api.properties.apiVersion
  ])

  latest_versions = slice(provider::semvers::sort(local.current_versions), 0, local.version_count_to_keep)

  tostay_api_versions = {
    for api in data.azapi_resource_list.apis.output.value :
    api.properties.apiVersion => api
    if contains(local.latest_versions, api.properties.apiVersion)
  }

  todeploy_api_versions = merge(local.tostay_api_versions, local.new_version)
}

resource "azurerm_api_management_api" "api" {
  for_each              = local.todeploy_api_versions
  
  name                  = "${local.api_name_prefix}-${replace(each.key, ".", "-")}"
  revision              = "1"

  version        = each.key
  version_set_id = azurerm_api_management_api_version_set.set.id

  import {
    content_format = "openapi+json"
    content_value  = file("./api-swagger.json")
  }

  depends_on = [azurerm_api_management_api_version_set.set]
  lifecycle {
    ignore_changes = [
      import,
    ]
  }
}

Published by Chris Pateman - PR Coder

A Digital Technical Lead, constantly learning and sharing the knowledge journey.

Leave a message please

This site uses Akismet to reduce spam. Learn how your comment data is processed.