OpenAPI client support in Haskell, Elm and Python

Posted on February 23, 2024

Introduction

OpenAPI, formerly known as “Swagger”, is an API description standard. I will only go into detail here, so suffice to say, at work we have a server that uses Python’s FastAPI to define HTTP API endpoints, and we have multiple clients that access these endpoints. First and foremost, we have a web user interface written in Elm.

Previously, we hard-coded the requests we made to the server, which meant that if the server API changed, our clients had to be changed as well, and there was no way to automatically check if anything was broken. You just have to do you due dilligence. Enter OpenAPI! Or so I thought. Because ideally, you just define your API in the server, output it as a openapi.json file, and use that to generate client code.

This works, in principle, but has some quirks I’d like to gloss over now.

Python Server API

The server API is so small that I can post it in its entirety in this post1:

import json
from typing import Literal
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from pydantic import BaseModel
from pydantic import Field

app = FastAPI()

class EntityTypeInteger(BaseModel):
    type: Literal["integer"]
    format: str

class EntityTypeString(BaseModel):
    type: Literal["string"]
    enum: list[str]

EntityType = EntityTypeInteger | EntityTypeString

class Entity(BaseModel):
    some_other_field: int
    type_field: EntityType = Field(discriminator="type")

@app.get("/entities")
async def get_entities() -> list[Entity]:
    return [
        Entity(
            some_other_field=1,
            type_field=EntityTypeInteger(type="integer", format="chemical id"),
        ),
        Entity(
            some_other_field=2,
            type_field=EntityTypeString(type="string", enum=[])
        ),
    ]

# At the time of writing, omitting openapi_version defaults to 3.1,
# which isn’t supported by the generator (see below) yet.
schema = get_openapi(
  version="1.0",
  routes=app.routes,
  title="My test OpenAPI",
  openapi_version="3.0.3"
)
print(json.dumps(schema))

If you want, you can put this into a __init__.py file inside a directory testapp and run uvicorn testapp:app to start a web server to serve this API.

The code defines just one test endpoint, sitting under /entities, which serves a list of “entities”. An entity has a numeric field some_other_field — maybe some ID? — and a field type_field that can be either an integer with a format field describing it further, or a string field with an enum field, listing the possible string values (or empty, indicating all strings are fine). Expresssed as JSON, this might be a valid response from the server:

[
  {"some_other_field": 1, "type_field": {"type":"integer", "format":"chemical id"} },
  {"some_other_field": 2, "type_field": {"type":"string", "enum":[]} }
]

Run the script as a normal Python file to get an openapi.json.

Haskell: Using openapi-generator

There is this neat project openapi-generator (officially endorsed by OpenAPI) that can take an openapi.json file and generate lots of different clients for different languages. So that was my go-to source to generate client code.

First, since we’re using Haskell at work, I wanted to generate a Haskell client. There is only one generator available (sometimes there’s more than one per language, using different HTTP libraries, for example): haskell-http-client. Which is nice, since I know http-client and like it.

I generated the source code using this command-line:

openapi-generator-cli generate\
  --generator-name haskell-http-client\
  --input-spec ./openapi.json\
  --output haskell-api/

This generates a whole project, with a .cabal file, a stack.yaml file in it an all. Looking at the generated code, however, I’m seeing this (in lib/MyTestOpen/Model.hs):

data E'Type3
  = E'Type3'Integer -- ^ @"integer"@
  | E'Type3'String -- ^ @"string"@
  deriving (P.Show, P.Eq, P.Typeable, P.Ord, P.Bounded, P.Enum)

-- …

