apiator

Before Apiator

Usage no npm install needed!

<script type="module">
  import apiator from 'https://cdn.skypack.dev/apiator';
</script>

README

Apiator

The awaited response to the request for a shiny new API service.

Before Apiator

// Example of using Fetch API:
const fetchBooks = async () => {
  try {
    const response = await fetch('http://my-api.com/books', {
      method: 'GET',
    });
    if (!response.ok) {
      // Whoops.
      return;
    }
    const books = await response.json();
    // Do stuff with `books`...
  } catch (err) {
    // Whoops.
  }
};

🦾🤖 Now with Apiator 🦾🤖

const fetchBooks = async () => {
  const [err, books] = await api.get('books').send();
  if (err) {
    // Whoops.
    return;
  }
  // Do stuff with `books`.
};

Core feautures include...

  • No boilerplate, easy-as-it-gets configuration.
  • In-built encoding.
  • No try/catch needed, no unhandled promise warnings.
  • Keep error handling close to the request.

And more...

  • Send multiple requests at once.
  • Painlessly abort any request.
  • Use events for global loader, error handling etc.
  • Customize every feature and add your own.

Install

yarn add apiator

- or -

npm install apiator

Quick start example

Setup your customized API service.

// api.js
import { createInstance } from 'apiator';

export default createInstance({
  baseUrl: 'https://my-api.com/',
});

Use it anywhere in your code.

import api from './api';

const findBook = async (id = '123') => {
  const [err, book] = await api.get('books/:id', {
    params: {id},
  }).send();
  // => GET https://my-api.com/books/123
  // ...
};

const findAllBooks = async () => {
  const [err, books] = await api.get('books', {
    query: { orderBy: 'latest', authors: ['max', 'john'] },
  }).send();
  // => GET https://my-api.com/books?orderBy=latest&authors[]=max&authors[]=john
  // ...
};

const createBook = async () => {
  const [err, book] = await api.post('books', {
    body: { title: 'My Book' },
  }).send();
  // => POST `{"title": "My Book"}` to https://my-api.com/books
  // ...
};

const updateBook = async (id = '123') => {
  const [err, book] = await api.post('books/:id', {
    params: {id}
    body: { title: 'New Title' },
  }).send();
  // => POST `{"title": "New Title"}` to https://my-api.com/books/123
  // ...
};

const deleteBook = async (id = '123') => {
  const [err] = await api.delete('books/:id', {
    params: {id}
  }).send();
  // => DELETE https://my-api.com/books/123
  // ...
};

Table of contents

Documentation

Create API service with createInstance() or createInstanceWith()

Apiator's flexible architecture lets you fully extend and manipluate its features. For your convenience, Apiator is shipped with commonly used core features that use the Fetch API for requests.

createInstance() lets you create an API service with default features.

import { createInstance } from 'apiator';

const api = createInstance({
  // Following are default configs...

  // `baseUrl` will be automatically prepended to all your request URLs.
  baseUrl: false,

  // `contentType` feature is a shortcut for setting the 'Content-Type' header.
  contentType: 'auto',

  // `headers` lets you define any other headers.
  headers: current => {},

  // `opts` lets you set additional request options.
  opts: current => {},

  // `payload` carries any other info that you want to use for your request handling.
  payload: undefined,

  // `debug` feature automatically writes infos and warnings to your console.
  debug: false,

  // `format` feature specifies how the response shall be formatted.
  format: 'auto',

  // `fetch` lets you set the reference to Fetch API's `fetch` function.
  // This is required on node.js. See 'Feature' section below for more.
  fetch: fetch,
});

createInstanceWith() lets you create an API service with any different bundle of feature than the default one:

import { createInstanceWith, CoreFeatures } from 'apiator';
import { MyFeatures } from 'somewhere';
const api = createInstanceWith([...CoreFeatures, ...MyFeatures]);

Both, features and configs, can be manipulated any time later on. You can also add or remove single features.

For more details on features see 'features' section of the documentation.

Update default configs with set()

You can extend your global configs with the set() method. It will only overwrite the arguments you passed. The passed object of configs is equally structured as on the createInstance() method (and as on any request as we will learn in a bit).

