The value of API-First design on side-projects

chen

Chen

Posted on July 12, 2024

The value of API-First design on side-projects

Cover Photo by Douglas Lopes on Unsplash

Intro

Lately, I had a chance to try out the API-First design approach. I had never written an OpenAPI document before, so I had no real knowledge of its benefits. It always seemed like too much prep work.

As developers, we often prefer writing code to writing documentation. We dive straight into coding, eager to see our project in action. However, I recently discovered a game-changing approach that has transformed my development process: API-First design. In this post, I'll share my experience implementing this method in a full-stack hobby project, highlighting how it streamlined my workflow and why it's worth considering for your next side project.

tl;dr: It will force you to think about your users and how they use your API before writing any code.

I’ve been working on a full-stack hobby project where my backend and frontend use different languages (Go and SvelteKit). I decided to give this approach a try and had my “aha” moment. I wish I had done it before.

Prioritizing Your Application's Foundation

The API is how we are going to expose our app functionality. An API-first design approach prioritizes the development of APIs before implementing other parts of a software system (or writing code). This method focuses on creating a well-designed, consistent, and user-friendly API that is the foundation for the entire application.

This methodology places the API at the center of the development process, treating it as a first-class citizen rather than an afterthought. Your API comes first, then the implementation.

With a written API specification, we can leverage code generation tools to create some boilerplate code. By defining objects in the specification, code-gen tools can generate the relevant structs, for both the frontend and backend (yes, even when the language used is different). This is a big time saver and it helps us to be consistent.

What is Open API?

The OpenAPI Specification (OAS) defines a standard, language-agnostic interface to RESTful APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection.”

Simply put, it’s a contract that describes your API types and endpoints. You list all your API endpoints, their HTTP methods, what they possibly return, and some description of what they do.

Now, what if I told you, you can use this document to improve and accelerate your dev experience?

Once I had this document, that describes the contract between my API server and its clients, these are the things I could do:

  1. Generate my backend types (Go)
  2. Generate my frontend types (Typescript)
  3. Generate a client code for my server (also Typescript)
  4. Generate a testing client with Insomnia or Postman

This is a lot of boilerplate code I could save myself from writing. It ensures the frontend and backend types are synchronized since both are generated.

Grab a 🍺, and let’s walk through an example.

The Project Structure

We will be using Go for the backend and some JS framework for the frontend, and a simple structure would look like:

app/
├─ api/ -- the place for the Swagger OpenAPI document
├─ client/ -- the client-side code
├─ cmd/
│  ├─ app.go -- thin main func that runs our API server
├─ internal/
│  ├─ api/
│  │  ├─ main.go -- for code-gen
│  ├─ users/
│  │  ├─ handlers.go -- implements the API contract
Enter fullscreen mode Exit fullscreen mode

Generate The APIs

Let's create our API specification. It includes two endpoints and two structs: User and Error.
Place this file under your /api directory

openapi: 3.0.3
info:
  title: Devopsian OpenAPI Example
  version: 0.1.0
  contact:
    name: dev
    url: https://devopsian.net
servers:
  - url: "http://localhost/v1"
components:
  schemas:
    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: integer
          format: int32
        message:
          type: string
    User:
      type: object
      required:
        - id
        - name
        - email
      properties:
        id:
          type: string
        name:
          type: string
        email:
          type: string
          format: email

paths:
  /user:
    get:
      description: Get the current logged-in user
      responses:
        200:
          description: user response
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        default:
          description: error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /signup:
    post:
      description: Creates a new user
      responses:
        200:
          description: Creates a user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        default:
          description: error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                email:
                  type: string
                  format: email
Enter fullscreen mode Exit fullscreen mode

Generate Server-Side Code

To generate the server-side code, we need some library. I found oapi-codegen for that. It supports many popular HTTP libraries (echo, gin, etc.) At the time of writing, I used oapi-codegen@v2.3.0

Add the following files to your /internal/api directory

// /internal/api/main.go
//go:generate oapi-codegen --config cfg.yaml ../api/openapi3.yaml
package api

// make sure to install: 
// go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.3.0
Enter fullscreen mode Exit fullscreen mode
# /internal/api/cfg.yaml
package: api
output: server.gen.go
generate:
  models: true
  echo-server: true
Enter fullscreen mode Exit fullscreen mode

I’m using the echo web framework, you can browse the library documentation to use other frameworks. Now run go generate ./... and it will generate the interfaces (handlers) your web server has to implement to fulfill this contract, including the types.

Interface Implementation

Now it’s time to write the implementation. We create a users package where all the user's API handlers, business logic, storage, etc. are defined. We will keep it simple and implement the handlers with static content.

type UsersHandler struct {
    DB *sql.DB
}

func (u *UsersHandler) GetUser(ctx echo.Context) error {
    // load the user from the database and return it to the caller
    return ctx.JSON(http.StatusOK, api.User{
       Email: types.Email("demo@devopsian.net"),
       Name:  "DemoUser",
       Id:    "1",
    })
}

func (u *UsersHandler) PostSignup(ctx echo.Context) error {
    var body api.PostSignupJSONBody
    if err := ctx.Bind(&body); err != nil {
       return ctx.JSON(http.StatusBadRequest, api.Error{Code: http.StatusBadRequest, Message: "invalid request"})
    }
    // save user in database

    return ctx.NoContent(http.StatusOK)
}

func New(db *sql.DB) *UsersHandler {
    return &UsersHandler{DB: db}
}
Enter fullscreen mode Exit fullscreen mode

Next, we need to create our web server entry point, we define that at cmd/server.go

type Server struct {
    users.UsersHandler
}

func main() {
    e := echo.New()
    s := Server{UsersHandler: *users.New(nil)}

    api.RegisterHandlers(e, &s)
    e.Logger.Fatal(e.Start(":8080"))
}
Enter fullscreen mode Exit fullscreen mode

Note I explicitly pass in nil as DB implementation for this example, because we don’t use it.

That’s it. If the code compiles, our server implements the API contract. All the API endpoints are handled by the spec. If I had missed something, it would have broken at compile time.

How nice is that?

Generate a Typescript Client

It’s time to generate a client for our API. We use the same openapi schema file to generate a JS client. I won’t include a full frontend project in this example, but rather show how you can generate a client to an existing one.

In the client/ directory, install the code-generation tool for JS:

npm install openapi-typescript-codegen --save-dev. (This post was tested with v0.29.0)

Create a client/api/ directory, and let’s run the tool:

npx openapi-typescript-codegen --input ../api/openapi3.yaml --output api/ --name ApiClient.

This will generate a bunch of typescript files. To use our client we need to create an instance of it.

// api.ts
import { ApiClient } from './api/ApiClient'

const client = new ApiClient().default

// client has all the methods of our API:
// - getUser()
// - postSignup(requestBody: {name?: string, email?: string})
Enter fullscreen mode Exit fullscreen mode

That's it.

Summary

API-First design isn't just another development buzzword—it's a powerful approach that can significantly enhance your side projects.

By prioritizing your API design before implementation, you gain clarity, consistency, and efficiency.

The OpenAPI specification is a contract between your frontend and backend, enabling automatic code generation for types, clients, and even testing tools.

This approach not only saves time but also ensures better synchronization between different parts of your application.

While it may seem like extra work upfront, the long-term benefits—including improved development speed, reduced errors, and better API documentation—make it a valuable investment for any side project.

If you haven't tried it yet, now might be the perfect time to give it a shot and experience these benefits firsthand.

💖 💪 🙅 🚩
chen
Chen

Posted on July 12, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related