data TypeField = TypeField
  { typeFieldType :: !(E'Type3) -- ^ /Required/ "type"
  , typeFieldFormat :: !(Text) -- ^ /Required/ "format"
  , typeFieldEnum :: !([Text]) -- ^ /Required/ "enum"
  } deriving (P.Show, P.Eq, P.Typeable)

-- …

data Entity = Entity
  { entitySomeOtherField :: !(Int) -- ^ /Required/ "some_other_field"
  , entityTypeField :: !(TypeField) -- ^ /Required/ "type_field"
  } deriving (P.Show, P.Eq, P.Typeable)

I was puzzled by this. This looks…wrong. The TypeField mandates that have both format and enum, which is not what I wrote in the FastAPI server. It’s one of them.

Contrary to what I thought, it didn’t generate a union type. It just mangled all (string and integer) types together into a single structure TypeField. To be sure, what I expected was this:

data EntityTypeInteger = EntityTypeInteger {
    type_ :: Text
  , format :: Maybe Text
  }

data EntityTypeString = EntityTypeString {
    type_ :: Text
  , enum :: Maybe [Text]
  }

data EntityTypeField = EntityTypeInteger EntityTypeInteger
                     | EntityTypeString EntityTypeString

data Entity = Entity {
    someOtherField :: Int
  , typeField :: EntityTypeField
  }

You know, since Haskell does have union types and they give guarantees! I was able to fix this by making all fields in the structures optional. But then, all you can do is check the typeFieldType enum and then additionally check that all the Maybe types are there.

Double-checking the openapi.json, the type_field is defined as such:

{
  "type_field": {
    "oneOf": [
      {
        "$ref": "#/components/schemas/EntityTypeInteger"
      },
      {
        "$ref": "#/components/schemas/EntityTypeString"
      }
    ],
    "title": "Type Field",
    "discriminator": {
      "propertyName": "type",
      "mapping": {
        "integer": "#/components/schemas/EntityTypeInteger",
        "string": "#/components/schemas/EntityTypeString"
      }
    }
  }
}

This is almost a 1:1 mapping from the Python code, and it clearly defines the type as oneOf. Not allOf or something. So this generator is broken. The list of open bugs with Haskell in them is long and old, and the list of closed issues is not long either, so I suppose it’s simply not well-maintained, or not maintained at all.

Haskell: using Haskell-OpenAPI-Client-Code-Generator

While googling, I found another generator, Haskell-OpenAPI-Client-Code-Generator, that apparently once was a Bachelor’s thesis by somebody, but has seen some maintenance in the meantime. It was easy for me to try it out, since there was a flake.nix file available (see nix flake docs).

What I did was simply: nix build && result/bin/openapi3-code-generator openapi.json. This output a few files into out/2:

Write file to path: out/src/StripeAPI/Types/Entity.hs
Write file to path: out/src/StripeAPI/Types/EntityTypeInteger.hs
Write file to path: out/src/StripeAPI/Types/EntityTypeString.hs

And indeed, the code looks much better:

data Entity = Entity {
  -- | some_other_field
  entitySomeOtherField :: Int
  -- | type_field
  , entityTypeField :: EntityTypeField'Variants
  } deriving (Show , Eq)

data EntityTypeField'Variants =
   EntityTypeField'EntityTypeInteger EntityTypeInteger
  | EntityTypeField'EntityTypeString EntityTypeString
  deriving (Show, Eq)

data EntityTypeInteger = EntityTypeInteger {
  -- | format
  entityTypeIntegerFormat :: Text
  } deriving (Show , Eq)

data EntityTypeString = EntityTypeString {
  -- | enum
  entityTypeStringEnum :: ([Text])
  } deriving (Show , Eq)

This is exactly what I had in mind! Looking at the .cabal file I can see that http-conduit and http-client are dependencies, so it’s a similar library choice. Judging from the source code alone (!), I was able to piece together a simple program using this API:

{-# LANGUAGE OverloadedStrings #-}

import StripeAPI
import StripeAPI.Common

main = do
  result <-
    runWithConfiguration
      (defaultConfiguration {configBaseURL = "http://localhost:8000"})
      getEntitiesEntitiesGet
  print result

Great! So that’s my recommendation when it comes to Haskell. I also applied the generator to our full openapi spec and it still worked just fine, after tweaking the output a little bit. I had to set response_model_exclude_defaults=True parameter of my FastAPI endpoint, so it doesn’t serialize:

class MyClass(BaseModel):
  foo: None | int = None
  bar: None | str = None

MyClass(foo=None, bar="1")

as

{
  "foo": null,
  "bar": "1"
}

Since that, for the Haskell generator, is an error. It should be

{
  "bar": "1"
}

But I can live with that requirement.

Also note that the Hackage version of the Haskell generator is old (2021), and is marked broken in nixpkgs.

Elm: using openapi-generator

If you’re only reading the Elm part and skipped the Haskell part: There is this neat project openapi-generator (officially endorsed by OpenAPI) that can take an openapi.json file and generate lots of different clients for different languages. So I tried this for Elm.

openapi-generator-cli generate\
  --generator-name elm\
  --input-spec ./openapi.json\
  --output elm-output

The models are put into the Api/Data.elm file and look like this:

type alias Entity =
    { someOtherField : Int
    , typeField : TypeField
    }

type alias EntityTypeInteger =
    { type_ : EntityTypeIntegerType
    , format : String
    }

type EntityTypeIntegerType
    = EntityTypeIntegerTypeInteger

entityTypeIntegerTypeVariants : List EntityTypeIntegerType
entityTypeIntegerTypeVariants =
    [ EntityTypeIntegerTypeInteger
    ]

type alias EntityTypeString =
    { type_ : EntityTypeStringType
    , enum : List String
    }

type EntityTypeStringType
    = EntityTypeStringTypeString

entityTypeStringTypeVariants : List EntityTypeStringType
entityTypeStringTypeVariants =
    [ EntityTypeStringTypeString
    ]

type TypeField
    = TypeFieldEntityTypeInteger EntityTypeInteger
    | TypeFieldEntityTypeString EntityTypeString

Which is basically perfect! I pieced together a little Main.elm looking like this (adapted from the book example on elm-lang.org):

module Main exposing (..)

import Browser
import Html exposing (Html, text, pre)
import Http
import Api.Data
import Api.Request.Default exposing (getEntitiesEntitiesGet)
import Api exposing (send)

main =
  Browser.element
    { init = init
    , update = update
    , subscriptions = always Sub.none
    , view = view
    }

type Model
  = Failure
  | Loading
  | Success (List Api.Data.Entity)

init : () -> (Model, Cmd Msg)
init _ =
  ( Loading
  , send GotResponse getEntitiesEntitiesGet
  )

type Msg
  = GotResponse (Result Http.Error (List Api.Data.Entity))

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    GotResponse result ->
      case result of
        Ok response ->
          (Success response, Cmd.none)

        Err _ ->
          (Failure, Cmd.none)

view : Model -> Html Msg
view model =
  case model of
    Failure ->
      text "Request failure"

    Loading ->
      text "Loading..."

    Success response ->
      pre [] <| List.map (\e -> text (String.fromInt e.someOtherField)) response

However, compiling this I received this error:

You are trying to expose a type named `TypeFieldType` but I cannot find its
definition.

…

You are trying to expose a value named `typeFieldTypeVariants` but I cannot find
its definition.

So I looked into the api/Data.elm again, and indeed:

module Api.Data exposing
    ( Entity
    -- , …
    , TypeField(..), TypeFieldType(..), typeFieldTypeVariants
    -- , …
    )

So the generator generates faulty code. This is an open issue in the GitHub repository. For now, I was able to remove these two exports, and the code compiled and worked just fine.

Elm: using elm-open-api

For Elm, there is an alternative OpenAPI generator: elm-open-api-cli, written by Wolfgang Schuster. After installing nodejs, I ran the generator as the USAGE.md3 tells me with:

npx elm-open-api openapi.json

This generates exactly one file: generated/MyTestOpenapi.elm. Now that’s compact! Here are the generated types:

type alias Entity =
    { some_other_field : Int, type_field : EntityTypeIntegerOrEntityTypeString }

type EntityTypeIntegerOrEntityTypeString
    = EntityTypeIntegerOrEntityTypeString_EntityTypeInteger EntityTypeInteger
    | EntityTypeIntegerOrEntityTypeString_EntityTypeString EntityTypeString

type alias EntityTypeInteger =
    { format : String, type_ : String }

type alias EntityTypeString =
    { enum : List String, type_ : String }

This, just like the official generator, looks pretty good! I would have appreciated turning some_other_field into Camel case, i.e. someOtherField, but that’s acceptable. I adapted my little test script, and basically just had to swap:

send GotResponse getEntitiesEntitiesGet

…with…

MyTestOpenapi.getEntitiesEntitiesGet { toMsg = GotResponse }

Also, elm-open-api-cli comes with its own error type, but I ignore that, so no change needed.

Trying to compile this I’m getting an error, though:

I am partway through parsing a custom type, but I got stuck here:

67| type GetEntitiesEntitiesGet_Error
68|     = 
         ^
I just saw an equals sign, so I was expecting to see the first variant defined
next.

Again, we have a compilation error in the generated code. This one is a bit weirder, since I’m not sure what should come after the equals sign. And there is no open bug for this in the issue tracker, unfortunately. So let’s try to get to the bottom of this.

Our weird type is used here:

getEntitiesEntitiesGetTask :
    {} -> Task.Task (Error GetEntitiesEntitiesGet_Error String) (List Entity)
getEntitiesEntitiesGetTask config =
    Http.task
        { url = "/entities"
        , method = "GET"
        , headers = []
        , resolver =
            jsonResolverCustom
                (Dict.fromList [])
                (Json.Decode.list decodeEntity)
        , body = Http.emptyBody
        , timeout = Nothing
        }

And Http.task returns Task x a, where x comes from the resolver : Resolver x a. The jsonResolverCustom looks like this:

jsonResolverCustom errorDecoders successDecoder =
    Http.stringResolver (\stringResolverUnpack -> lotsofcode)

No type signature here, which makes our work harder. stringResolver has the type

stringResolver : (Response String -> Result x a) -> Resolver x a

So we have to look deeper inside jsonResolverCustom:

jsonResolverCustom errorDecoders successDecoder =
    Http.stringResolver
        (\stringResolverUnpack ->
            case stringResolverUnpack of
                Http.BadUrl_ url ->
                    Result.Err (BadUrl url)
                -- …
                Http.GoodStatus_ metadata body ->
                    case Json.Decode.decodeString successDecoder body of
                        Result.Ok value ->
                            Result.Ok value
                -- …

From that, I can deduce that it must be:

type GetEntitiesEntitiesGet_Error = Result Http.Error (List Entity)

And indeed that works. Elm then complains:

You are trying to import a `Json.Decode.Extra` module:

25| import Json.Decode.Extra
           ^^^^^^^^^^^^^^^^^
I checked the "dependencies" and "source-directories" listed in your elm.json,
but I cannot find it! Maybe it is a typo for one of these names?

Which isn’t indicated in the usage docs, unfortunately, but it’s easily fixable: elm install elm-community/json-extra. Then everything compiled and worked just fine.

Python: using openapi-generator

If you’re only reading the Elm part and skipped the Haskell part: There is this neat project openapi-generator (officially endorsed by OpenAPI) that can take an openapi.json file and generate lots of different clients for different languages. So I tried this for Python using the command line:

openapi-generator-cli generate\
  --generator-name python\
  --input-spec ./openapi.json\
  --output python-output

This generates a complete Python project with:

  • pyproject.toml, which uses setuptools as the build-backend and defines build and dev dependencies
  • setup.py, which repeats the non-dev dependencies in the REQUIRES variable (I’m only using poetry, so I don’t know: is that normal?)
  • setup.cfg, which has just one setting in it, to set the flake8 line length (can’t you do that in pyproject.toml as well?)
  • tests
  • some markdown docs, which are actually important, since they contain code examples

Pretty complete package!

The models are put into openapi_client/models. They are actually too big to post here, so I’ll abridge them a bit:

class Entity(BaseModel):
    """
    Entity
    """ # noqa: E501
    some_other_field: StrictInt
    type_field: TypeField

    # …

class TypeField(BaseModel):
    """
    TypeField
    """
    # data type: EntityTypeInteger
    oneof_schema_1_validator: Optional[EntityTypeInteger] = None
    # data type: EntityTypeString
    oneof_schema_2_validator: Optional[EntityTypeString] = None
    actual_instance: Optional[Union[EntityTypeInteger, EntityTypeString]] = None
    one_of_schemas: List[str] = Field(default=Literal["EntityTypeInteger", "EntityTypeString"])

    # …

class EntityTypeInteger(BaseModel):
    """
    EntityTypeInteger
    """ # noqa: E501
    type: StrictStr
    format: StrictStr

    # …

class EntityTypeString(BaseModel):
    """
    EntityTypeString
    """ # noqa: E501
    type: StrictStr
    enum: List[StrictStr]

    # …

Which, cutting down on the boilerplate, looks correct! The pyproject.toml lists the following dependencies:

python = "^3.7"
urllib3 = ">= 1.25.3"
python-dateutil = ">=2.8.2"
pydantic = ">=2"
typing-extensions = ">=4.7.1"

Which, apart from the dubious >= operators4, is a pretty solid choice of dependencies. I mean, they could have used the HTTP library that comes with Python, or the also very popular requests library, but urllib3 looks just fine.

An example program (from the docs) looks like this:

import openapi_client
from pprint import pprint

# Defining the host is optional and defaults to http://localhost
# See configuration.py for a list of all supported configuration parameters.
configuration = openapi_client.Configuration(
    host = "http://localhost:8000"
)


# Enter a context with an instance of the API client
with openapi_client.ApiClient(configuration) as api_client:
    # Create an instance of the API class
    api_instance = openapi_client.DefaultApi(api_client)

    try:
        # Get Entities
        api_response = api_instance.get_entities_entities_get()
        print("The response of DefaultApi->get_entities_entities_get:\n")
        pprint(api_response)
    except Exception as e:
        print("Exception when calling DefaultApi->get_entities_entities_get: %s\n" % e)

and it just works, out of the box. Nice! You can also generate asyncio output via

openapi-generator-cli generate\
  --generator-name python\
  --additional-properties=library=asyncio\
  --input-spec ./openapi.json\
  --output python-api-asyncio

which generates an invalid example in the docs, but if you sprinkle in some await statements into the code, this all works out.

Summary

I have only touched upon four programming languages here, but the support seems to vary a bit. During the research for this, I also learned that using the latest stable version of the generator is a good idea, since there are major changes implemented, for instance, in version >7.0.

Also, be aware that the code generation is always opinionated. As we saw, the project being generated almost always (except in Elm) used a specific library to do the http requests, and almost every generator not only produced the model classes (which are, arguably, the most important part of the whole process), but also some boilerplate around it. I was completely fine with the choices being made, but your mileage may vary.

All in all, I’m very happing using OpenAPI, despite its “generational shortcomings”.


  1. Generating the openapi.json file is described in the FastAPI manual. Just redirect standard output of this program to openapi.json↩︎

  2. It was originally designed for the Stripe payment system, hence certain defaults.↩︎

  3. which is broken on npmjs.com btw., gives a HTTP 404↩︎

  4. The problem, of course, being breaking major releases in one of these libraries, which silently break the generated code.↩︎