api.set({
  baseUrl: 'https://my-api.com/authenticated/',
});
// `baseUrl` is now updated for all following requests.
// All other configs stay the same.

Create requests with get(), post(), put(), delete() or request()

Once your API service was created you can start sending a GET, POST, PUT, DELETE request with the get(), post(), put(), delete() method, respectively. They accept two parameters: a required url and optional extraInput. They return a request object with a send() and abort() method. Let's have a look:

Syntax:

const request = api[method](url [,extraInput])

Example:

// Create request:
const request = api.get('books', {
  // Request configs:
  query: { ID: 123 },
});
// Send request:
await request.send();
// Abort request:
await request.abort();

You can also use request(), e.g. for sending other types of methods:

Syntax:

const request = api.request({
  method,
  url,
  [...extraInput]
})

Example:

const request = api.request({
  method: 'HEAD',
  url: 'books/:id',
  params: { id: 12 },
});

Send requests with send()

It is as simple as it gets:

const request = api.get('books');
const [err, books] = await request.send();

We can rewrite the above example in one line by immediately calling send():

const [err, books] = await api.get('books').send();

In this example send() will send a GET request to https://your-base-url.com/books and return a [err, books] duplet. The first argument of the duplet holds an error object if something went wrong during sending. The second argument holds the output of the request if everything worked out fine.

send() is an async function so don't forget to to use it with await.

NOTE: Duplets are a core feature that can be disabled, meaning that you can also use your service with try/catch error handlers instead. See duplet section for more details.

Abort requests with abort()

Any request object holds an abort() method that will cancel the request for you.

let request;

const async abortBooksRequest = () => {
  if (request) await request.abort();
};

const sendBooks = async () => {
  request = api.get('books');
  const [err, books] = await request.send();
  if (err) {
    // `err` will be defined if aborted.
    if (!err.is.ABORT_ERROR) {
      // This way you can only show an error warning if
      // it was NOT due to aborting.
    }
  }
  // ...
  request = null;
};

Core features

baseUrl

Automatically prependeds to all your request URLs. This is optional and can be overwritten for every request.

Default: false

Accepts: false, any URL string

// Set `baseUrl` globally for all following requests:
api.set({
  baseUrl: 'https://your-base-url.com/',
});

// Use default baseUrl:
const [err, books] = await api.get('books').send();
// => https://your-base-url.com/books

// Overwrite baseUrl per request:
const [err, books] = await api
  .get('books', {
    baseUrl: 'https://another-base-url.com/',
  })
  .send();
// => https://another-base-url.com/books

// Disable baseUrl per request:
const [err, books] = await api
  .get('https://full-url.com/books', {
    baseUrl: false,
  })
  .send();
// => https://full-url.com/books

// Disable gloablly:
api.set({ baseUrl: false });

params

Replaces :-escaped placeholders in URL with a given value per request.

Default: undefined

Accepts: flat 'key/value' object

const [err, book] = await api
  .get('books/:id', {
    params: { id: '123' },
  })
  .send();
// => GET https://my-api.com/books/123

query

Appends encoded query string to the URL per request.

Default: undefined

Accepts: flat 'key/value' object

const [err, books] = await api
  .get('books', {
    query: { orderBy: 'latest', authors: ['max', 'john'] },
  })
  .send();
// => GET https://my-api.com/books?orderBy=latest&authors[]=max&authors[]=john

NOTE: Only flat arrays are supported as shown in the example above.

body

Sets the request body.

Default: undefined

Accepts: any type of data

const [err, book] = await api
  .post('books/:id', {
    params: {id: '123'}
    body: { title: 'New title' }, // <- The post body.
  })
  .send();

contentType

Manages the 'Content-Type' header.

Default: 'auto'

Accepts: 'auto', false, any other content type string

You can either pass the 'Content-Type'-value, e.g. 'audio/mpeg', you can set it to 'auto' (default value) or disable it with false.

The default value 'auto' will let the feature try to set the right 'Content-Header' based on your provided input.

// Set feature:
api.set({
  contentType: 'auto', // Default option.
  // contentType: false, // Feature is now disabled.
  // contentType: 'audio/mpeg', // Set the type directly.
});

