I run a React Native app with an Elixir backend. I picked Elixir for a few reasons:
One thing I generally try to set up right away in a project is strong typing across the API boundary. The shape I like is:
With something like FastAPI in Python, this is really easy to do and more or less comes for free out of the box.
After reading some docs and poking around, I decided to define schemas in Elixir with OpenApiSpex.
A simple user object:
defmodule UserOut do
@moduledoc false
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%Schema{
title: "UserOut",
description: "information about the user",
type: :object,
properties: %{
id: %Schema{type: :integer, description: "User ID"},
username: %Schema{type: :string, description: "User username"}
},
required: [:id, :username]
})
end
Then you can use these specs in a controller:
defmodule MyProject.UserProfileController do
use MyProject, :controller
use OpenApiSpex.ControllerSpecs
alias Db.User
alias MyProject.Schemas.UserSchemas.{UserOut, ErrorResponse}
operation(:get_user_profile,
summary: "Get user profile",
description: "Get a user's profile",
parameters: [
user_id: [in: :path, description: "User ID", type: :integer, required: true],
],
responses: [
ok: {"User profile retrieved successfully", "application/json", UserOut},
]
)
...
So with this we can generate a fully typed API client for the link between the backend API and the frontend client. That said, we still don't have any compile time checks, linting, or tests that confirm the objects we return from the business logic actually match the shape of what we promise to return from the API.
For example, I may say at the contract level that I'm going to return an object with an id field, yet not actually include it:
def build_user do
%{public_id: 1, username: "hew"} # oops, "id" is missing
end
In a language with a decent typing story (Haskell or Python), you would likely try to encapsulate this in the types and expect a blunder to be caught by a type check. In a language like Ruby, you would probably write tests to make sure the fields you expect are actually being returned.
I considered trying to use Dialyzer (Elixir's type annotations), but opted to try to establish a pattern for this logic that was both testable and operationally easy to work with.
The pattern defines a constructor (Builder) for each schema we may return from the API:
defmodule MyProject.Builders.UserBuilder do
@behaviour MyProject.Builders.VerifiedShapeBehavior
def schema, do: MyProject.Schemas.UserOut
def base_db_model, do: MyProject.User
def build(user) do
%{
"id" => user.id,
"username" => user.username,
}
end
end
This says that the builder expects a User (as defined by our database schema) and is expected to return a UserOut.
The behaviour just requires that every builder module defines schema, base_db_model, and build:
defmodule MyProject.Builders.VerifiedShapeBehavior do
@callback build(map()) :: map()
@callback schema() :: module()
@callback base_db_model() :: module()
end
With this in place we can auto-generate tests for every builder. We know what object to pass the builder, and we can assert against the expected return type:
def assert_shape(module) do
base_model = base_db_to_struct(module)
data = module.build(base_model)
schema = module.schema()
case OpenApiSpex.cast_value(data, schema) do
{:ok, _} ->
:ok
{:error, errors} ->
flunk("""
Schema validation failed for \#{inspect(module)}
Data:
\#{inspect(data, pretty: true)}
Errors:
\#{format_errors(errors)}
""")
end
end
This is, of course, not perfect. If you don't actually use the builders to construct the objects you return from the API, they'll do you no good. You can still easily write code that breaks the API contract and passes all the tests. But as a project with a small number of developers (1), it's pretty easy to just follow the convention. Also, localizing the logic around building your transfer objects makes a lot of code a bit cleaner and easier to read.
If you made it this far, hopefully that was interesting! And if I'm missing a better pattern, please reach out and let me know!