ELI5

Explain like I'm five

Lately, I’ve been contemplating the inaccessibility of modern frontend development and striving to enhance my projects’ documentation by including an ELI5 section. My aim is to provide detailed explanations while being respectful of your time. I'll try to assume very little knowledge! If you already know this stuff, feel free to skip this section. If this is helpful, please let me know!

OpenAPIs

First, let's talk about APIs. APIs help machines/servers communicate. The most common API conventions are REST or GraphQL. They both work over HTTP. GraphQL is nice because it is a strongly typed schema with its own query language. Meanwhile REST does not enforce any schema conventions.

If you want to add schema validation to your REST API, you can make it OpenAPI compliant. OpenAPI is a specification for describing REST APIs. It is a JSON schema that describes the structure of your API. An OpenAPI schema can help other humans and machines understand how your API works. They can generate client code against your API, or generate test cases, or configure infrastructure, etc.

For example, OpenAI's ChatGPT plugin system (opens in a new tab) relies on OpenAPIs. A "ChatGPT plugin" is just a pointer to an OpenAPI specification. Then ChatGPT will figure out how to use it from that specification alone 🙀. (Side note: it's unfortunate how similar both the words "OpenAI" and "OpenAPI" are 😅)

Client generation

If an API is OpenAPI compliant, you can generate client-code against it. This is a huge time-saver. You don't have to manually write the HTTP requests and responses. You can just import the client and call the functions. The client is type-safe, so you can't make typos in the API calls.

Here is an example of querying against an API which isn't OpenAPI compliant.

// someone else's API endpoint, available on
// https://example.com/api
 
const handler = (request: Request) => {
  const name = new URL(request.url).searchParams.get("name");
  return new Response(JSON.stringify({ greeting: `Hello, ${name}!` }), {
    headers: { "content-type": "application/json" },
  });
};
// how you would query against it in your code
 
const response = await fetch(
  `https://example.com/api?name=${encodeURIComponent("Hedwig")}`
);
const data: {
  greeting: string;
} = await response.json();

This feels a bit brittle. You have to make sure you are passing the right query params, and that the response is what you expect. If you make a typo, you won't know until runtime.

Here is an example of querying against an API which is OpenAPI compliant:

