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
= FastAPI()
app
class EntityTypeInteger(BaseModel):
type: Literal["integer"]
format: str
class EntityTypeString(BaseModel):
type: Literal["string"]
list[str]
enum:
= EntityTypeInteger | EntityTypeString
EntityType
class Entity(BaseModel):
int
some_other_field: = Field(discriminator="type")
type_field: EntityType
@app.get("/entities")
async def get_entities() -> list[Entity]:
return [
Entity(=1,
some_other_field=EntityTypeInteger(type="integer", format="chemical id"),
type_field
),
Entity(=2,
some_other_field=EntityTypeString(type="string", enum=[])
type_field
),
]
# At the time of writing, omitting openapi_version defaults to 3.1,
# which isn’t supported by the generator (see below) yet.
= get_openapi(
schema ="1.0",
version=app.routes,
routes="My test OpenAPI",
title="3.0.3"
openapi_version
)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
= do
main <-
result
runWithConfiguration= "http://localhost:8000"})
(defaultConfiguration {configBaseURL
getEntitiesEntitiesGetprint 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):
None | int = None
foo: None | str = None
bar:
=None, bar="1") MyClass(foo
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 usessetuptools
as thebuild-backend
and defines build and dev dependenciessetup.py
, which repeats the non-dev dependencies in theREQUIRES
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 theflake8
line length (can’t you do that inpyproject.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
= None
oneof_schema_1_validator: Optional[EntityTypeInteger] # data type: EntityTypeString
= None
oneof_schema_2_validator: Optional[EntityTypeString] = None
actual_instance: Optional[Union[EntityTypeInteger, EntityTypeString]] str] = Field(default=Literal["EntityTypeInteger", "EntityTypeString"])
one_of_schemas: List[
# …
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.
= openapi_client.Configuration(
configuration = "http://localhost:8000"
host
)
# 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
= openapi_client.DefaultApi(api_client)
api_instance
try:
# Get Entities
= api_instance.get_entities_entities_get()
api_response 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”.
Generating the
openapi.json
file is described in the FastAPI manual. Just redirect standard output of this program toopenapi.json
↩︎It was originally designed for the Stripe payment system, hence certain defaults.↩︎
which is broken on npmjs.com btw., gives a HTTP 404↩︎
The problem, of course, being breaking major releases in one of these libraries, which silently break the generated code.↩︎