How to Generate a Type-Safe, No-Dependencies Client from your OpenAPI

How to Generate a Type-Safe, No-Dependencies Client from your OpenAPI

The ability to generate and manage types for an application is extremely important in full-stack development. As an application evolves and grows in both scale and complexity, the need for automation and efficiency in type generation increases.

In this article, we will look into an overview of types and the various ways to generate them. We will also explore how Platformatic makes this process easier and faster for developers, particularly through the use of Platformatic Client. Based on fetch, Platformatic Client allows users to generate a fully-typed client with no dependencies.

The Process Of Building APIs

In modern web applications, APIs are contracts between people: the backend engineer who creates the API and the frontend engineer who consumes the API on the user interface. This back-and-forth process of creating and consuming APIs adds a significant overhead when developing applications.

The backend engineer has to build out the API and pass instructions on implementing or consuming the API through the documentation. On the other hand, the frontend engineer has to build the user interface, generate types, and handle connections between the frontend and backend.

Platformatic Client seamlessly comes in to bridge this gap, eliminating all the heavy lifting and enabling engineers to worry less about tasks like writing documentation. In the same way, Platformatic helps frontend engineers spend less time generating types and instead focus on generating the client.

Generating the Client

There are a lot of technologies in existence for generating types and ensuring type-safety in an application. Two such technologies are tRPC and ts-rest.

tRPC vs ts-rest

tRPC is a framework that solves the problem of strong type safety and seamless communication between the client and the server. However, one of its downsides is that it introduces tight coupling.

Tight coupling occurs in a full-stack application when the client and server are closely connected in a very strict way, which makes it almost impossible for them to work without each other, leading to difficulties when trying to make engineering changes to one side of the application without affecting the other. Tight coupling is more of a flexibility trade-off, promoting a specific way of structuring the API, which in turn limits the flexibility and customisation. While this may be fine when working alone, it poses a big issue when working with a team or within a large organization. Meanwhile, Ts-rest is similar to tRPC because it also has the tight coupling feature. While it presents some benefits over tRPC, it also has some native deficiencies.

Ultimately, tight coupling leads to a monolith that is very hard to break down, and difficult to maintain in the long run. So, how do we solve these problems while maintaining a frictionless developer experience between the client and server?

Leveraging OpenAPI

Based on popularity, most developers prefer REST API to GraphQL for building their APIs. OpenAPI, formerly called Swagger, is the most popular and standard way to define REST APIs for several reasons, including standardized description, documentation and testing.

The standardized output makes it easier for developers and tools to understand and work with APIs. Open API also automatically generates concise documentation for API endpoints. This documentation, coupled with other tools, makes it easier to test the APIs.

So, how does Platformatic handle OpenAPI standard compliance in API development and consumption? It helps handle all of this heavy lifting and generates documentation in line with the OpenAPI standard.

Client Generation

How do we automatically generate a client for our REST APIs? Ideally, the generated code should be minimal and simple. To achieve this, there are a few requirements the generated code has to meet:

  1. Routes must be defined via Open API.

  2. No data validation must occur at runtime (this helps save time) .

  3. It must be fully typed to safeguard against developer errors via the editor or compile time.

  4. It must use only fetch() at runtime with no dependency.

The TypeScript Compiler is hard to use

The TypeScript compiler API is a powerful tool for working with TypeScript. However, a major downside to its use is its extremely high expansion ratio of about 20.

This means it takes about 60 lines of code to generate three lines of code. Moreover, due to its complexity, it is not easily readable.

In addition to using many lines to generate types, it does not maintain the same relationship with the various parts of our generated code.

The code-block-writer library

The code-block-writer is an upgrade to the TypeScript compiler. It is homomorphic because it maintains the same relationship between the various parts of our generated code.

It has a much lower expansion ratio than the TypeScript compiler. The code that generates the function is also readable by humans.

A downside to using the library is that it does not validate the generated code, nor does it do any safety checking, posing potential security vulnerabilities.

How To Use Platformatic to Generate Types

Now, let’s look at how to generate types for a client application using Platformatic. The first step is to create the backend of the application.

Create a new Platformatic project by running the command below:

npm create platformatic@latest
Hello xxxxxxxxx, welcome to Platformatic 1.9.0!
 Let's start by creating a new project.
? Which kind of project do you want to create? DB
? Where would you like to create your project? platformatic-db
? What database do you want to use? SQLite
? Do you want to use the connection string "sqlite://./db.sqlite"? Confirm
? What port do you want to use? 3042
? Do you want to run npm install? yes
? Do you want to create default migrations? yes
? Do you want to apply migrations? yes
? Do you want to create a plugin? yes
? Do you want to use TypeScript? no
? Do you want to create the github action to deploy this application to Platformatic Cloud? no
? Do you want to enable PR Previews in your application? yes
? Do you want to init the git repository? yes

