Thursday, February 9, 2023

Writing a static maps API with FastAPI and Azure

As you may know if you've been reading my blog for a while, my first job in tech was in Google developer relations, working on the Google Maps API team. During my team there, we launched the Google Static Maps API. I loved that API because it offered a solution for developers who wanted a map, but didn't necessarily need the relatively heavy burden of an interactive map (with it's JavaScript and off-screen tiles).

Ever since using the py-staticmaps package to generate maps for my Country Capitals browser extension, I've been thinking about how fun it'd be to write an easily deployable Static Maps API as a code sample. I finally did it last week, using FastAPI along with Azure Functions and Azure CDN. You can fork the codebase here and deploy it yourself using the README instructions.

Screenshot with FastAPI documentation parameters on left and image map output on right

Here are some of the highlights from the sample.


Image responses in FastAPI

I've used FastAPI for a number of samples now, but only for JSON APIs, so I wasn't sure if it'd even work to respond with an image. Well, as I soon discovered, FastAPI supports many types of responses, and it worked beautifully, including the auto-generated Swagger documentation!

Here's the code for the API endpoint:


@router.get("/generate_map")
def generate_map(
    center: str = fastapi.Query(example="40.714728,-73.998672", regex=r"^-?\d+(\.\d+)?,-?\d+(\.\d+)?$"),
    zoom: int = fastapi.Query(example=12, ge=0, le=30),
    width: int = 400,
    height: int = 400,
    tile_provider: TileProvider = TileProvider.osm,
) -> fastapi.responses.Response:
    context = staticmaps.Context()
    context.set_tile_provider(staticmaps.default_tile_providers[tile_provider.value])
    center = center.split(",")
    center_ll = staticmaps.create_latlng(float(center[0]), float(center[1]))
    context.set_center(center_ll)
    context.set_zoom(zoom)

    # Render to PNG image and return
    image_pil = context.render_pillow(width, height)
    img_byte_arr = io.BytesIO()
    image_pil.save(img_byte_arr, format="PNG")
    return fastapi.responses.Response(img_byte_arr.getvalue(), media_type="image/png")

Notice how the code saves the PIL image into an io.BytesIO() object, and then returns it using the generic fastapi.responses.Response object with the appropriate content type.

Testing the image response

Writing the endpoint test took much longer, as I've never tested an Image API before and wasn't sure the best approach. I knew I needed to store a baseline image and check the generated image matched the baseline image, but what does it mean to "match"? My first approach was to check for exact equality of the bytes. That did work locally, but then failed when the tests ran on Github actions. Presumably, PIL saves images with slightly different bytes on the Github CI server than it does on my local machine. I switched to checking for a high enough degree of similarity using PIL.ImageChops, and that works everywhere:


def assert_image_equal(image1, image2):
    assert image1.size == image2.size
    assert image1.mode == image2.mode
    # Based on https://stackoverflow.com/a/55251080/1347623
    diff = PIL.ImageChops.difference(image1, image2).histogram()
    sq = (value * (i % 256) ** 2 for i, value in enumerate(diff))
    rms = math.sqrt(sum(sq) / float(image1.size[0] * image1.size[1]))
    assert rms < 90

def test_generate_map():
    client = fastapi.testclient.TestClient(fastapi_app)
    response = client.get("/generate_map?center=40.714728,-73.998672&zoom=12&width=400&height=400&tile_provider=osm")
    assert response.status_code == 200
    assert response.headers["content-type"] == "image/png"
    generated_image = PIL.Image.open(io.BytesIO(response.content))
    baseline_image = PIL.Image.open("tests/staticmap_example.png")
    assert_image_equal(generated_image, baseline_image)

I only tested a single baseline image, but in the future, it'd be easy to add additional tests for more API parameters and images.


Deploying to Azure

For deployment, I needed some sort of caching on the API responses, since most practical usages would reference the same image many times, plus the usage needs to adhere to the OpenStreetMap tile usage guidelines. I considered using either Azure API Management or Azure CDN. I went with the CDN mostly because I wanted to try it out, but API Management would also be a great choice, especially if you're interested in enabling more APIM policies.

All of my infrastructure is described declaratively in Bicep files in the infra/ folder, to make it easy for anyone to deploy the whole stack. This diagram shows what gets deployed:

Architecture diagram for CDN to Function App to FastAPI

Securing the function

Since I was putting a CDN in front of the Function, I wanted to prevent unauthorized access to the Function. Why expose myself to potential costly traffic on the Function's endpoint? The first step was changing function.json from an authLevel of "anonymous" to an authLevel of "function".


{
  "scriptFile": "__init__.py",
  "bindings": [
    {
      "authLevel": "function",
      ...

With that change, anyone hitting up the Function's endpoint without one of its keys gets served an HTTP 401. The next step was making sure the CDN passed on the key successfully. I set that up in the CDN endpoint's delivery rules, using a rule to rewrite request headers to include the "x-functions-key" header:


{
  name: 'Global'
  order: 0
  actions: [
    {
      name: 'ModifyRequestHeader'
      parameters: {
        headerAction: 'Overwrite'
        headerName: 'x-functions-key'
        value: listKeys('${functionApp.id}/host/default', '2019-08-01').functionKeys.default
        typeName: 'DeliveryRuleHeaderActionParameters'
      }
    }
  ]
}

Caching the API

In that same global CDN endpoint rule, I added an action to cache all the responses for 5 minutes:


    {
      name: 'CacheExpiration'
      parameters: {
          cacheBehavior: 'SetIfMissing'
          cacheType: 'All'
          cacheDuration: '00:05:00'
          typeName: 'DeliveryRuleCacheExpirationActionParameters'
      }
    }

That caching rule is really just for the documentation, however, as I also added a more specific rule to cache the images for a more aggressive 7 days:


{
  name: 'images'
  order: 1
  conditions: [
    {
      name: 'UrlPath'
      parameters: {
          operator: 'BeginsWith'
          negateCondition: false
          matchValues: [
            'generate_map/'
          ]
          transforms: ['Lowercase']
          typeName: 'DeliveryRuleUrlPathMatchConditionParameters'
      }
    }
  ]
  actions: [
    {
      name: 'CacheExpiration'
      parameters: {
          cacheBehavior: 'Override'
          cacheType: 'All'
          cacheDuration: '7.00:00:00'
          typeName: 'DeliveryRuleCacheExpirationActionParameters'
      }
    }
  ]
}

Check out the full CDN endpoint description in cdn-endpoint.bicep.

If you decide to use this Static Maps API in production, remember to adhere to the tile usage guidelines of the tile providers and minimize hitting up their server as much as possible. In the future, I'd love to spin up a full tile server on Azure, to avoid needing tile providers entirely. Next time!

No comments: