DEV Community

Cover image for Stop writing API functions
Saeed Mosavat
Saeed Mosavat

Posted on

Stop writing API functions

If you are developing a front-end application which uses a back-end with RESTFUL APIs, then you have got to stop writing functions for each and every endpoint!

RESTFUL APIs usually give you a set of endpoints to perform CRUD (Create, Read, Update, Delete) actions on different entities. We usually have a function in our project for each of these endpoints and these functions do a very similar job, but for different entities. For example, let's say we have these functions:

// apis/users.js

// Create
export function createUser(userFormValues) {
  return fetch("/users", { method: "POST", body: userFormValues });
}

// Read
export function getListOfUsers(keyword) {
  return fetch(`/users?keyword=${keyword}`);
}

export function getUser(id) {
  return fetch(`/users/${id}`);
}


// Update
export function updateUser(id, userFormValues) {
  return fetch(`/users/${id}`, { method: "PUT", body: userFormValues });
}

// Destroy
export function removeUser(id) {
  return fetch(`/users/${id}`, { method: "DELETE" });
}
Enter fullscreen mode Exit fullscreen mode

and a similar set of functions may exist for other entities like City, Product, Category, ... . However, we can replace all of these functions with a simple function call:

// apis/users.js
export const users = crudBuilder("/users");

// apis/cities.js
export const cities = crudBuilder("/regions/cities");

Enter fullscreen mode Exit fullscreen mode

And then use it like this:

users.create(values);
users.show(1);
users.list("john");
users.update(values);
users.remove(1);
Enter fullscreen mode Exit fullscreen mode

"But Why?" you may ask

Well, there are some good reasons for that:

  • It reduces the lines of code  -  code that you have to write and someone else has to maintain when you leave the company.
  • It enforces a naming convention for the API functions, which increases code readability and maintainability. You may have seen function names like getListOfUsers, getCities, getAllProducts, productIndex, fetchCategories etc. that all do the same thing: "get the list of an entity". With this approach you'll always have entityName.list() functions and everybody on your team knows that.

So, let's create that crudBuilder() function and then add some sugar to it.


A very simple CRUD builder

For the simple example above, the crudBuilder function would be so simple:

export function crudBuilder(baseRoute) {
  function list(keyword) {
    return fetch(`${baseRoute}?keyword=${keyword}`);
  }

  function show(id) {
    return fetch(`${baseRoute}/${id}`);
  }

  function create(formValues) {
    return fetch(baseRoute, { method: "POST", body: formValues });
  }

  function update(id, formValues) {
    return fetch(`${baseRoute}/${id}`, { method: "PUT", body: formValues });
  }

  function remove(id) {
    return fetch(`${baseRoute}/${id}`, { method: "DELETE" });
  }

  return {
    list,
    show,
    create,
    update,
    remove
  }
}
Enter fullscreen mode Exit fullscreen mode

It assumes a convention for API paths and given a path prefix for an entity, it returns all the methods required to call CRUD actions on that entity.

But let's be honest, we know that a real-world application is not that simple! There are lots of thing to consider when applying this approach to our projects:

  • Filtering: list APIs usually get lots of filter parameters
  • Pagination: Lists are always paginated
  • Transformation: The API provided values may need some transformation before actually being used
  • Preparation: The formValues objects need some preparation before being sent to the API
  • Custom Endpoints: The endpoint for updating a specific item is not always `${baseRoute}/${id}`

So, we need a CRUD builder that can handle more complex situations.


The Advanced CRUD Builder

Let's build something that we can really use in our everyday projects by addressing the above issues.

Filtering

First, we should be able to handle more complicated filtering in out list function. Each entity list may have different filters and the user may have applied some of them. So, we can't have any assumption about the shape or values of applied filters, but we can assume that any list filtering can result in an object that specifies some values for different filter names. For example to filter some users we could have:

const filters = {
  keyword: "john",
  createdAt: new Date("2020-02-10"),
}
Enter fullscreen mode Exit fullscreen mode

On the other hand, we don't know how these filters should be passed to the API, but we can assume (and have a contract with API providers) that each filter has a corresponding parameter in the list API which can be passed in the form of "key=value" URL query params.

So, we need to know how to transform the applied filters into their corresponding API parameters to create our list function. This can be done with passing a transformFilters parameter to the crudBuilder(). An example of that for users could be:

