const statuses = require('statuses');
const urijs = require('urijs');
const ObjectUtils = require('./objectUtils');
/**
* @module shared/apiClient
*/
/**
* This kind of dictionary is used for building stuff like query string parameters and
* headers.
*
* @typedef {Object.<string, string | number>} APIClientParametersDictionary
* @parent module:shared/apiClient
*/
/**
* @typedef {Object} APIClientFetchOptions
* @property {string} [method]
* The request method.
* @property {APIClientParametersDictionary} [headers]
* The request headers.
* @property {string} [body]
* The request body.
* @property {boolean} [json]
* Whether or not the response should _"JSON decoded"_.
* @parent module:shared/apiClient
*/
/**
* @callback APIClientFetchClient
* @param {string} url The request URL.
* @param {APIClientFetchOptions} [options] The request options.
* @returns {Promise<Response>}
* @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
* @parent module:shared/apiClient
*/
/**
* @typedef {APIClientFetchOptions & APIClientRequestOptionsProperties} APIClientRequestOptions
* @parent module:shared/apiClient
* @prettierignore
*/
/**
* @typedef {Object} APIClientRequestOptionsProperties
* @property {string} url
* The request URL.
* @parent module:shared/apiClient
*/
/**
* @typedef {Object} APIClientEndpoint
* @property {string} path The path to the endpoint relative to
* the API entry point. It can include
* placeholders with the format
* `:placeholder-name` that are going to
* be replaced when the endpoint gets
* generated.
* @property {?APIClientParametersDictionary} query A dictionary of query string
* parameters that will be added when
* the endpoint. If the value of a
* parameter is `null`, it won't be
* added.
* @parent module:shared/apiClient
*/
/**
* @typedef {string | APIClientEndpoint} APIClientEndpointValue
* @parent module:shared/apiClient
*/
/**
* @typedef {Object.<string, APIClientEndpointValue>} APIClientEndpoints
* @example
*
* { // Endpoint path as a string.
* endpointOne: 'endpoint-one',
* // Endpoint as {APIClientEndpoint}.
* endpointTwo: {
* path: 'endpoint-two',
* query: {
* count: 20,
* },
* },
* // Endpoint as a dictionary of endpoints ({APIClientEndpoints}).
* endpointThree: {
* subEndpointThreeOne: 'sub-endpoint-three-one',
* subEndpointThreeTwo: {
* path: 'sub-endpoint-three-two',
* query: {
* count: 20,
* },
* },
* },
* }
*
* @parent module:shared/apiClient
*/
/**
* An API client with configurable endpoints.
*
* @parent module:shared/apiClient
* @tutorial APIClient
*/
class APIClient {
/**
* @param {string} url
* The API entry point.
* @param {APIClientEndpoints} endpoints
* A dictionary of named endpoints relative to the API entry point.
* @param {APIClientFetchClient} fetchClient
* The fetch function that makes the requests.
* @param {APIClientParametersDictionary} [defaultHeaders={}]
* A dictionary of default headers to include on every request.
*/
constructor(url, endpoints, fetchClient, defaultHeaders = {}) {
/**
* The API entry point.
*
* @type {string}
* @access protected
* @ignore
*/
this._url = url;
/**
* A dictionary of named endpoints relative to the API entry point.
*
* @type {Object.<string, string | APIClientEndpoint>}
* @access protected
* @ignore
*/
this._endpoints = ObjectUtils.flat(
endpoints,
'.',
'',
(ignore, value) => typeof value.path === 'undefined',
);
/**
* The fetch function that makes the requests.
*
* @type {APIClientFetchClient}
* @access protected
* @ignore
*/
this._fetchClient = fetchClient;
/**
* A dictionary of default headers to include on every request.
*
* @type {APIClientParametersDictionary}
* @access protected
* @ignore
*/
this._defaultHeaders = defaultHeaders;
/**
* An authorization token to include on the requests.
*
* @type {string}
* @access protected
* @ignore
*/
this._authorizationToken = '';
}
/**
* Makes a `DELETE` request.
*
* @param {string} url The request URL.
* @param {Object} body The request body.
* @param {APIClientFetchOptions} [options={}] The request options.
* @returns {Promise<Response>}
*/
delete(url, body = {}, options = {}) {
return this.post(url, body, { method: 'delete', ...options });
}
/**
* Generates an endpoint URL.
*
* @param {string} name
* The name of the endpoint on the `endpoints` property.
* @param {APIClientParametersDictionary} [parameters={}]
* A dictionary of values that will replace placeholders on the endpoint definition.
* @returns {string}
* @throws {Error}
* If the endpoint doesn't exist on the `endpoints` property.
*/
endpoint(name, parameters = {}) {
// Get the endpoint information.
const info = this._endpoints[name];
// Validate that the endpoint exists.
if (!info) {
throw new Error(`Trying to request unknown endpoint: ${name}`);
}
// Get a new reference for the parameters.
const params = { ...parameters };
// If the endpoint is a string, format it into an object with `path`.
const endpoint = typeof info === 'string' ? { path: info, query: null } : info;
// Define the object that will have the query string.
const query = {};
// If the endpoint has a `query` property...
if (endpoint.query) {
// ...Loog all the query parameters.
Object.keys(endpoint.query).forEach((queryName) => {
// Get the defined value of the parameter.
const queryValue = endpoint.query[queryName];
// If there's a value of this parameter on the received `parameters`...
if (typeof params[queryName] !== 'undefined') {
// ...add it to the query dictionary.
query[queryName] = params[queryName];
// Remove the used parameter.
delete params[queryName];
} else if (queryValue !== null) {
// If the default value of the parameter is not `null`, use it.
query[queryName] = queryValue;
}
});
}
// Get the endpoint path.
let { path } = endpoint;
// Loop all the received `parameters`...
Object.keys(params).forEach((parameter) => {
// Build how a placeholder for this parameter would look like.
const placeholder = `:${parameter}`;
// Get the parameter value.
const value = params[parameter];
// If the path has the placeholder...
if (path.includes(placeholder)) {
// ...replace the placeholder with the value.
path = path.replace(placeholder, `${value}`);
} else {
// ...otherwise, add it on the query string.
query[parameter] = value;
}
});
// Convert the URL into a `urijs` object.
const uri = urijs(`${this._url}/${path}`);
// Loop and add all the query string parameters.
Object.keys(query).forEach((queryName) => {
uri.addQuery(queryName, query[queryName]);
});
// Return the `urijs` object as a string.
return uri.toString();
}
/**
* Formats an error response into a proper Error object. This method should proabably be
* overwritten to accomodate the error messages for the API it's being used for.
*
* @param {Object} response A received response from a request.
* @param {?string} response.error An error message received on the response.
* @param {number} status The HTTP status of the response.
* @returns {Error}
*/
error(response, status) {
return new Error(`[${status}]: ${response.error}`);
}
/**
* Makes a request.
*
* @param {APIClientRequestOptions} options The request options.
* @returns {Promise<Response>}
*/
fetch(options) {
// Get a new reference of the request options.
const opts = { ...options };
// Format the request method and check if it should use the default.
opts.method = opts.method ? opts.method.toUpperCase() : 'GET';
// Get the request headers.
const headers = this.headers(opts.headers);
// This check is to avoid pushing an empty object on the request options.
if (Object.keys(headers).length) {
opts.headers = headers;
}
// Format the flag the method will use to decided whether to decode the response or not.
const handleAsJSON = typeof opts.json === 'boolean' ? opts.json : true;
const { url } = opts;
// Remove the necessary options in order to make it a valid `FetchOptions` object.
delete opts.url;
delete opts.json;
// If the options include a body...
if (opts.body) {
// Let's first check if there are headers and if a `Content-Type` has been set.
let hasContentType = false;
if (opts.headers) {
hasContentType = !!Object.keys(opts.headers).find(
(name) => name.toLowerCase() === 'content-type',
);
} else {
opts.headers = {};
}
// If the body is an object...
if (typeof opts.body === 'object') {
// ...and if it's an object literal...
if (Object.getPrototypeOf(opts.body).constructor.name === 'Object') {
// ...encode it.
opts.body = JSON.stringify(opts.body);
}
// If no `Content-Type` was defined, let's assume is a JSON request.
if (!hasContentType) {
opts.headers['Content-Type'] = 'application/json';
}
}
}
let responseStatus;
// Make the request.
return this._fetchClient(url, opts)
.then((response) => {
// Capture the response status.
responseStatus = response.status;
let nextStep;
// If the response should be handled as JSON and it has a `json()` method...
if (handleAsJSON && typeof response.json === 'function') {
/**
* Since some clients fail to decode an empty response, we'll try to decode it,
* but if it fails, it will return an empty object.
*
* @ignore
*/
nextStep = response.json().catch(() => ({}));
} else {
// If the response shouldn't be handled as JSON, set to return the raw object.
nextStep = response;
}
return nextStep;
})
.then((response) =>
/**
* If the response status is from an Error, format and return the error; otherwise, return
* the same response.
*/
responseStatus >= statuses('bad request')
? Promise.reject(this.error(response, responseStatus))
: response,
);
}
/**
* Makes a `GET` request.
*
* @param {string} url The request URL.
* @param {APIClientFetchOptions} [options={}] The request options.
* @returns {Promise<Response>}
*/
get(url, options = {}) {
return this.fetch({ url, ...options });
}
/**
* Makes a `HEAD` request.
*
* @param {string} url The request URL.
* @param {APIClientFetchOptions} [options={}] The request options.
* @returns {Promise<Response>}
*/
head(url, options = {}) {
return this.get(url, { ...options, method: 'head' });
}
/**
* Generates a dictionary of headers using the service `defaultHeaders` property as
* base.
* If a token was set using `setAuthorizationToken`, the method will add an
* `Authorization`
* header for the bearer token.
*
* @param {Object.<string, string | number>} [overwrites={}]
* Extra headers to add.
* @returns {Object.<string, string | number>}
*/
headers(overwrites = {}) {
const headers = { ...this._defaultHeaders };
if (this._authorizationToken) {
headers.Authorization = `Bearer ${this._authorizationToken}`;
}
return { ...headers, ...overwrites };
}
/**
* Makes a `PATCH` request.
*
* @param {string} url The request URL.
* @param {Object} body The request body.
* @param {APIClientFetchOptions} [options={}] The request options.
* @returns {Promise<Response>}
*/
patch(url, body, options = {}) {
return this.post(url, body, { method: 'patch', ...options });
}
/**
* Makes a `POST` request.
*
* @param {string} url The request URL.
* @param {Object} body The request body.
* @param {APIClientFetchOptions} [options={}] The request options.
* @returns {Promise<Response>}
*/
post(url, body, options = {}) {
return this.fetch({
url,
body,
method: 'post',
...options,
});
}
/**
* Makes a `PUT` request.
*
* @param {string} url The request URL.
* @param {Object} body The request body.
* @param {APIClientFetchOptions} [options={}] The request options.
* @returns {Promise<Response>}
*/
put(url, body, options = {}) {
return this.post(url, body, { method: 'put', ...options });
}
/**
* Sets a bearer token for all the requests.
*
* @param {string} [token=''] The new authorization token. If the value is empty, it
* will remove any token previously saved.
*/
setAuthorizationToken(token = '') {
this._authorizationToken = token;
}
/**
* Sets the default headers for the requests.
*
* @param {APIClientParametersDictionary} [headers={}]
* The new default headers.
* @param {boolean} [overwrite=true]
* If `false`, it will merge the new default headers with the current ones.
*/
setDefaultHeaders(headers = {}, overwrite = true) {
this._defaultHeaders = {
...(overwrite ? {} : this._defaultHeaders),
...headers,
};
}
/**
* An authorization token to include on the requests.
*
* @type {string}
*/
get authorizationToken() {
return this._authorizationToken;
}
/**
* A dictionary of default headers to include on every request.
*
* @type {APIClientParametersDictionary}
*/
get defaultHeaders() {
return { ...this._defaultHeaders };
}
/**
* A dictionary of named endpoints relative to the API entry point.
*
* @type {Object.<string, string | APIClientEndpoint>}
*/
get endpoints() {
return { ...this._endpoints };
}
/**
* The fetch function that makes the requests.
*
* @type {APIClientFetchClient}
*/
get fetchClient() {
return this._fetchClient;
}
/**
* The API entry point.
*
* @type {string}
*/
get url() {
return this._url;
}
}
module.exports = APIClient;