We should have this output in our terminal:

Here, we have created a server for a basic movie application using Platformatic.

Start the server by running the command in the terminal:

npm start

The server starts on the URL http://127.0.0.1:3042 port, and the Open API documentation can be accessed at http://127.0.0.1:3042/documentation.

At this point, it is time to generate our client. To do so,

Create a new folder within the project folder called client. Open a new terminal Navigate to the folder.

Then, paste the command below in the new terminal.

npx platformatic client --frontend --language ts http://127.0.0.1:3042/documentation/json

This will be the output in the terminal:

A new folder is generated in the client folder called api. This folder has three main files that contain the generated code: api.ts, api-types.d.ts and api.openapi.json.

The api-types.d.ts file contains a TypeScript module that includes all the OpenAPI-related types. Below is a snippet from the file:

export interface FullResponse<T, U extends number> {
  'statusCode': U;
  'headers': object;
  'body': T;
}

export interface GetMoviesRequest {
  'limit'?: number;
  'offset'?: number;
  'totalCount'?: boolean;
  'fields'?: Array<'id' | 'title'>;
  'where.id.eq'?: number;
  'where.id.neq'?: number;
  'where.id.gt'?: number;
  'where.id.gte'?: number;
  'where.id.lt'?: number;
  'where.id.lte'?: number;
  'where.id.like'?: number;
  'where.id.in'?: string;
  'where.id.nin'?: string;
……..

The api.ts file contains a TypeScript module that includes a typed function for every single OpenAPI endpoint. Below is a snippet from the file:

// This client was generated by Platformatic from an OpenAPI specification.

import type { Api } from './api-types'
import type * as Types from './api-types'

// The base URL for the API. This can be overridden by calling `setBaseUrl`.
let baseUrl = ''
export const setBaseUrl = (newUrl: string) : void => { baseUrl = newUrl }

const _getMovies = async (url: string, request: Types.GetMoviesRequest) => {
  const response = await fetch(`${url}/movies/?${new URLSearchParams(Object.entries(request || {})).toString()}`)

  if (!response.ok) {
    throw new Error(await response.text())
……………………………………..

Then, the api.openapi.json file contains the schema of the application. Below is a snippet from the application:

{
  "openapi": "3.0.3",
  "info": {
    "title": "Platformatic DB",
    "description": "Exposing a SQL database as REST",
    "version": "1.0.0"
  },
  "components": {
    "schemas": {
      "Movie": {
        "title": "Movie",
        "description": "A Movie",
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "nullable": true
          },
          "title": {
            "type": "string",
....................................

Note: If your client folder does not have the package.json and tsconfig.json files, you can generate them by running the commands below in your terminal.

npm init -y

This generates a package.json file. Then, to generate a tsconfig.json, install typescript globally first and then generate the tsconfig.json file by running the command below:

npm i -g typescript 

npm tsc --init

Finally, write and change the output directory in the tsconfig file by uncommenting the “outDir” and setting the value to “./dist” in the tsconfig.json file.

Now, it’s time to test our client. Create a new file in the api folder called test.ts. Paste the following code in it:

import { createMovie, getMovies, setBaseUrl } from './api';

setBaseUrl('http://127.0.0.1:3042');

async function run() {
    {
        const movies = await getMovies();
        console.log(movies);
    }

    {
        const movie = await createMovie({
            title: "Movie 1"
        })
        console.log(movie);
    }

    {
        const movies = await getMovies();
        console.log(movies);
        }
}

run()

In the above snippet, we import the setBaseUrl function and set our base URL to our running server, the localhost URL.

Then, we import the getMovies function and createMovies function, which we executed.

Compile the code to pure JavaScript by running the command below in the terminal:

npx tsc

A new folder named “dist” is created, with the following files api.js, and test.js within it.

We can then run our test in the terminal by running the command below:

node dist/test.js

When the test is executed, we get the result below in our terminal, with the three lines signifying each time we log data to the console in our test.

We can now go on to test out other endpoints. Alternatively, we can use the build function from our api.ts file as shown below:

import build from './api';

const client = build('http://127.0.0.1:3042');
const {getMovies, createMovie} = client

async function run() {
    {
        const movies = await getMovies();
        console.log(movies);
    }

    {
        const movie = await createMovie({
            title: "Movie 1"
        })
        console.log(movie);
    }

    {
        const movies = await getMovies();
        console.log(movies);
    }
}

run()

When we compile and rerun our test, we should get the same output as the first method.

Wrapping Up

In this article, we took a look at different type generation methods, their pros and cons, and went into depth looking at how Platformatic Client. We explored how our Platformatic can help generate a client and types for a full-stack web application and also took a step-by-step look at how to do so.

You can read more about how to generate a client for your web applications using Platformatic here.

Having learned about how Platformatic helps in the full-stack engineering process, take your enterprise backend to the next level today with Platformatic!