// Send form data:
const body = new FormData(); // Feature will recognize form data.
formData.append('title', 'New book');
const [err, books] = await api.post('books', { body }).send();
// => 'Content-Type' header is set to 'multipart/form-data'.

// Send JSON:
const body = { title: 'New book' }; // JSON will be recognized.
const [err, book] = await api.post('books', { body }).send();
// => 'Content-Type' header is set to 'application/json'.

// Implicitly set content type:
const [err, song] = await api
  .post('songs', { body, contentType: 'audio/mpeg' })
  .send();

format

Defines how the service should format the response body of the server before passing it back to you.

Default: 'auto'

Accepts: 'auto', false, any Fetch API's body method such as 'json', 'text', 'arrayBuffer', 'blob', 'formData'

'auto' will try to find an appropriate format based on the Content-Type header of the server's response:

'Content-Type' header of response Used Fetch API format method
application/json json
multipart/form-data formData
text/* text
other MIME types blob

You can overwrite the default format with every request:

const [err, htmlString] = await api
  .get('my-html-endpoint', {
    format: 'text',
  })
  .send();

false will not format anything and returns the native Fetch API response object instead:

const [err, response] = await api
  .get('my-html-endpoint', {
    format: false, // Disable `format` feature.
  })
  .send();
if (!err) {
  // Access the native Fetch API response:
  const htmlString = await response.text();
}

headers()

Defines default headers on init by returning them in the headers() function.

Default: undefined

Accepts: flat 'header/value' object

api.set({
  headers: () => ({
    'X-App-Name': 'MyApp',
  }),
});

You can alter the default headers for every request by setting a headers() function. It receives an object with the current default headers that you can change and return back.

// Example of extending the default headers.
const [err, books] = await api
  .get('books', {
    headers: current => ({
      ...current,
      'X-My-Header': '123',
    }),
    // => { 'X-App-Name': 'MyApp', 'X-My-Header': '123' }
  })
  .send();

// Example of replacing all default headers by ignoring the defaults.
const [err, books] = await api
  .get('books', {
    headers: () => ({ 'X-My-Header': '123' }),
    // => { 'X-My-Header': '123' }
  })
  .send();

opts()

Lets you define additional options that will be passed on to the Fetch API request. See Fetch API docs for a full list of available options.

NOTE: if you pass options such as body, headers and method that are handled by other features you will overwrite them.

You can globally change opts() for every following request by defining an opts() function.

api.set({
  opts: () => ({
    cache: 'force-cache',
  }),
});

You can alter requests options by passing a opts function.

const [err, books] = await api
  .get('books', {
    opts: current => ({
      ...current,
      cache: 'no-cache',
    }),
  })
  .send();

payload

Adds additional data to request and event context.

Default: undefined

Accepts: anything: string, object, number, ...

// Log request unless disabled.
const onResult = ({ context }) => {
  const { payload } = context;
  if (!payload.loggingDisabled) {
    // Log the request...
  }
};

api.on('result', onResult);

const [err] = await api
  .post('analytics', {
    body: { event: 'opened_settings' },
    payload: { loggingDisabled: true },
  })
  .send();

duplet

Returns an error/response duplet instead of throwing errors.

Default: true

Accepts: true, false

// Default behavior:
const [err, res] = await api.get('books').send();

// When disabled:
api.set({
  duplet: false,
});
try {
  const books = await api.get('books').send();
} catch (err) {
  // ...
}

debug

Automatically logs requests/errors and their context in your console. Especially useful for non-browser environments like React Native and others.

Default: false

Accepts: true, false

Send and abort multiple requests with requests()

fetch

Lets you set the Fetch API fetch function used for handling the request. This is only required if your environment does not support Fetch API.

Default: undefined

Accepts: A Fetch API conform fetch implementation.

This is necessary when you are in a node.js environment. Here is an example for using node-fetch:

import * as fetch from 'node-fetch'; // <-- Import the Fetch API implementation.
import { createInstance } from 'apiator';
const api = createInstance({
  baseUrl: 'https://my-api.com/',
  fetch, // <-- Set the reference to package.
});

Send and abort multiple requests with requests()

You can send multiple requests with the requests() method. It works in accordance with sending a single request. The result will contain a duplet [errors, outputs] with a potential array of errors and the outputs.

const requests = api.requests([
  api.get('books/:id', {
    params: { id: '123' },
  }),
  api.get('authors', {
    query: { books: ['123'] },
  }),
]);
const [errs, [book, authors]] = await requests.send(); // Sends all requests.
if (errs) {
  // An array of one or more error objects.
}

// ...
await requests.abort(); // Aborts all requests at once.

NOTE: errors will only contain errors that occured, so it can be shorter than the amount of requests. Otherwise it is undefined. outputs will always be equal to the amount of requests but will contain an undefined item for the request that failed.

Handle events with on() and off()

Available events are send and result.

You can register events with the on() method.

const onSend = async ({ request }) => {
  // Do stuff...
};

const onResult = async ({ error, response, request }) => {
  // Not all event data is always available. For instance
  // `error` is undefined if everything worked out fine.
};

api.on('send', onSend);
api.on('result', onResult);

// Check out the 'deep dive' section for a practical example.

You can remove events with the off() method.

api.off('send', onSend);
api.off('result', onResult);

Add and remove features with use() and discard()

Features enable you to manipulate all inputs and outputs. In fact, Apiator is using its own features under the hood for its core functionality.

You can add and remove features any time. Take a look at this example:

import { CoolFeature } from 'somewhere';
import { DupletFeature } from 'apiator';

api.use(CoolFeature); // That's it!
api.discard(CoolFeature); // Aaand it's gone!

api.discard(DupletFeature); // No more duplet results.

The request object

Property Type Description
getInput() async function Returns the data that was passed for creating the request.
getContext() async function Returns the data that is used for handling the request, e.g.: method, url, headers, body
send() async function Sends the request.
abort() async function Aborts the request.

The requests object

Property Type Description
list array The list of all contained request objects.
send() async function Sends all request.
abort() async function Aborts all request.

Error types

Error type Description
SCRIPT_ERROR Something went wrong during execution. Check error message.
HTTP_ERROR The server returned a HTTP error code.
NETWORK_ERROR The server could not be reached. Most likely due to no connection.
ABORT_ERROR The request was aborted.

The error object

Property Type Description
type string One of the error types.
is object Convenience object to check for error type, e.g. if (error.is.HTTP_ERROR) ...
message string Contains more details on what went wrong.
request* object The request object that relates to this error. *Not available on SCRIPT_ERROR.
response* object The unformatted response (i.e. Fetch API response object). *Not available on SCRIPT_ERROR or NETWORK_ERROR.

Deep dive

Handle loaders and errors via events

A standard use case is a global loader and error handling that also checks for your custom payload options. Remember, payload is a feature that you need to handle yourself, this is just an example of how payload could be used to control event behavior.

const onSend = ({ request }) => {
  const { payload } = await request.getContext();
  // Check if loader was disabled in the payload...
  if (payload?.noLoader) return;

  // Code that shows a loader...
};

const onResult = async ({ error: err, response, request }) => {
  let errorMessage
  let defaultErrorMessage = 'Something went wrong.'
  try {
    const context = await request.getContext();
    const { payload } = context;

    // Code that hides the loader...

    if (!err) {
      if (payload.successMessage) {
        // Code that shows `payload.successMessage`.
      }
    } else {
      // Handle error.
      // Check if error warning was disabled in the payload
      // and do not show warning if user aborted.
      if (payload?.noErrorWarning || err.is.ABORT_ERROR) return;

      // Define general error message.
      if (err.is.HTTP_ERROR) {
        // If an HTTP_ERROR occured `response` will be available.
        // Assuming your server returns a message on what went wrong
        // you can extract it from the response.
        errorMessage = await response.text();
      } else if (err.is.NETWORK_ERROR) {
        // Show message that user should check internet connection.
        errorMessage = 'No internet connection.';
      } else {
        // Show default error message.
        errorMessage = defaultErrorMessage;
      }
    }
  } catch {
    // Show default error message.
    errorMessage = defaultErrorMessage;
  }
  if (errorMessage){
    // Code that shows an error warning with `errorMessage`...
  }
};

api.on('send', onSend);
api.on('result', onResult);

Here is an example of how it works:

const createBook = async () => {
  const [err, book] = await api
    .post('books', {
      body: { title: 'New book' },
      payload: { noErrorWarning: true, successMessage: 'Book was created.' },
    })
    .send();
  if (err) return;
  // We do not need to care about handling the error message since
  // the event handler is showing a warning automatically.
  // In this example, however, we also told the event handler to show a
  // success message and refrain from showing a warning for this request.
};

Handle authorization with headers

In some cases you will want to update the headers globally to authorize the user in all following requests:

const login = async () => {
  const [err, res] = await api
    .post('login', {
      body: { email: 'test@test.com', pw: 'a_secure_pw' },
    })
    .send();
  if (err) {
    // Login failed
  } else {
    api.set({
      headers: current => ({
        ...current,
        Authorization: 'Bearer ' + res.token,
      });
    })
    // The 'Authorization' header will from now on automatically
    // be set in all following requests.
  }
}

const logout = () => {
  api.set({
    headers: current => {
      delete current.Authorization
      return current
    }
  });
  // From now on 'Authorization' header will be gone on all
  // following requests.
}

Omit error or response

You can send a one time attempt without caring for success and response by not assigning a duplet.

await api
  .post('analytics', {
    body: { action: 'opened_book', meta: { book_id: '123' } },
  })
  .send();

If you don't really care about the response you can simply not assign it.

const [err] = await api
  .post('books', {
    body: { title: 'New book' },
  })
  .send();
if (err) {
  // Mmm, maybe we should try again. Let's show our user a "retry" button.
}

Do not omit the error if you need to work with the response! Of course you don't need to do anything with the error really but at least check if there was one.

const showBooksMaybe = async () => {
  const [err, books] = await api.get('books').send();
  if (err) return;
  // Ok, now we can do something with `books`...
};

But what if we flip the duplet around? We know it is tempting but the motivation is to force you to actively think about what to do with a potential error so you won't forget it when you really shouldn't have.

Custom features

Features are still undergoing development. We will provide detailed documentation once it is ready. If you like play around with features here is a full example of an intercepting feature:

const CoolFeature = () => {
  const defaults = {
    test: '123',
  };

  return {
    name: 'custom/cool',
    set: args => {
      if (args.test) {
        defaults.test = args.test;
      }
    },
    intercept: {
      request: {
        input: input => {
          // Do stuff...
          return input;
        },
        context: (context, { input }) => {
          context.test = input?.test || defaults.test;
          return context;
        },
        response: (response, { context, input }) => {
          // Do stuff...
          return response;
        },
      },
    },
    on: {
      send: ({ request }) => {
        // Do stuff.
      },
      result: ({ error, response, request }) => {
        // Do stuff.
      },
    },
  };
};

Here is an example of a feature that disables the baseUrl if a full URL was passed:

import { createInstance, Feature, BaseUrlFeature } from 'apiator';

const api = createInstance({
  baseUrl: 'https://my-api.com/',
});

/**
 * Disables base URL automatically if we have a full URL.
 */
const StreetSmartBaseUrlFeature: Feature = {
  intercept: {
    request: {
      input: input => {
        if (input.url?.startsWith('http')) {
          // Disable baseUrl if it seems to be a full URL string.
          input.baseUrl = false;
        }
        return input;
      },
    },
  },
};

api.use(StreetSmartBaseUrlFeature);

// Now...
api.get('https://other-domain.com/test').send();
// ...will be send to 'https://other-domain.com/test'
// and NOT to 'https://my-api.com/https://other-domain.com/test'.
// It saves you from taking care of disabling baseUrl manually:
api.get('https://other-domain.com/test', { baseUrl: false }).send();

Intercept the response

You can intercept the response by writing a tiny feature.

/*
 * Checks if response is wrapped in 'data' and returns accordingly.
 */
const InterceptResponseFeature = {
  name: 'custom/intercept-response',
  intercept: {
    request: {
      response: response => {
        if (response.data) {
          return response.data;
        }
        return response;
      },
    },
  },
};

api.use(InterceptResponseFeature);