Intro

There are many ways to build APIs:

  • YOLO approach
  • code-first approach
  • API-first approach

YOLO approach

In the "YOLO mode" you don't care much about the users of your API.
Maybe you have a few examples or docs you made, but nothing too serious.

Code-first approach

In the "code-first" approach you start with the coding right away. Schedules are tight, deadlines are near, pressure is real.

The specification (if any) is mostly an afterthought. Consider Spring annotations, you can always sprinkle some @ApiParam here and there in your controllers.

There are also approaches like Tapir, where you use a Scala DSL to define your API. Then you implement the server logic, and generate the spec from that Scala code. Potentially the client(s) too.

This approach is popular, since you start with coding right away. But it has some downsides:

  • you kinda play russian roulette with the spec, since it is generated from the code, so you can't really call it stable
  • it is more code to maintain, increasing your cognitive load and compile times
  • bundle size is increased, since you have to include the dependencies
  • sometimes you have to fight the DSL to get what you want (e.g polymorphic payloads and such)
  • you are limited by what DSL provides, if you need some framework-specific feature, it's challenging to get to it (or impossible)

API-first approach

In the "API-first" approach you start with the API specification. You define the API usually with OpenAPI (Swagger) in a YAML or JSON file. Then you generate the server from that spec (and client code if need be).

This approach has some advantages:

  • you are in full control of the spec, it is the source of truth
  • spec is stable and versioned
  • less code to maintain, there is no DSLs or annotations that obfuscate your code
  • you can leverage your framework's features to the max

An example in Scala is the Guardrail tool. It generates some server boilerplate code to the target/.../src_managed/main folder (you don't commit this to git). Then you implement the server logic by overriding those abstract definitions, fill in the blanks essentially. Note that when you change the openapi.yaml, the src_managed code is overwritten. Guardrail is a nice approach, sadly it doesn't support Scala 3 yet.

A new kid on the block is the OpenApi4s tool that I made. It takes a bit different approach, it doesn't hide the server code from you, it generates it directly in your src folder. Exactly like you would have written it by hand.
So how does it handle changes in the spec??? Overwrite the code every time? No, that would be silly, so let's see...

OpenApi4s

TLDR: OpenApi4s refactors your code automatically, by using regenesca diff+merge library.

Generating the models and controllers is the easy part:

  • parse the OpenAPI spec
  • generate models
  • generate controllers

and that's it.


The hard part is how to handle changes in the spec.

Consider the classical PetStore spec. OpenApi4s will generate something like this for the User model:

case class User(
  id: Option[Long],
  username: Option[String],
  firstName: Option[String],
  lastName: Option[String],
  email: Option[String],
  password: Option[String],
  phone: Option[String],
  userStatus: Option[Int]
) derives JsonRW

Adding a new property

When you add a new age field of type integer to the User model. OpenApi4s will compare the newly generated case class User (in-memory), with the existing case class User in your source code. Then it will figure out that age: Int parameter is missing, and it will add it.

--- a/api/src/com/example/petstore/api/models/User.scala
+++ b/api/src/com/example/petstore/api/models/User.scala
@@ -14,5 +14,5 @@ case class User(
   email: Option[String],
   password: Option[String],
   phone: Option[String],
-  userStatus: Option[Int]
+  userStatus: Option[Int], age: Long
 ) derives JsonRW

Changing a property

Let's say you change the userStatus's format from int32 to int64. OpenApi4s will figure out that userStatus: Option[Int] needs to be changed to userStatus: Option[Long].

--- a/api/src/com/example/petstore/api/models/User.scala
+++ b/api/src/com/example/petstore/api/models/User.scala
@@ -14,5 +14,6 @@ case class User(
   email: Option[String],
   password: Option[String],
   phone: Option[String],
-  userStatus: Option[Int]
+  userStatus: Option[Long]

Adding a new endpoint

Adding a new endpoint to existing controller is easy too. It will just add another case to your existing routes. For example in sharaf framework it will add something like this:

case GET -> Path("user", "new-endpoint") =>
  Response.withStatus(StatusCodes.NOT_IMPLEMENTED)

It will even generate a boilerplate implementation for you. You can then fill in the blanks.

Changing an endpoint

Now this is a bit more tricky. OpenApi4s must not touch your existing code, since you might have already implemented some logic. So it will not touch your existing expressions, for example Response.withStatus(..) in the previous example.

But it will update the query parameters if needed.

CI

Preventing accidental overwrites

You might be cautious about the changes that OpenApi4s makes. Thinking, will it overwrite my code? How can I make sure it doesn't? A simple check you can do in your CI pipeline is to:

  • touch the openapi.yaml file, just so that mill detects a change and triggers openapi4s
  • compile the code, to regenerate the files
  • see if there are any changes in the git diff

Example:

echo " " >> api/resources/openapi.yaml
./mill api.compile
truncate -s -1 api/resources/openapi.yaml
git diff --exit-code
[ $? -eq 0 ]  || exit 1

Preventing breaking changes

Since the openapi.yaml is now in git, you can do some cool stuff with it. One very useful check is preventing breaking changes. You can do it with openapi-diff for example:

git show origin/main:api/resources/public/openapi.json > main_openapi.json

cs launch org.openapitools.openapidiff:openapi-diff-cli:2.1.0-beta.12 -M org.openapitools.openapidiff.cli.Main -- --fail-on-incompatible main_openapi.json ./api/resources/public/openapi.json
[ $? -eq 0 ]  || exit 1

rm main_openapi.json

See the CI script in the openapi4s demo repo.

Here is an example of compatible change PR.
And an example of breaking change PR, CI fails of course.

Conclusion

Hope you find this post (and OpenApi4s tool) useful.
You can find more tools to combine with api-first approach at https://openapi.tools/

Check out the video demo on YouTube!

Additional resources