Writing an API layer in an application is so last century! Meet the universal proxy

Most companies regularly face the challenge of constantly modifying the API layer in their web applications in response to server API changes. Some of them resort to auto-generating code based on Swagger schemas, others rewrite the code manually. However, these approaches require significant resources and time, and often result in redundant code that grows in volume along with the number of API methods involved in the code. In this article I want to share a method that avoids these difficulties.

Let's start by defining the essence and purpose of the API layer in web applications. This layer represents the interface between the application and the backend, its main tasks are:

  • providing a list of available API methods and their description

  • making a request to the server and returning the server response to the application

This layer should not contain logic other than the logic of transporting data between the frontend and backend. There should not be any additional calculations or data processing – this is the task of the application core.

If we look at the situation objectively, we will see that for the developer only the semantics of method calls and their number in the API change – other aspects are not so important.

A question that plagues many developers: “Why do I have to fiddle with this proxy layer every time the API on the backend changes?”
The answer may already be hidden in the question itself.

The idea is very simple and at the same time brilliant: proxy object + TypeScript!

The proxy object allows us to do almost anything we want, and TypeScript won't let us do anything unnecessary – something that's not in the API!

Freedom of action under restrictions is the key to harmony!

Let me show you how it works with an example:

const getAPI = (apiUrl) =>
    new Proxy(
        {},
        {
            get(_, method_name) {
                return async (props) => {
                    const apiMethod = camelToSnake(methodName);
                    const httpMethod = apiMethod.split('_')[0].toUpperCase();
                    const isGetMethod = httpMethod === 'GET';
                    const url = new URL(`${apiUrl}/${apiMethod}`);
                    const options = {
                        method: httpMethod,
                        headers: { 'Content-Type': 'application/json' },
                    };

                    if (isGetMethod) {
                        url.search = new URLSearchParams(props).toString();
                    } else {
                        options.body = JSON.stringify(props);
                    }

                    const response = await fetch(url, options);
                    return response.json();
                };
            },
        },
    );

The code provides a simplified implementation of the API proxy object logic. For the sake of this example, we'll assume that API method names always start with the HTTP method name, and that the actual backend method names have snake notation (in practice, you may have any other terms and conventions).

Example of using a proxy object:

const api = getAPI('http://localhost:3000/api');

// С Proxy мы можем писать реальные вызовы методов
api.getTodos(); 
// --> fetch('http://localhost:3000/api/get_todos?...', { method: 'GET', ... })

api.postTodo({ title: 'test' }); 
// --> fetch('http://localhost:3000/api/post_todo', { method: 'POST', ... }))

api.deleteTodo({ id: 1 }); 
// --> fetch('http://localhost:3000/api/delete_todo', { method: 'DELETE', ... }))

// С Proxy никто не запретит нам писать всякую билеберду
api.putLalalalala('lololololo'); 
// --> fetch('http://localhost:3000/api/put_lalalalala', { method: 'PUT', ... }))

However, in order to limit our imagination to the actual implementation of the API, we need a description of the API in the form of an interface in TypeScript.

type Todo = {
    id: number;
    title: string;
};

interface API {
    getTodos: async () => Todo[];
    postTodo: async ({ title: string }) => Todo;
    deleteTodo: async ({ id: number }) => Todo;
}

Let's complete the implementation of the proxy object with types:

import type { API } from '@companyName/api';

const getAPI = (apiUrl) => new Proxy( ... ) as API;

Now api will contain descriptions of API methods from the TypeScript interface, which provides auto-hints in the IDE and does not allow us to go beyond the actual implementation. Typescript provides us with descriptions of API methods, validates parameters, and provides information about the returned result.

Trying to write a call to a non-existent method:

api.putLalalalala('lololololo');

or any other call that does not match the interface will result in an error.

If the API interface changes on the server, the backend developers will have to change the interface description and publish a new version. In this case, the web application only needs to update the package with the interface description.

Conclusion

Using a combination of a proxy object and TypeScript to implement an API layer can significantly simplify the process of developing and maintaining an application, avoiding constant rewriting or generation of API layer code in the web application when the API changes on the backend.

The size of your API layer will be consistently minimal, whether you use tens or tens of thousands of API methods in code (compare this to the ever-increasing size of current API implementations based on generated or hand-written code).

Moreover, this code can be the same for many web applications. It can be packaged in a separate package and used in many company projects.

The method is very simple, lightweight and requires only periodic updating of the types that describe the API interface.

Stop writing and generating and rewriting the API layer for every change in the API server – this is no longer fashionable!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *