DEV Community

Eddie
Eddie

Posted on

Create an awesome JS API interface using Fetch (in less than 50 lines)

In this tutorial, we will create a single reusable module that can perform all of our API calls in less than 50 lines of code! (Jump to final module code here). If you're familiar with the fetch API, you've seen how it could start to look ugly and unreadable with all of the promise chains.

fetch(url)
  .then((response) => response.json())
  .then(data => {
    console.dir(data);
  });
})
Enter fullscreen mode Exit fullscreen mode

Ew. You don't want to be doing that for every API call in your app. We should abstract this into a module so that your calls become more legible and easier to make. API calls will then look like this:

async function getArticles(userId) {
  const articles = await Fetch.get('/articles/' + userId);
  articles.forEach(item => {
   // iterate through results here...
  });
}
Enter fullscreen mode Exit fullscreen mode

We will be using ES6 modules in this tutorial. Let's go! (Take note of async and await keywords).

Create a Fetch.js module skeleton

First, let's define our public CRUD methods that we will use to interact with our module and define the API host:

// Fetch.js
const _apiHost = 'https://api.service.com/v1';

export default {
  get,
  create,
  update,
  remove
};
Enter fullscreen mode Exit fullscreen mode

Create the main fetch method

Next, let's make a private method inside our module that performs the actual fetching. This general purpose method should be able to handle both read and write API calls. Our method will take 3 arguments:

  1. url - the API endpoint - ex: '/articles'
  2. params - additional parameters to pass to the endpoint as either a query string for GET or data for the body of the request on POST, UPDATE, and DELETE calls
  3. method - GET (default), POST, PUT, DELETE
// Fetch.js
// ...

async function request(url, params, method = 'GET') {

}

// ...
Enter fullscreen mode Exit fullscreen mode

Notice the async at the beginning of our function. We need this because we are going to deal with Promises.

Add options and make sure to use "await"

// Fetch.js
const _apiHost = 'https://api.service.com/v1';

async function request(url, params, method = 'GET') {

  // options passed to the fetch request
  const options = {
    method
  };

  // fetch returns a promise, so we add keyword await to wait until the promise settles
  const response = await fetch(_apiHost + url, options);
  const result = await response.json(); // convert response into JSON

  // returns a single Promise object
  return result;

}

// ...
Enter fullscreen mode Exit fullscreen mode

Handle parameters for all request types

We want this method to be able to handle any parameters that may need to be included as part of the API request. For GET requests, we need to support query string . For all other write-related requests, we need to be able to include a body to the request.

We want our module to be super easy to use so let's standardize on handling objects for all cases. We can then convert the object into a query string for GET requests and include the object as JSON as part of the request body for all other types.

Let's create our object to query string conversion method:

// Fetch.js

// ...

// converts an object into a query string
// ex: {authorId : 'abc123'} -> &authorId=abc123
function objectToQueryString(obj) {
  return Object.keys(obj).map(key => key + '=' + obj[key]).join('&');
}

// ...
Enter fullscreen mode Exit fullscreen mode

Now we can add some code to handle the parameters if they exist. Let's add this right after the options definition in our request method:

// Fetch.js
const _apiHost = 'https://api.service.com/v1';

async function request(url, params, method = 'GET') {

  const options = {
    method,
    headers: {
      'Content-Type': 'application/json' // we will be sending JSON
    }
  };

  // if params exists and method is GET, add query string to url
  // otherwise, just add params as a "body" property to the options object
  if (params) {
    if (method === 'GET') {
      url += '?' + objectToQueryString(params);
    } else {
      options.body = JSON.stringify(params); // body should match Content-Type in headers option
    }
  }

  const response = await fetch(_apiHost + url, options);
  const result = await response.json();

  return result;

}

function objectToQueryString(obj) {
  return Object.keys(obj).map(key => key + '=' + obj[key]).join('&');
}

// ...
Enter fullscreen mode Exit fullscreen mode

Create public methods

Excellent! Now let's create the four public methods that we can use to make our different requests. We will export a single object that references these four methods at the very bottom of our module.

// Fetch.js
// ...
function get(url, params) {
  return request(url, params);
}

function create(url, params) {
  return request(url, params, 'POST');
}

 function update(url, params) {
  return request(url, params, 'PUT');
}

function remove(url, params) {
  return request(url, params, 'DELETE');
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

It's a good idea to add some error handling to our module. One way is to check the status code of the response. If it is NOT 200, then we should return some kind of error message.

You may handle errors any way you want. In this example, we will return an object with a status and message. Let's check the status immediately after our fetch request:

// Fetch.js

// ...

  const response = await fetch(_apiHost + url, options);

  // show an error if the status code is not 200
  if (response.status !== 200) {
    return generateErrorResponse('The server responded with an unexpected status.');
  }

  const result = await response.json();

  return result;

}

// A generic error handler that just returns an object with status=error and message
function generateErrorResponse(message) {
  return {
    status : 'error',
    message
  };
}
Enter fullscreen mode Exit fullscreen mode

We did it!

Now we can make all four types of API requests using a single module!

async / await

Our Fetch calls return a single promise so we need to use the await keyword in front of our calls. Additionally, await keyword does not work on top level code so they must be wrapped inside an async function. Here's what these requests may look like:

import Fetch from './Fetch.js';

// GET
async function getAllBooks() {
  const books = await Fetch.get('/books');
}

// POST
async function createBook() {
  const request = await Fetch.create('/books', {
    title: 'Code and Other Laws of Cyberspace',
    author: 'Lawrence Lessig'
  });
}

// PUT
async function updateBook(bookId) {
  const request = await Fetch.update('/books/' + bookId, {
    title: 'How to Live on Mars',
    author: 'Elon Musk'
  });
}

// DELETE
async function removeBook(bookId) {
  const request = await Fetch.remove('/books/' + bookId);
}
Enter fullscreen mode Exit fullscreen mode

Our completed API interface module! - Next Steps

I hope that you enjoyed this tutorial and that you can use it to become more productive!

You can add all sorts of other options to this module and in the fetch request itself. For example, what parameters would you add to the fetch method to support CORS requests?

How would you handle file uploads?

Happy Fetching!

// Fetch.js
const _apiHost = 'https://api.service.com/v1';

async function request(url, params, method = 'GET') {

  const options = {
    method,
    headers: {
      'Content-Type': 'application/json'
    }
  };

  if (params) {
    if (method === 'GET') {
      url += '?' + objectToQueryString(params);
    } else {
      options.body = JSON.stringify(params);
    }
  }

  const response = await fetch(_apiHost + url, options);

  if (response.status !== 200) {
    return generateErrorResponse('The server responded with an unexpected status.');
  }

  const result = await response.json();

  return result;

}

function objectToQueryString(obj) {
  return Object.keys(obj).map(key => key + '=' + obj[key]).join('&');
}

function generateErrorResponse(message) {
  return {
    status : 'error',
    message
  };
}

function get(url, params) {
  return request(url, params);
}

function create(url, params) {
  return request(url, params, 'POST');
}

 function update(url, params) {
  return request(url, params, 'PUT');
}

function remove(url, params) {
  return request(url, params, 'DELETE');
}

export default {
  get,
  create,
  update,
  remove
};

Enter fullscreen mode Exit fullscreen mode

Top comments (23)

Collapse
 
mvasigh profile image
Mehdi Vasigh • Edited

You need to make sure you're encoding your query parameters as URI components :) nice article!

Next steps I would take to expand this would be thinking about how you can allow headers to be configured on a per request basis, adding things like interceptors and support for form-data requests, canceling requests, and so on.

Collapse
 
kaos profile image
Kai Oswald

Wouldn't you need to await though?

const books = await Fetch.get('/books');
Collapse
 
eaich profile image
Eddie

Yes you're right! Sorry I forgot this very important aspect. And the call needs to be wrapped inside an async function. I've corrected the code examples. Thank you!

Collapse
 
joshuaneidich profile image
Joshua Neidich

In the final version, I see export default {
get,
create,
update,
remove
}; In the examples I see import of Fetch and you're using Fetch.get. Is it better to export default or export as a named class?

Collapse
 
eaich profile image
Eddie

That's entirely up to you. Personally, I like to use export default in this instance to provide context. Fetch.get() adds some clarity to my code, but that's just my opinion:

import { get } from './Fetch.js';
get(url); // function name is too ambiguous

// vs.

import { get as getData } from './Fetch.js';
getData(url); // better, but still a bit ambiguous. Might have another function in my module named getData()

// vs.

import Fetch from './Fetch.js';
Fetch.get(url);

Collapse
 
joshuaneidich profile image
Joshua Neidich

Thanks for the clarification. How should the export look? The export above is written as: export default {
get,
create,
update,
remove
};

When I imported Fetch, it wasn't working? Btw, great article!

Thread Thread
 
eaich profile image
Eddie

It should look like this:

import Fetch from './Fetch.js';

Depending on the location of your Fetch.js file, the path could be different.

Collapse
 
wagnerfillio profile image
Wagner Fillio • Edited

Hello!

Suppose I create a BookController and have a function below in this controller:


import Fetch from './Fetch.js';

// GET
async function getAllBooks() {
  const books = await Fetch.get('/books');
}

Could I export this function to call it in another js file?


function getAll() {   
  return getAllBooks();
}

export default {
  getAll,
};

Another thing, is it possible to make the constant books a global variable and export that variable so that I can use it anywhere?

Collapse
 
eaich profile image
Eddie

Yes, you can export it. Regarding global access, I like to create a new module for that. In my case, I called it Store.

// Store.js
const Store = {};
export default Store;

I then include this in my modules whenever I need to access a global variable.

import Store from './Store.js';

export async function getAllBooks() {
  const books = await Fetch.get('/books');
  Store.BOOKS = books;
  return books;
}
Collapse
 
wagnerfillio profile image
Wagner Fillio • Edited

thanks for the return, it was of great help!

Just one more question and I will always be grateful!

Suppose I require the data from bookId = 1 and want to display the attribute data in a Modal component (we can imagine the bootstrap), I would also like to change any value of any field, imagine the title of the book and save next.

In the code below, I'm just giving you an idea! Can you help me organize this code?


function openModal() {
    $("#form").modal({show: true});
}

function objectForm() {
    const id = document.getElementById('id');
    const title = document.getElementById('title');
    const author = document.getElementById('author');

    id.value = Store.BOOK.id;
    title.value = Store.BOOK.title;
    author.value = Store.BOOK.author;
}

// use to save start
// function called by the save button
function saveBook() {
    formObject();    
}

function formObject() {
    const id = document.getElementById('id');
    const title = document.getElementById('title');
    const author = document.getElementById('author');

    Store.BOOK.id = id.value;
    Store.BOOK.title = title.value;
    Store.BOOK.author = author.value;
}
// // use to save end

// GET BY ID
async function getBookById(bookId) {
    const book = await Fetch.get('/books/' + bookId);
    Store.BOOK = book;
    //return book;
    objectForm();
    openModal();
}

Collapse
 
wagnerfillio profile image
Wagner Fillio • Edited

Hi, can I resolve the promise this way?

import Fetch from './Fetch.js';

// GET
async function getAllBooks() {
  const books = await Fetch.get('/books')
                        .then(response => {
                          return response.data;
                        });
  return books;
}

Thinking about it, what is the best way to do this?

I want to call this function in another file, but I would like the promise to be resolved

Collapse
 
eaich profile image
Eddie

You can import this file for use in another file. First you will need to export that function. You can do using a number of ways, but here's a way using named export:

export async function getAllBooks() {
...
}

Then in your other file:

import { getAllBooks } from './Books.js';

async function myCall() {
  const response = await getAllBooks();
  console.log(response);
}
Collapse
 
jterranova profile image
j-terranova

I am having trouble using this for creates and updates. The create works fine when using a form object but if just sending a regular object there is a problem. On the client side:
try {
let response = await Fetch.create(
"/api/frameworks/userrunstatus/by/" + userId.userId,
frameworkUserRunStatus,
credentials
);
console.log("api-constructAssociation - response = ", response);
return await response;

I was expecting to have a request object (options or params) that I could read the, frameworkUserRunStatus object, on the server side but I could not. What is the object that I read in the controller on server side?

const create = async (req, res) => {
console.log("Server Side - frameworkUserRunStatus.controller - create start ");

console.log(
"Server Side - frameworkUserRunStatus.controller - inside try create after start, req.params = ",
req.params
);

/*
console.log(
"Server Side - frameworkUserRunStatus.controller - inside try create after start, req.options.body = ",
req.options.body
);

console.log(
"Server Side - frameworkUserRunStatus.controller - inside try create after start, req.options.json()= ",
req.options.json()
);
*/

const newFrameworkUserRunStatus = new FrameworkUserRunStatus({
})

Collapse
 
paul_melero profile image
Paul Melero

I think it'd be useful to include in the Error handler the actual error message returned by the api (on response.status !== 200) 😉

Collapse
 
zoldbogar profile image
zoldbogar

I would have prefered the objectToQueryString to be included in the fetch library...

Collapse
 
wagnerfillio profile image
Wagner Fillio • Edited

Hello, can you explain how I could upload an avatar image associated with json data?

There may be cases where the form can contain an avatar, such as a user form, and there can be cases where the form can only contain data without an avatar.

Collapse
 
aleksandar874 profile image
aleksandar87

create,
read,
update,
delete

instead of

get,
create,
update,
remove

Collapse
 
eaich profile image
Eddie

Read is a great alternative instead of get. Unfortunately, "delete" is a reserved keyword in JavaScript.

Collapse
 
aleksandar874 profile image
aleksandar87

I do it similarly on my daily job.

Simplified structure:

book/
create
read
update
delete

books/
available
availableByType
specific

Thread Thread
 
eaich profile image
Eddie

Oh I see. Yes, that's a good solution too. Thanks for sharing.