There are a number of use cases regarding API client within front-end application. Some of them includes:
-
Single API client (static)
Shouldn't be a problem with this one. Configuration can be statically defined upfront.
-
Single API client (dynamic)
API client config might be dynamically set at runtime. For example, a difference in baseURL based on remote config.
-
Multiple API client (static)
Same with (1), but with multiple clients, each pointing onto different endpoint.
-
Multiple API client (dynamic)
Same with (3), but dynamically configured at runtime.
-
Unit test
Each API client, either static or dynamic, must be easily mocked.
API client configuration pattern demonstrated within this project is meant to solve above problems.
.
├── api
| ├── auth.js
| ├── ...
| ├── news.js
| └── user.js
├── api-client
| ├── main.client.js
| ├── ...
| ├── some-auth-service.client.js
| └── some-third-party.client.js
├── api-utils
└── tests
Folder | Description |
---|---|
api | This is where all the abstracted request placed. Can be grouped by feature. |
api-client | This is where we define API clients needed for the app. |
api-utils | Some utilities regarding axios instance |
tests | Unit test suites |
Say, the backend service is progressively migrating onto a different framework, and the migrated endpoints is accessible within /v2/
route.
import { getUserById, getUsers } from '/api/user.js'
const customConfig = {
baseURL: `https://some.endpoint/v2`
}
// this points onto /v2/
getUserById.call(customConfig, '290191')
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
// this points onto the default baseURL
getUsers(1, 10)
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
Sometimes during development, the backend service is not ready yet. In such condition, we can either choose to:
-
create a fixture, which then be statically imported
While this is convenient, a static import is a totally different data flow than a network request, due to the asynchronicity.
// fixture/user-data.js export default { name: 'Adrian', birthday: '1991-01-29' } // somewhere in your app import userDataFixture from '/fixture/user-data' // fixture is accessed statically let user = userDataFixture
-
or, consume such fixture within a simulated/mocked network request
Using this method, we can still use the abstracted request method, but bound onto the mock adapter.
import userDataFixture from '/fixture/user-data' mock.onAny().replyOnce(200, userDataFixture) let user getUserById.call(mock, '290191') .then((res) => { // fixture is handled as if it was a network request user = res.data }) .catch((err) => { // handle error })
Somewhere in the future, when the backend service is ready, we can simply remove the bound context.
- import userDataFixture from '/fixture/user-data' - mock.onAny().replyOnce(200, userDataFixture) let user - getUserById.call(mock, '290191') + getUserById('290191') .then((res) => { user = res.data }) .catch((err) => { // handle error })
We can unit test each request by importing the corresponding method.
import MockAdapter from 'axios-mock-adapter'
import { instance } from '/api-client/main.client'
import { getUserById } from '/api/user.js'
describe('API: user', () => {
test('getUserById: correctly returns user data', (done) => {
const userId = '290191'
const mock = new MockAdapter(instance)
.onAny()
.replyOnce(201, { /* user data fixture */ })
getUserById.call(mock, userId)
.then((res) => {
expect(res.data).toEqual(
expect.objectContaining({
/* expected object */
})
)
done()
})
.catch(done)
})
})
Mock server can be easily setup nowadays, some might use Postman/JsonServer for that. That said, unit test should only focus on how corresponding logic works locally. As for the actual network request, it should have been tested within the integration test environment.
-
Create the client instance in
/api-client
folder. For example,main.client.js
.// main.client.js import axios from 'axios' export const instance = (function () { const _instance = axios.create({ /* define config */ }) /* define any other thing here, e.g interceptors */ return _instance })()
-
Import
baseRequest
from/api-utils/axios.base-request
# main.client.js import axios from 'axios' + import { baseRequest } from '/api-utils/axios.base-request'
-
Create a wrapper for
baseRequest
, supplying the previously created instance as argument. Exported function name be can anything. See also: Function#call.# main.client.js + export function request(config) { + return baseRequest.call(this, config, instance) +}
-
Final code should at least look like this.
import axios from 'axios' import { baseRequest } from '../api-utils/axios.base-request' export const instance = (function () { const _instance = axios.create({ // ... }) return _instance })() export function request(config) { return baseRequest.call(this, config, instance) }
Any other client can be defined using the same pattern under different filename, e.g
secondary.client.js
,some-service.client.js
.
Instead of calling axios
method directly, e.g axios#get
, axios#post
, create an abstraction of each request. This can be placed within /api
folder, and can also be grouped by feature.
.
├── api
│ ├── article.js
│ ├── ...
│ ├── news.js
│ └── user.js
For example, in user.js
import { request } from '/api-client/main.client'
export function getUserById(id) {
return request.call(this, {
url: `/user/${id}`
})
}
export function getUsers(page = 1, limit = 10) {
return request.call(this, {
url: `/users`,
params: {
page,
limit
}
})
}
Function#call
is a must, so each abstracted request can be easily overriden by supplyingthis
context.
Simply import methods defined in /api/<feature>.js
folder.
import { getUserById } from '/api/user.js'
getUserById('290191')
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
This is used for API client with dynamic configuration, as mentioned in the (2) and (4) use cases.
For example, we want to use version 2's baseURL, instead of the default version 1's.
import { getUserById } from '/api/user.js'
const customConfig = {
baseURL: 'https://some.endpoint/v2'
}
getUserById.call(customConfig, '290191')
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
customConfig
can of course be fetched asynchronously, from cache, or anywhere else.
import { getUserById } from '/api/user.js'
async function () {
const baseURL = await getAPIBaseUrl()
// const baseURL = myCookieParser.parse('api_base_url')
// const baseURL = window.sessionStorage.get('api_base_url')
// const baseURL = window.localStorage.get('api_base_url')
const customConfig = { baseURL }
getUserById.call(customConfig, '290191')
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
}
To override the configuration of an API client instance, we can use the exported instance and mutate its config.
import { instance } from '/api-client/main.client'
import _merge from 'lodash.merge'
// as in previous example, customConfig can also be fetched asynchronously
const customConfig = {
baseURL: 'https://some.endpoint/v2'
}
// we can use lodash.merge to merge multiple config object
instance.defaults = _merge(instance.defaults, customConfig, /* ... */)
// or just reset the default config with the new one
instance.defaults = customConfig
// all kinds of axios instance mutation should also applies here
instance.interceptors.request.use(/* interceptors */)
instance.interceptors.response.use(/* interceptors */)
This might be the case when a totally different config is needed for the API client, e.g different interceptors, authorization header, etc.
import axios from 'axios'
import { getUserById } from '/api/user.js'
const customClient = axios.create({
// define config
})
getUserById.call(customClient, '290191')
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
Such customized client can be defined per request basis, or defined on a higher level which then be imported into above file.
Simply bind the abstracted request to the mock adapter instance using Function#call
.
import MockAdapter from 'axios-mock-adapter'
import { instance } from '/api-client/main.client'
import { getUserById } from '/api/user.js'
import fixtureData from '/path/to/fixture'
const mock = new MockAdapter(instance)
mock.onAny('/something').replyOnce(200, fixtureData)
getUserById.call(mock, '290191')
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
You can also supply an empty axios instance for the mock adapter. Just be aware that it might not behave the same as pre-configured axios instance, especially when such instance include interceptors etc.
import MockAdapter from 'axios-mock-adapter'
- import { instance } from '@/api-client/main.client'
+ import axios from 'axios'
import { getUserById } from '/api/user.js'
const mock = new MockAdapter(axios.create({}))
mock.onAny().replyOnce(200, fixtureData)
getUserById.call(mock, '290191')
.then((res) => {
// handle response
})
.catch((err) => {
// handle error
})
This pattern is heavily inspired by and is an improvement upon the pattern used in @jabardigitalservice.