OpenAPI and Codegen for Django
RecipeYak uses a React UI that talks to a Django powered JSON API.
For RecipeYak’s existence, we’ve manually written the API queries in the UI. This gets tedious as there are lots of fields in both request params and responses. It’s also easy for the backend schema to get out of sync from the frontend, and it’s difficult to ensure API changes don’t break the UI’s requests.
Recently I decided it was time to simplify this setup and generate the API client.
The Setup
There are two steps involved in the codegen process:
- Generate the API schema from the Django API endpoints
- Generate the TypeScript API Client from the API schema
Generating the API schema
We use Pydantic to ensure static types are accurate at runtime. This means as long as we generate our schema off the types, we’ll never lie to the client.
API Changes
Before codegen, endpoints didn’t have explicit types for params and responses:
@endpoint()
def ingredient_update_view(
request: AuthedHttpRequest, ingredient_id: int
) -> JsonResponse:
params = IngredientsPatchParams.parse_raw(request.body)
# --snip--
return JsonResponse(serialize_ingredient(ingredient))
To facilitate both static typing and runtime type introspection, I added generics to the request and response types:
@endpoint()
def ingredient_update_view(
request: AuthedHttpRequest[IngredientsPatchParams], ingredient_id: int
) -> JsonResponse[IngredientResponse]:
params = IngredientsPatchParams.parse_raw(request.body)
# --snip--
return JsonResponse(serialize_ingredient(ingredient))
It’s not perfect, since there’s nothing that enforces actually serializing the request params with the same type as the one specified in the request, but it’s a good v0.
Additionally, since we’re now statically typing the response shape, any non-successful response needs to be an exception.
I created a helper exception for this, so instead of having:
# --snip--
if some_condition:
return JsonResponse(
{"code": "invalid_params", "message": "url was not valid"},
status=400
)
return JsonResponse(success_data)
we use:
# --snip--
if some_condition:
raise APIError(code="invalid_params", message="url was not valid")
return JsonResponse(success_data)
Future API Shape
Ideally the API would look more like:
@endpoint()
def ingredient_update_view(
request: AuthedHttpRequest, params: IngredientsPatchParams
) -> IngredientResponse:
# --snip--
return serialize_ingredient(ingredient)
URL path params would be included in the params
argument and the params
would automatically be deserialized using Pydantic’s parse_raw
method before
they’re passed to the endpoint function.
This also avoids JsonResponse
, since all API endpoints use JSON.
Supporting differing success status codes gets trickier without the
JsonResponse
wrapper, but we could either centrilaze on all endpoints returning
200 for success or maybe return a tuple of status code and data for non-200s.
Actually generating the schema
Generating the schema is pretty straightfoward, we:
- get all the urls and their handler functions
- parse all the path params from the urls
- convert all request and response types to json schema
- munge everything into the OpenAPI Schema shape
- format the schema with
prettier
✨
It ends up being around 400 lines of code, including comments, empty lines, etc.
The generated schema on the other hand currently tops out at almost 6k LOC, OpenAPI is not concise, but it’s easy to work with.
Generating the TypeScript API client
After generating the OpenAPI Schema, we generate our TypeScript API client.
The generated API client functions are straightforward:
// generated by recipeyak.api.base.codegen
import { http } from "@/apiClient"
export function ingredientUpdate(params: {
quantity?: string | null
name?: string | null
description?: string | null
position?: string | null
optional?: boolean | null
ingredient_id: number
}) {
return http<{
id: number
quantity: string
name: string
description: string
position: string
optional: boolean
}>({
url: "/api/v1/ingredients/{ingredient_id}/",
method: "patch",
params,
pathParamNames: ["ingredient_id"]
})
}
The http
function we’re using is a wrapper around axios
that handles templating the url path params, serializing dates, and omitting the path param from the request body.
For a given API method, we pass the OpenAPI schema to a recursive function, _json_schema_to_typescript_type
, that returns a string of TypeScript.
Some highlights from _json_schema_to_typescript_type
:
-
request types are treated differently from response types:
- we use
ReadonlyArray
instead ofArray
for inputs - we never have an optional properties in response types, since JSON doesn’t support serializing
undefined
- we use
-
datetime
params are typed asDate
, whiledate
s are typed asstring
. JS not having different types fordate
s vsdatetime
s is annoying -
we don’t convert from snake case to camel case, field names mirror the actual API
Type Mapping
Python type | JS request type | JS response type |
---|---|---|
date |
string |
string |
datetime |
Date |
string |
T | U (union) |
T | U (union) |
T | U (union) |
bool |
boolean |
boolean |
None |
null |
null |
int |
number |
number |
str |
string |
string |
list[T] |
ReadonlyArray<T> |
Array<T> |
NotRequired / has default |
[field_name]?: T |
omitted |
dict[str, T] |
Record<string, T> |
Record<string, T> |
no response (204) | n/a | unknown |
CI
There are a few new CI jobs that help keep everything in sync and nicely formatted:
-
check_missing_api_schema_changes
Ensure the OpenAPI schema doesn’t get out of sync with the API.
Schema generation has a
--check
option that generates the schema and compares it against the existing schema to see if any changes are missing. -
Ensure the TypeScript API client doesn’t get out of sync with the OpenAPI schema.
Codegen also has a
--check
option that generates the TypeScript modules in a temp directory and compares the directory, and all its files, against the one in the repo; checking for missing, extranous, and out of date files. -
Ensure the OpenAPI schema is following the spec via
redocly lint
.
Conclusion
It wasn’t too hard to start generating the API client, especially when we’re only targeting TypeScript.
The end result in terms of LOC is manageable and only requires pydantic and the standard library.
Here are the PRs that encompass the codegen changes: