import axios, {AxiosInstance, AxiosRequestConfig, AxiosResponse} from 'axios'
import {deepMerge} from 'grommet/utils'
type ErrorRecord = {
date: string
error: string
}
type ApiStatus = {
inflight?: number,
errors?: ErrorRecord[]
}
/**
* This is the parent class for all API interfaces. An API interface
* is the connection between your React web app and a remote JCU API.
*
* From this class, API interfaces inherit the ability to manage a
* base url, have transient headers (such as user tokens) automatically
* added to url requests, and can call convenience functions like
* [getData]{@link ApiInterface#getData} and
* [postData]{@link ApiInterface#postData}.
*
* Use this {@link ApiInterface} by:
*
* - Creating a new API interface class that extends this
* {@link ApiInterface} parent class (see example below)
* - Setting static values {@link interfaceName} and
* {@link interfaceVersion} in your new class
* - Defining your interface's configuration in your application's
* config file (see {@link appConfigSchema.js} for info about
* API interface configurations)
* - Importing your new interface into {@link ApiManager} and calling
* {@link addInterface} to add it to the library of API interfaces.
*
* Once you've done these steps, your new interface will be available
* from the {@link ApiManager}'s {@link getApiInterface}, retrievable
* using the {@link interfaceName} and {@link interfaceVersion} you gave it.
*
* ### Example API interface using this parent class
*
* ```js
* import { deepMerge } from 'grommet/utils'
* import { ApiInterface } from '@jcu/spark'
*
* class ExampleInterface extends ApiInterface {
* static interfaceName = 'example-api' // name of this API interface
* static interfaceVersion = '0.1.0' // version of this interface implementation
*
* // Optional defaults for configuration.
* // Items specified here can be overridden from the
* // config file.
* static defaultConfig = {
* dateFormat: "YYYY-MM-DDThh:mm"
* }
*
* constructor(config) {
* const superConfig = deepMerge(ExampleInterface.defaultConfig, config)
* super(superConfig)
* }
*
* // API-specific action (fetching Examples)
* // should (almost) always be asynchronous
* async fetchExampleData(options = {}) {
* let exampleData = this.getData('')
* .then( (response) => {
* let info = response.data
* // do data manipulation here, e.g. sorting, filtering...
* return info
* })
* .catch( (e) => {
* console.log('problem getting data: ', e)
* return e
* })
*
* return exampleData
* }
* }
* export default ExampleInterface
* ```
*
* ### Mocking API Responses
*
* It's common to mock API data like this:
*
* ```js
* // ... inside your getInfo(...) function:
* return delayedResult({
* data: {
* id: userId,
* name: 'Pierce Hawthorne',
* dob: '1944/11/27'
* }
* }, 5)
* ```
*
* The above returns a promise (similar to regular API fetches) that resolves after 5 seconds (+- 2.5 seconds)
*
* ### Future Direction
*
* Potentially, non-RESTful interfaces like GraphQL or streaming connections might
* require alternative architectures; one option would be that this becomes the base
* class for RESTful APIs, there is another base class for graph APIs, and a common
* super parent for those which handles the common concerns like header management.
*
* @category Core
*/
export class ApiInterface {
/**
* This object provides extended functionality to the ApiInterface class methods
* @typedef {Object} ApiInterface#options
* @property {Object} headers A JSON key/value set of HTTP headers
*/
/**
* An <a href="https://github.com/axios/axios#response-schema">Axios response object</a>
* @typedef {Object} Response
*/
/**
* An <a href="https://github.com/axios/axios#request-config">Axios request object</a>
* @typedef {Object} Request
*/
static defaultConfig: object = {options: {headers: {'Content-Type': 'application/json'}}}
/**
* The string identifier for this API interface. You will use this, along with
* {@link ApiInterface#interfaceVersion interfaceVersion},
* to retrieve an interface from the API library in
* your React app. Use dash-separated, all-lower-case words, e.g. 'eop-schedule',
* 'web-app-config'
* @abstract
* @member {string}
*/
static interfaceName: string = 'NOT_SET'
/**
* Version number (in string format) for this edition of this API interface. You
* will use this, along with {@link ApiInterface#interfaceName interfaceName},
* to retrieve an interface from the API library in your React app.
* Use a three-value semver style version e.g. '1.2.3'
* @abstract
*/
static interfaceVersion: string = 'NOT_SET'
/**
* The current status of the ApiInterface
* @typedef {Object} ApiInterface#status
* @property {number} inflight The number of currently in-flight requests
* @property {array} errors An array of the errors "raised" by the API interface
*/
status: ApiStatus = {
inflight: 0,
errors: []
}
/**
* A Map of transient headers that get attached to axios connections when the interface is retrieved.
*
* Most commonly used for authentication headers, to attach and remove auth tokens during login / logout
*/
transientHeaders: Map<string, string>
transientHeaderKeys: string[] // This is to work around typescript transpiling broken iterators
//TODO: Proper typing for config object
config: any = {}
conn?: AxiosInstance
connectionValid: boolean = false
// Override to specify scopes (use key for a easy ID to refer to in your code)
static scopes = {}
/**
* The ApiInterface constructor. Takes a configuration object.
* @param {Object} config API interface configuration details. Schema in {@link appConfigSchema.js}
* @param {string} config.url Base URL for the HTTP(S) endpoint
* @param {ApiInterface#options} config.options Additional configuration parameters
*
*/
constructor(config: object) {
this.config = deepMerge(ApiInterface.defaultConfig, config)
this.transientHeaders = new Map<string, string>()
this.transientHeaderKeys = []
this.connect()
}
/**
* Adds an error to the interfaces error list
* @param {String} errorDescription A description of the error
*/
addError(errorDescription: string) {
const nowStr = new Date().toISOString()
if (this && this.status && this.status.errors) {
this.status.errors.push({date: nowStr, error: errorDescription})
}
}
/**
* Listener function for adding and removing transient headers. Generally
* you won't need to use this method yourself -- it's used internally by
* the OIDC context to attach and remove transient headers like OIDC user
* tokens.
* @method
* @param {*} header The header to set or remove
* @param {string} value The value of the header
*/
transientHeadersListener(header: string, value: string) {
if (value !== undefined) {
this.transientHeaders.set(header, value)
this.transientHeaderKeys.push(header)
} else if (this.transientHeaders.has(header)) {
this.transientHeaders.delete(header)
this.transientHeaderKeys = this.transientHeaderKeys.filter(key => key !== header)
}
this.connectionValid = false
}
/**
* "Connect" the interface to the configured API endpoint. This does not
* open an actual network connection, it only creates an Axios connection
* object that will be used to handle any requests.
*/
connect() {
const headers = Object.assign(this.config.options.headers)
for (let key of this.transientHeaderKeys) {
headers[key] = this.transientHeaders.get(key)
}
this.conn = axios.create({baseURL: this.config.url, headers: headers})
this.connectionValid = true
}
/**
* Make a HTTP request to the configured API endpoint. Uses Axios library.
* @param {Request} requestConfig An {@link https://github.com/axios/axios#request-config Axios config object}
* for the HTTP request
* @returns {Promise} (A Promise that resolves to) the API response
*/
async query(requestConfig: AxiosRequestConfig) {
// Reconnect interface (sets headers)
this.connect()
if (this.conn) {
const queryPromise = this.conn.request(requestConfig)
// TODO: Caching?
if (this.status && this.status.inflight) {
this.status.inflight += 1
}
// Axios' default error handling definition
// TODO: Add richer error handling
queryPromise.catch((err) => {
if (err.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
this.addError('response code ' + err.response.status + ' from ' + requestConfig.url)
} else if (err.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
this.addError('no response from ' + requestConfig.url)
} else {
// Something happened in setting up the request that triggered an Error
this.addError(err.message)
}
})
// when the query's done, that's one less query in flight
// @ts-ignore
queryPromise.finally(() => this.status.inflight -= 1)
return queryPromise
}
}
/**
* A convenience method that performs a GET request to the API endpoint
* and directly returns the response that carried that data.
*
* This uses {@link ApiInterface#query query} in the background.
*
* @example
* ```
* const {data, status} = await getData(url)
* if (status >= 200 && status < 300) { console.log('all good') }
* ```
*
* @param {string} path Trailing path for the request
* @param {*} [params] Extra query params for the request
* @param {ApiInterface#options} [options] // TODO: Future implementation
* @returns {Promise<AxiosResponse>} the response to the GET call
*/
async getData(path: string, params: any = {}, options: any = {}) {
// Make GET requests
const reqConfig: AxiosRequestConfig = {
params,
url: path,
headers: deepMerge(this.transientHeaders, options.headers),
method: 'GET'
}
return this.query(reqConfig)
}
/**
* A convenience method that performs a POST request to the API endpoint
* and directly returns the server's response to the POST request.
*
* @example
* ```
* const {status} = await getData(url, postData)
* if (status >= 200 && status < 300) { console.log('posted') }
* ```
*
* This uses {@link ApiInterface#query query} in the background.
* @param {string} path Trailing path for the request
* @param {*} [data] JSON data to be sent by the request
* @param {ApiInterface#options} [options] //TODO: Future implementation
* @returns {Promise<AxiosResponse>} the response to the POST call
*/
async postData(path: string, data: any = {}, options: any = {}) {
const reqConfig: AxiosRequestConfig = {
data,
url: path,
headers: deepMerge(this.transientHeaders, options.headers),
method: 'POST'
}
return this.query(reqConfig)
}
/**
* A convenience method that performs a PUT request to the API endpoint
* and directly returns the server's response to the PUT request.
*
* This uses {@link ApiInterface#query query} in the background.
*
* @param {string} path Trailing path for the request
* @param {*} [data] JSON data to be sent by the request
* @param {ApiInterface#options} [options] // TODO: Future implementation
* @returns {Promise<AxiosResponse>} the response to the PUT call
*/
async putData(path: string, data: any = {}, options: any = {}) {
const reqConfig: AxiosRequestConfig = {
data,
url: path,
headers: deepMerge(this.transientHeaders, options.headers),
method: 'PUT'
}
return this.query(reqConfig)
}
/**
* A convenience method that performs a PATCH request to the API endpoint
* and directly returns the server's response.
*
* This uses {@link ApiInterface#query query} in the background.
*
* @param {string} path Trailing path for the request
* @param {*} [data] JSON data to be sent by the request
* @param {ApiInterface#options} [options] // TODO: Future implementation
* @returns {Promise<AxiosResponse>} the response to the PATCH call
*/
async patchData(path: string, data: any = {}, options: any = {}) {
const reqConfig: AxiosRequestConfig = {
data,
url: path,
headers: deepMerge(this.transientHeaders, options.headers),
method: 'PATCH'
}
return this.query(reqConfig)
}
/**
* A convenience method that performs a DELETE request to the API endpoint
* and directly returns the response that carried that data.
*
* Note that unlike some other methods, there is no `data` argument;
* if you need to send data with your DELETE call, supply the data
* structure in `options.data`.
*
* This uses {@link ApiInterface#query query} in the background.
*
* @param {string} path Trailing path for the request
* @param {ApiInterface#options} [options] // TODO: Future implementation
* @param {*} [options.data] JSON data to be sent by the request
* @returns {Promise<AxiosResponse>} the response to the DELETE call
*/
async delete(path: string, options: any = {}) {
const reqConfig: AxiosRequestConfig = {
data: options.data || null,
url: path,
headers: deepMerge(this.transientHeaders, options.headers),
method: 'DELETE'
}
const resp = this.query(reqConfig)
return resp
}
/**
* A utility function returning a Promise that will be
* fulfilled by `data` after a duration of roughly `delay`
* seconds. `delay` will be 3 seconds if not supplied.
*
* By default the delay will vary randomly between
* `0.5 * delay` and `1.5 * delay` (±50%).
*
* If you don't want the delay length to randomly vary,
* supply `true` as the third argument, `fixedDelay`.
* Use this to mock out returning results asynchonously
* from your API calls, for example:
*
* ```
* async getUserInfo(userId) {
* // mock data for now
* return delayedResult({
* data: {
* id: userId,
* name: 'Pierce Hawthorne',
* dob: '1944/11/27'
* }
* })
* }
* ```
*
* **NOTE**: For your benefit, it's best to try and match the response data to the expected response from the API.
* For most JCU APIs, this usually means including a data key as a wrapper for your data (as the APIs tend to return
* their answers in a data key, see below).
*
* ```
* {
* data: {
* ...
* }
* }
* ```
*
* @param {*} data The data you want returned after the delay
* @param {number} [delay] Time (in seconds) to delay returning the response
* @param {boolean} [fixedDelay] Flag to set the delay to fixed instead of +- 50%
*/
delayedResult(data, delay = 3, fixedDelay = false) {
// TODO: Add extra features to support more extensive testing e.g. full mock responses (status codes etc...)
// make sure the user hasn't set a crazy delay
if (delay > 60) {
console.warn('You have asked for a delay of over a minute. Are you sure?')
}
// how long to delay
let millis = 1000 * delay * (fixedDelay ? 1.0 : (0.5 + Math.random()))
// make a promise
console.log('delaying ' + millis)
const res = new Promise((resolve) => {
window.setTimeout(() => {
resolve(data)
}, millis)
}
)
return res
}
getStatus() {
return this.status
}
}
Source