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:

  1. Generate the API schema from the Django API endpoints
  2. 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:

  1. get all the urls and their handler functions
  2. parse all the path params from the urls
  3. convert all request and response types to json schema
  4. munge everything into the OpenAPI Schema shape
  5. 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 of Array for inputs
    • we never have an optional properties in response types, since JSON doesn’t support serializing undefined
  • datetime params are typed as Date, while dates are typed as string. JS not having different types for dates vs datetimes 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.

  • check_missing_codegen_changes

    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.

  • lint_api_schema

    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:

Edit(2024-06-19): there are some existing projects that cover the django url -> js url generation:

  • https://github.com/buttondown/django-typescript-routes
  • https://github.com/vintasoftware/django-js-reverse

But they don’t handle the reuqest and response body type generation.