function transformUserFilters(filters) {
  const params = []
  if(filters.keyword) {
    params.push(`keyword=${filters.keyword}`;
  }
  if(filters.createdAt) {
    params.push(`created_at=${dateUtility.format(filters.createdAt)}`;
  }

  return params;
}
Enter fullscreen mode Exit fullscreen mode

Now, we can use this parameter to create the list function.

export function crudBuilder(baseRoute, transformFilters) {
  function list(filters) {
    let params = transformFilters(filters)?.join("&");
    if(params) {
      params += "?"
    }
    return fetch(`${baseRoute}${params}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Transformation and Pagination

The data received from the API may need some transformation before being usable in our app. For example we may need to transform the snake_case names to camelCase or to transform some date string into users timezone etc.
Also, we need to handle pagination.

Let's assume that all paginated data from APIs have the following shape (Standardized by the API provider):

{
  data: [], //list of entity objects
  pagination: {...}, // the pagination info
}
Enter fullscreen mode Exit fullscreen mode

So all we need to know is how we should transform a single entity object. Then we can loop over the objects of the list to transform them. To do that, we need a transformEntity function as a parameter to our crudBuilder:

export function crudBuilder(baseRoute, transformFilters, transformEntity) {
  function list(filters) {
    const params = transformFilters(filters)?.join("&");
    return fetch(`${baseRoute}?${params}`)
      .then((res) => res.json())
      .then((res) => ({
        data: res.data.map((entity) => transformEntity(entity)),
        pagination: res.pagination,
      }));
  }
}
Enter fullscreen mode Exit fullscreen mode

And we are done with the list() function.

Preparation

For create and update functions, we need to transform the formValues into the format that the API expects. For example, imagine we have a city select in our form that selects a City object. but the create API only needs the city_id. So, we would have a function that does something like this:

const prepareValue = formValues => ({city_id: formValues.city.id}) 

Enter fullscreen mode Exit fullscreen mode

This function may return a plain object or a FormData depending on the use-case and can be used to pass data to API like:

export function crudBuilder(
  baseRoute,
  transformFilters,
  transformEntity,
  prepareFormValues
) {
  function create(formValues) {
    return fetch(baseRoute, {
      method: "POST",
      body: prepareFormValues(formValues),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom Endpoints

There are some rare situations that the API endpoint for some action on an entity doesn't follow the same convention. For example instead of having `/users/${id}` to edit a user, we have to use `/edit-user/${id}`. For these cases we should be able to specify a custom path.

Here, we allow the override of any path used in crud builder. Note that the paths for show, update and remove actions may depend on some info from the entity object, so we have to use a function and pass the entity object to get the path.

We need to get these custom paths in an object and fallback to our default paths if nothing specified. Something like:

const paths = {
  list: "list-of-users",
  show: (userId) => `users/with/id/${userId}`,
  create: "users/new",
  update: (user) => `users/update/${user.id}`,
  remove: (user) => `delete-user/${user.id}`
}
Enter fullscreen mode Exit fullscreen mode

The Final CRUD Builder

This is the final code to create CRUD API functions.

export function crudBuilder(
  baseRoute,
  transformFilters,
  transformEntity,
  prepareFormValues,
  paths
) {

  function list(filters) {
    const path = paths.list || baseRoute;
    let params = transformFilters(filters)?.join("&");
    if(params) {
      params += "?"
    }
    return fetch(`${path}${params}`)
      .then((res) => res.json())
      .then((res) => ({
        data: res.data.map((entity) => transformEntity(entity)),
        pagination: res.pagination,
      }));
  }

  function show(id) {
    const path = paths.show?.(id) || `${baseRoute}/${id}`;

    return fetch(path)
      .then((res) => res.json())
      .then((res) => transformEntity(res));
  }

  function create(formValues) {
    const path = paths.create || baseRoute;

    return fetch(path, {
      method: "POST",
      body: prepareFormValues(formValues),
    });
  }

  function update(id, formValues) {
    const path = paths.update?.(id) || `${baseRoute}/${id}`;

    return fetch(path, { method: "PUT", body: formValues });
  }

  function remove(id) {
    const path = paths.remove?.(id) || `${baseRoute}/${id}`;

    return fetch(path, { method: "DELETE" });
  }

    return {
    list,
    show,
    create,
    update,
    remove
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (31)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Interesting take on this. I will put you in the right direction: wj-config has a feature called URL Functions. Basically it takes a section of your configuration file and creates URL functions based on the information contained within that section, such as scheme, host, port and root paths.

You can have a config.json like this:

{
    "api": {
        "host": "", // <-- Blank if you want relative URL's.
        "rootPath": "/api",
        "users": {
            "rootPath": "/users",
            "all": "", // so the 'all' URL is /api/users
            "single": "/{userId}" // so the URL is /api/users/{userId}, and {userId} is replaceable
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And then you write your config.js module and import it. Once imported you have the URL functions good to go.

import config from './config.js';

...
const allUsersUrl = config.api.users.all(); // Just like that.  The 'all' property is converted to a function.
const userId = 123;
const singleUserUrl = config.api.users.single({ userId: userId }); // Yields /api/users/123
// And searching:
const searchUsersUrl = config.api.users.all(null, { firstName: 'john augustus' });
console.log(searchUsersUrl); // Outputs /api/users?firstName=john%20augustus
Enter fullscreen mode Exit fullscreen mode
Collapse
 
saeedmosavat profile image
Saeed Mosavat

Cool. Thanks for the suggestion.
This doesn't give all the functionalities that I have in mind, but I'm sure it has some good ideas in creating the routes. I will definitely dig deeper in its source code.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Agreed. wj-config will provide you with the URL construction. All you need now is to build upon this an API client. You could take a configuration section and assume it will have sub-objects (or sub-sections) with all and single URL's. The sub-object's name is the entity name, while the URL's used with GET, POST, PUT, PATCH and DELETE would provide the common CRUD you want, maybe with friendly names like addNew (uses POST), upsert (uses PUT), etc. You can borrow the function-creation algorithm to create the CRUD functions.

Then all other URL's could create objects with functions for each of the HTTP verbs.

import ApiClient from 'xxx';
import config from './config.js';

const apiClient = new ApiClient(config.api.entities);
export default apiClient;
Enter fullscreen mode Exit fullscreen mode

Consumption:

import apiClient from './apiClient.js';

await apiClient.users.getAll();
await apiClient.users.search({ name: 'brutus caesar' }); // GET to config.api.entities.users.all(null, { name: 'brutus caesar' });
await apiClient.users.addNew({ username: 'webJose', firstName: 'Jose' }); // POST
await apiClient.users.upsert('webJose', { username: 'webJose', firstName: 'Jose' }); // PUT to config.api.entities.users.single({ id: 'webJose' })
await apiClient.users.deactivate.delete({ id: 123 }); // config.api.entities.users.deactivate({ id: 123 });
await apiClient.users.verifyEmail.post({ id: 123, email: 'abc@example.com' }); // config.apip.entities.users.verifyEmail({ id: 123, email: 'abc@example.com' });
Enter fullscreen mode Exit fullscreen mode

That would be fantastic, actually. If you don't make this package, I will for sure. :-)

Thread Thread
 
saeedmosavat profile image
Saeed Mosavat

That's interesting. Thanks.

Collapse
 
ecyrbe profile image
ecyrbe • Edited

You should take a look at zodios.
You just need to declare your api, and you are ready to go.

Works in both JavaScript and typescript and with autocompletion.

Collapse
 
bartoszkrawczyk2 profile image
Bartosz Krawczyk

There's also ts-rest. I haven't used it, but it looks promising. Inspired by tRPC, but more focused on REST, like zodios.

Collapse
 
saeedmosavat profile image
Saeed Mosavat

Looks interesting. I will give it a try.

Collapse
 
xr0master profile image
Sergey Khomushin • Edited

Instead of something simple, you created something complex. The complex exponentially increases the probability of bugs. And if you need something that doesn't fit in this "code for everything", you'll add more ifs. I think it's better to use the WET/AHA pattern at the end. IMO

Collapse
 
saeedmosavat profile image
Saeed Mosavat

Well, it's a trade-off and depends on how it is used.
This is meant to be used for a set of standard and similar APIs, not for all APIs in the project. Using unique API functions should be preffered for special cases.

Collapse
 
orubel profile image
Owen Rubel

CRUD does not dictate business logic. And your business logic does not fit neatly into CRUD. If you try to, you are going to overengineer EVERY... SINGLE... TIME.

This is why no professional shop uses RESTful. They use RPC API's.

Collapse
 
saeedmosavat profile image
Saeed Mosavat

Yeah, this is approach is not meant to be used for every API in the project. But, when we have true CRUD APIs, it works really well. For example in most management dashboards, there is a lot of CRUD APIs to just list, create and update some entities.

Collapse
 
ryannerd profile image
Ryan Jentzsch

Too much boilerplate for my taste. I cringe any time a complex config file is imported. Nearly impossible to debug.

Collapse
 
wee profile image
Jelena Sokic

Weeeeeeeeee
Image description

Collapse
 
saeedmosavat profile image
Saeed Mosavat

Thanks for sharing your opinion. Any suggestion on how to avoid this kind of repeated work with less complexity?

Collapse
 
wee profile image
Jelena Sokic

Weeeeeeeeeeeeeeeee
Image description

Collapse
 
lico profile image
SeongKuk Han

That's a really good idea. Making consistency for APIs.
I'll try that with react-query.

Collapse
 
saeedmosavat profile image
Saeed Mosavat

It would be a good idea. react-query is the best 😍
I will be happy to see the result

Collapse
 
akmjenkins profile image
Adam

This is great, but please make sure you actually have a REST API in use everywhere in your application before creating this abstraction. And make sure you don’t try and make this work everywhere, even for the 5% of your API calls that aren’t actually REST

Collapse
 
saeedmosavat profile image
Saeed Mosavat

You're right. It should not be used for EVERY API in the application. It can even be more customized to only generate a subset of the CRUD actions fo an entity to prevent not-availavle actions from existing.

Collapse
 
skmohammadi profile image
skmohammadi

Great article !

Collapse
 
otumianempire profile image
Michael Otu

As a back-end developer I believe that your suggestion actually is right. This way of designing has more advantage compared to the previous.

Collapse
 
sasanazizi profile image
sasan azizi

Interesting idea, thanks