// https://petstore3.swagger.io/api/v3/openapi.json
{
  "openapi": "3.0.2",
  "info": {
    "title": "Petstore - OpenAPI 3.0",
    "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification.",
    "version": "1.0.17"
  },
  "servers": [
    {
      "url": "/api/v3"
    }
  ],
  "tags": [
    {
      "name": "pet",
      "description": "Everything about your Pets"
    }
  ],
  "paths": {
    "/pet/findByStatus": {
      "get": {
        "tags": ["pet"],
        "summary": "Finds Pets by status",
        "description": "Multiple status values can be provided with comma separated strings",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Status values that need to be considered for filter",
            "required": false,
            "explode": true,
            "schema": {
              "type": "string",
              "default": "available",
              "enum": ["available", "pending", "sold"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Pet"
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

We can generate a type-safe client against it using a library like Orval (opens in a new tab) or oazapfts (opens in a new tab). These libraries wrap fetch/axios and do the input/output parsing in the native typesystem of the language, e.g. TypeScript, Go, Rust etc. Some pseudocode:

codegen --input https://petstore3.swagger.io/api/v3/openapi.json --output ./src/client.ts

And now we can use the client in our code. Notice how it is type-safe, and we neither have to worry about typos, nor about how to prepare the underlying fetch call.

import { findPetsByStatus } from "./src/client";
 
// find pet by status
const response = await findPetsByStatus({
  status: ["available", "pending"],
});

Building an OpenAPI compliant API

In the above section, I showed an OpenAPI spec, but didn't show how the server code was implemented. It's nothing fancy, you as the implementer just have to make sure you follow the contract:

// this handler should be reachable on /api/v3/pet/findByStatus
const handler = (request: Request) => {
  const status = new URL(request.url).searchParams.getAll("status");
  const pets = db.getPets({
    filter: {
      status: status,
    },
  });
  return new Response(JSON.stringify(pets), {
    headers: { "content-type": "application/json" },
  });
};

This also feels a bit brittle. If we make a typo, or return the wrong data, we won't know until runtime that we're not respecting the spec. If we make changes to the spec, we have to make sure we update the handler to match (and vice versa).

Automated OpenAPI generation

Instead of a schema-first approach, what if we could just write our server code first? And then generate the OpenAPI spec from the server code? This would be a huge time-saver. We wouldn't have to write the OpenAPI spec by hand, and we wouldn't have to worry about keeping the spec and the server code in sync. With the spec, we can generate a client, and use that anywhere. The types "flow" across the stack.

Here is an example from tsoa (opens in a new tab), a popular server framework for this purpose:

import { Controller, Get, Query } from "tsoa";
import { Pet } from "../models/Pet";
 
@Route("pet")
export class PetController extends Controller {
  @Get("findByStatus")
  public async findByStatus(@Query() status?: string): Promise<Pet[]> {
    // Your implementation here
    return [];
  }
}

tsoa will look at those decorators and generate an OpenAPI spec for you.

Full stack frameworks

Types of server code

The next piece of the puzzle is to see where OpenAPI compliant server code can live inside full stack frameworks. Some common frameworks include: Next.js, Remix, Nuxt, SolidStart, SvelteKit, Astro and many more. These frameworks blur the boundary between client and server (these days, even more so than when they first started). Here are some ways they blur the boundary:

  1. Nearly all of them let you write server code inside dedicated files. These are request/response handlers, and are usually written in the style of the underlying runtime (e.g. Node Express, or Edge). For example, in Next.js, you could write API routes like this:
// server code in the express style
// pages/api/hello.ts
// e.g. https://nextjs.org/docs/pages/building-your-application/routing/api-routes
const handler = (req, res) => {
  return res.json({});
};
 
// OR in the winterCG style
// app/hello/route.ts
// e.g. https://nextjs.org/docs/app/building-your-application/routing/router-handlers
const handler = (request: Request) => {
  return new Response();
};
  1. They let you run server code inside dedicated lifecycle hooks. For example, in Next.js, you could run server code inside getServerSideProps. The code runs each time the page is loaded, and the data is passed to the client. This is a bit different from the previous example, because the server code is not a request/response handler, but a data-fetching hook. You couldn't really use this to mutate data from the client. For example:
// e.g. https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props
export const getServerSideProps = () => {
  return {
    props: {},
  };
};
  1. Other abstractions. Remix let's you have specially named loader functions (opens in a new tab) that fetch data. Next.js supports React Server Components (opens in a new tab) that can run top-level async/await network fetches on the server. There is some magic behind the scenes to make this work. My best guess is that the server bits are extracted by a compiler and used to spin up Lambdas/Edge functions. This blurring of the boundary has its pitfalls, you can see this talk by Rich Harris (opens in a new tab) (creator of SvelteKit) to learn more.

Using Zodios or TRPC to write OpenAPI compliant routes

I would be remiss if I didn't mention Zodios (opens in a new tab). You can give it control of a wildcard route pattern in your framework, and write your server code using its primitives. It will then:

  1. Give you a typesafe client for your internal usecase with no generation step, by inferring the types from your server code
  2. Generate an OpenAPI spec from your server code
  3. Let others generate a typesafe client using the OpenAPI spec

Another popular library that does the above is TRPC (opens in a new tab). It provides an OpenAPI plugin (opens in a new tab) as well.

Using Clover to write OpenAPI compliant routes

Zodios and TRPC require you to embrace a large set of abstractions. Clover is more lightweight. Here is what an augmented server route might look like:

// app/api/pets/findByStatus/route.ts
import { makeRequestHandler } from "@sarim.garden/clover";
import { z } from "zod";
 
export const { handler } = makeRequestHandler({
  method: "GET",
  path: "/api/pets/findByStatus",
  description: "Finds Pets by status",
  input: z.object({
    status: z.enum(["available", "pending", "sold"]).array().optional(),
  }),
  output: z.object({
    // ...pet schema fields
  }),
  run: async ({ input, sendOutput }) => {
    return sendOutput({
      // ...pet data
    });
  },
});
 
export { handler as GET };

The rest of the documentation will provide more details about how Clover works e.g. a TRPC-style inferred client, and how to serve the generated OpenAPI schema.

Other concepts

Runtime typesafety with Zod

Just having TypeScript types (either inferred from server code or generated from an OpenAPI spec) doesn't gurarantee type-safety over the wire during runtime. What if there was a cosmic bitflip ☀️💀 when the data was enroute from the server to the client? To gurarantee type-safety, it's generally a good idea to use a schema validation library like Zod, which is what you see with all the z.object stuff in the code examples above. You can learn more about Zod at https://github.com/colinhacks/zod (opens in a new tab).

// an example from Zod's documentation
 
import { z } from "zod";
 
// the schema
const User = z.object({
  username: z.string(),
});
 
// the type guarantee
type User = z.infer<typeof User>;
// { username: string }
 
// the runtime guarantee
User.parse({ username: "Ludwig" });