Illuminate your REST API π‘ with React hooks and a centralized cache.
const [{data, loading, error}] = useApiQuery({url: `/users/${id}`})
Features:
- π React hook for querying HTTP API data
- πΎ Turnkey API response caching
- π Parallel request de-duplication
- π‘ Support for API requests and cache queries outside of React components (in redux sagas, thunks, etc.)
- π₯ Parses response bodies of any data type (
'json'
,'blob'
,'text'
) - π Compatible with almost any REST API
- π³ Exports tree-shakable ES modules
- Designed with full Typescript support
Contents:
- Installation & Setup
- Basic Usage
- Guide
- Response Cache
- Re-fetching a query
- Dependent queries
- Manually updating query data
- Custom response body parsing
- Success response codes
- Custom query string serialization
- Setting default headers (ie. an auth token) for all requests
- Testing
- Error handling
- Typescript
- Usage with Redux
- Server-side rendering (SSR)
- Motivation
- Comparison to similar libraries
- Feature Roadmap
- API Documentation
Install the package as a dependency:
npm install --save fairlight
# or
yarn add fairlight
Create an Api
instance, add your API's baseUrl
, and provide it to your app:
import {Api, ApiProvider} from 'fairlight'
const api = new Api({
// β prefixes all request urls
baseUrl: 'http://your-api.com'
})
const App = () => (
<ApiProvider api={api}>{/* render your app here */}</ApiProvider>
)
You can now define endpoints and make requests in your app.
Query your API:
import React from 'react'
import {useApiQuery} from 'fairlight'
const MyComponent = (props) => {
const [userQuery] = useApiQuery({url: `/users/${props.userId}`})
if (userQuery.error) {
// display error
}
if (userQuery.loading) {
// display loading state
}
return <h1>{userQuery.data.firstName}</h1>
}
As you add more API requests, we strongly recommend organizing your request definitions into centralized "endpoint" classes, grouped by domain/resource. This will help to encapsulate request-specific logic, reduce path-prefixing boilerplate, and automatically serialize query parameters.
Continuing our example, we'll create a UserEndpoints
class to define endpoints under the '/users'
base path. We'll subclass HttpEndpoints
, which gives us static HTTP helper methods:
import {HttpEndpoints} from 'fairlight'
export class UserEndpoints extends HttpEndpoints {
static basePath = '/users'
static list(query) {
return super._get('', {query})
}
static create(body) {
return super._post('', {body})
}
// etc.
}
(All HttpEndpoints
methods are documented here)
If your endpoints follow common REST-ful conventions, you can subclass RestEndpoints
(which subclasses HttpEndpoints
) to reduce REST boilerplate:
import {RestEndpoints} from 'fairlight'
export class UserEndpoints extends RestEndpoints {
static basePath = '/users'
static list(query) {
return super._list(query)
}
static create(body) {
return super._create(body)
}
static findById(id) {
return super._findById(id)
}
static update(id, body) {
return super._update(id, body)
}
static partialUpdate(id, body) {
return super._partialUpdate(id, body)
}
static destroy(id) {
return super._destroy(id)
}
}
(All RestEndpoints
methods are documented here)
Then you can use these endpoints to make queries:
import React from 'react'
import {useApiQuery} from 'fairlight'
import {UserEndpoints} from 'my-app/endpoints'
const MyComponent = (props) => {
const [usersQuery] = useApiQuery(UserEndpoints.list({limit: 10}))
const [userQuery] = useApiQuery(UserEndpoints.findById(props.userId))
// ... etc
}
To create an event handler for form submissions, deletions, etc., use the useApiMutation
hook.
The hook takes a curried mutation handler which is passed the api
for making queries. It returns a mutation function and a mutating
loading flag which you can use for a loading state.
import React, {useState} from 'react'
import {useApiMutation} from 'fairlight'
import {UserEndpoints} from 'my-app/endpoints'
const CreateUserForm = (props) => {
const [createUser, {mutating: creatingUser}] = useApiMutation({
mutation: (firstName: string, lastName: string) => async (api) => {
return api.request(UserEndpoints.create({firstName, lastName}))
},
onError: (error) => console.error(error),
onSuccess: (user) => console.log(`Created user ${firstName}`)
})
return (
<>
{creatingUser && <LoadingSpinner />}
<CreateUserForm
onSubmit={(firstName, lastName) => {
createUser(firstName, lastName)
}}
/>
</>
)
}
(useApiMutation
API documentation is here)
The caching functionality of useApiQuery
is determined by its fetchPolicy
. By default, this is set to cache-and-fetch
: the first time you use useApiQuery
for a given request, it will fetch the data and save it. The next time useApiQuery
is used for that same request, the hook will return the cached response data
and make a request for a fresh version, allowing the UI to display a value quickly to the user. This pattern is called stale-while-invalidate
.
This fetch policy can be overridden on a per-request:
const [users] = useApiQuery(UserEndpoints.list(), {
fetchPolicy: 'no-cache' // disables the cache altogether
})
Here are the possible fetch policies:
Field | Description |
---|---|
no-cache |
Only fetch from the server and never read from or write to the cache. |
cache-first |
The request will first attempt to read from the cache. If the data is cached, return the data and do not fetch. If the data isn't cached, make a fetch and then update the cache. |
fetch-first |
The request will fetch from the server, and then write the response to the cache. |
cache-only |
The request will only read from the cache and never fetch from the server. If the data does not exist, it will throw an ApiCacheMissError . |
cache-and-fetch |
The request will simultaneously read from the cache and fetch from the server. If the data is in the cache, the promise will resolve with the cached results. Once the fetch resolves, it will update the cache in the background and emit an event to notify listeners of the update. If the data is not in the cache, the promise will resolve with the result of the fetch and then update the cache. |
If you'd like to override the default fetchPolicy
for useApiQuery
, you can pass a defaultFetchPolicies
prop to <ApiProvider />
:
const App = () => (
<ApiProvider api={api} defaultFetchPolicies={{useApiQuery: 'fetch-first'}}>
{/* Render app */}
</ApiProvider>
)
Note that calling api.request()
directly always defaults fetchPolicy
to 'no-cache'
, but you can override this per-request if you'd like to interact with the cache.
It can be useful to read and write directly to and from the cache. For this, you can use Api#readCachedResponse
and Api#writeCachedResponse
. View the API examples for usage.
Requests are keyed by a deterministic hash of its params. These properties include: url
, method
, headers
, responseType
, and extraKey
.
Note that the body
is not included in the cache key, so equivalent POST
requests with different body
payloads would have the same cache key. In these instances, if you would like to utilize the cache, you can set an extraKey
for that request. See the documentation for api.request
for more details.
Sometimes, it may be desirable to re-fetch the data in response to a user action. This can be done using the refetch()
action returned by the hook:
Example:
const [users, usersQueryActions] = useApiQuery(UserEndpoints.list())
return (
<button
type='button'
onClick={() => {
usersQueryActions.refresh()
}}
>
Refresh Users
</button>
)
- This will always make a fetch to the user, no matter what
fetchPolicy
is. - If
fetchPolicy: 'no-cache'
is passed touseApiQuery
, the cache will not be updated after the fetch completes. IffetchPolicy
is set to anything else, the cache will be updated after the request completes. - By default,
deduplicate
is alwaysfalse
, meaning it will always make a fresh request. You can override this by passing adeduplicate
option:refresh({ deduplicate: true })
. - If you want to reinitialize
data
tonull
when you callrefresh
, you can do so by passing areinitialize
option:refresh({ reinitialize: true })
Full documentation is available here.
If one query depends on data that isn't yet available, or on the results of another query, you can pass a falsy value to useApiQuery
. query.data
will be null
and query.loading
will be false
.
Example:
const [users] = useApiQuery(UserEndpoints.list({email: props.userEmail}))
const [friends, friendsQueryActions] = useApiQuery(
users.data &&
users.data.length > 0 &&
UserProfileEndpoints.list(users.data[0].id)
)
// friends.data === null
// friends.loading === false
// note that `refetch` will be a NOOP:
friendsQueryActions.refetch()
useApiQuery
returns a setData
method which allows you to manually set the data stored by the hook (similar to react#useState's functional updates).
For example, suppose we have a query to get a user:
const [user] = useApiQuery(UserEndpoints.findById(1))
Now, suppose we also have a mutation which updates a user. This API request happens to returns the updated user, so we can use setData
to update our useApiQuery
data with the most up-to-date user.
const [user, userQueryActions] = useApiQuery(UserEndpoints.findById(1))
const [saveUser] = useApiMutation({
mutation: (values) => (api) => {
return api.request(UserEndpoints.partialUpdate(1, values))
},
onSuccess: (updatedUser) => {
userQueryActions.setData(updatedUser)
// ββ this updates `user.data`
},
onError: (error) => {
// etc
}
})
Note: if fetchPolicy
is not no-cache
, this will persist directly to the response cache.
By default, responses will attempt to be parsed to 'json'
, 'text'
, or 'blob'
formats using the Content-Type
header on the response:
Pattern | Parsed Value |
---|---|
'application/json' |
'json' |
'text/*' |
'text' |
'(application|image|video)/*' |
'blob' |
However, you can manually specify the parse type by passing responseType: 'json' | 'text' | 'blob'
to your request:
class TransactionEndpoints extends HttpEndpoints {
static basePath = '/transactions'
static csvExport() {
return super._get('/export.csv', {
responseType: 'text'
})
}
}
const csv = await api.request(TransactionEndpoints.csvExport)
// typeof csv === 'string'
By default, requests with response statuses in the 200
-range (200-299) will be considered successful, with anything outside of the range throwing an ApiError
. If you'd like to customize the success statuses, you can set successCodes
:
class UserEndpoints extends HttpEndpoints {
static basePath = '/users'
static create(body) {
return super._post('', {
body,
successCodes: [201]
})
}
}
await api.request(
UserEndpoints.create({
firstName: 'Jane',
lastName: 'Doe'
})
)
// β any response status other than `201` will throw `ApiError`
If you want to provide your own query string serialization, you can override HttpEndpoints#_serializeQuery
.
Note that it may be useful to apply this logic to all of your endpoints by creating a BaseEndpoints
class and extending from that:
import {qs} from 'qs'
class BaseEndpoints extends HttpEndpoints {
static _serializeQuery(query) {
return qs.stringify(query)
}
}
class UserEndpoints extends BaseEndpoints {
static basePath = '/users'
static list(query) {
return super._get('/', {query}) // will use `qs.stringify`
}
}
A common use case is to pass an authentication token to requests after a user has logged in. This can be achieved using default headers:
api.setDefaultHeader('X-Auth-Token', token)
Now, all of your queries will pass this additional header to requests.
Currently, the recommended method of testing is to mock fetch
at the source. jest-fetch-mock
is a useful tool for this.
Here is an example of testing a useApiQuery
using React Testing Library
:
// Component file:
const MyComponent = (props) => {
const [user] = useApiQuery({url: `/users/${id}`})
if (user.error) {
return <p>'An error occurred'</p>
}
if (user.loading) {
return <p>'Loading ...'</p>
}
return <p>User: {user.firstName}</p>
}
// Test file:
import {render, waitFor, screen} from '@testing-library/react'
it('renders the user name', async () => {
fetchMock.mockResponseOnce(JSON.stringify({firstName: 'Thomas'}), {
headers: {'content-type': 'application/json'}
})
const {getByText} = render(<MyComponent id />)
await waitFor(() => getByText('User: Thomas'))
})
Api requests can return two types of errors:
ApiError
If a request returns a non-200 status code, an ApiError
is thrown. You can catch these errors and perform additional handling:
import {ApiError} from 'fairlight'
try {
await api.request(UserEndpoints.create({name: 'ExistingUser'}))
} catch (err) {
if (
error instanceof ApiError &&
error.status === 400 &&
error.responseBody?.code === '4120'
) {
// handle error
return
}
// bubble unexpected error up
throw error
}
ApiCacheMissError
When making an Api request with fetchPolicy: 'cache-only'
, and a cache value does not exist, an ApiCacheMissError
will be thrown. In cases where you want to handle this error by re-fetching over the network, you could just use fetchPolicy: 'cache-first'
which will make the fetch for you.
The library was internally written in Typescript and supports strong request/response typings.
Example:
interface IUser {
id: number
firstName: string
lastName: string
}
class UserEndpoints extends RestEndpoints {
list(query?: {limit?: number; page?: number}) {
return super._list<IUser[]>(query)
}
create(body?: Omit<IUser, 'id'>) {
return super._create<IUser>(body)
}
patch(id: number, body: Partial<Omit<IUser, 'id'>>) {
return super._update<IUser>(id, body)
}
}
const users = await api.request(
UserEndpoints.list({
limit: 5,
page: 2
})
)
// `users` has type `IUser[]`
const user = await api.request(
UserEndpoints.create({
firstName: 'Jane',
lastName: 'Doe'
})
)
// `user` has type `IUser`
const updatedUser = await api.request(
UserEndpoints.patch(user.id, {
firstName: 'Jane'
})
)
// `updatedUser` has type `IUser`
While it is recommended to use the useApiQuery
hook to make requests in your components, it's possible to make Api
calls in your Redux middleware (ie. sagas, thunks).
Usage with redux-thunk
:
When you instantiate your redux store, you can pass your Api
client instance as an extra argument to your thunk middleware:
import {createStore, applyMiddleware} from 'redux'
import {Provider} from 'react-redux'
import thunk from 'redux-thunk'
import {Api} from 'fairlight'
const api = new Api()
const store = createStore(
reducer,
applyMiddleware(thunk.withExtraArgument({api}))
)
const App = () => (
<Provider store={store}>
<ApiProvider api={api}>{/* Render your app here */}</ApiProvider>
</Provider>
)
Then you can make API calls in your thunks:
const loadUsers = () => async (dispatch, getState, {api}) => {
dispatch(userActions.request())
try {
const user = await api.request(UserEndpoints.list())
dispatch(userActions.success(user))
} catch (error) {
dispatch(userActions.failure(error))
}
}
Usage with redux-saga
:
When you instantiate your saga middleware, you can pass your Api
client instance to your sagas via saga context.
The following example is likely be broken down into separate files, but is included in one example for simplicity:
import {createStore, applyMiddleware} from 'redux'
import createSagaMiddleware from 'redux-saga'
import {Provider} from 'react-redux'
import {Api} from 'fairlight'
// define root saga
function* rootSaga(api) {
// set `api` in the saga context
yield setContext('api', api)
}
// create store with saga middleware
function createStore(api) {
const sagaMiddleware = createSagaMiddleware()
const store = createStore(reducer, applyMiddleware(sagaMiddleware))
// pass `api` to root saga
sagaMiddleware.run(rootSaga, api)
return store
}
sagaMiddleware.run(rootSaga)
// create api and pass to `createStore`:
const api = new Api()
const store = createStore(api)
const App = () => (
<Provider store={store}>
<ApiProvider api={api}>{/* Render your app here */}</ApiProvider>
</Provider>
)
Then you can make API calls in your sagas:
function* loadUsers() {
const api = yield getContext('api')
yield put(userActions.request())
try {
const user = yield call(api.request, UserEndpoints.list())
yield put(userActions.success(user))
} catch (error) {
yield put(userActions.failure(error))
}
}
This library uses fetch
internally to make requests, so you'll need to globally polyfill fetch
on the server. One popular option is isomorphic-unfetch
.
To make requests on the server (ex. using next.js
), you can create an api
instance and make requests with it:
import {Api} from 'fairlight'
import {UserEndpoints} from 'my-app/endpoints'
const api = new Api()
// fetch data server-side:
export async function getServerSideProps(context) {
const user = await api.request(UserEndpoints.findById(context.params.id))
return {props: {user}}
}
// define your component:
const UserProfile = (props) => {
// we can now use `props.user`
}
export default UserProfile
If you need to share an api
instance across your server, you can create an api
singleton file and import that into each file that needs it.
Note that instances of useApiQuery
will return a loading
flag for the initial render, and will not run the request since it's performed in a useEffect
hook. If you'd like to render data on the server and also trigger a new fetch for the same endpoint using useApiQuery
, you can pass initialData
to the hook using the props passed by the server fetch:
import {Api} from 'fairlight'
import {UserEndpoints} from 'my-app/endpoints'
const api = new Api()
export async function getServerSideProps(context) {
const user = await api.request(UserEndpoints.findById(context.params.id))
return {props: {user}}
}
const UserProfile = (props) => {
const [user] = useApiQuery(UserEndpoints.findById(user.id), {
initialData: props.user
})
}
export default UserProfile
At d1g1t, we make over 400 REST API calls in our enterprise investment advisor platform. Over time, as we started using React hooks and wanted to introduce caching optimizations, it was clear that we needed to overhaul our internal REST API fetching library to use patterns that scale with our app.
Since it works well for d1g1t's purposes, we decided to open-source the library to help others who are building REST API-consuming React applications.
fairlight
synthesizes patterns from other libraries, such as apollo-client
, swp
, and react-query
. It mainly differs in that it's specifically designed for making HTTP calls to your API. It allows you to request API data with URL paths, query parameters, request bodies, and HTTP headers. The caching layer will deterministically map these HTTP request parameters to response bodies, allowing the user to easily query their API and defer caching logic to fairlight
.
It's most similar to the out-of-the-box, batteries-included solution that apollo-client
provides, but for REST rather than GraphQL.
Here is the current roadmap of features we have in mind:
- Suspense API #11
- Mutations & Optimistic responses #28
- Opt-in cache data normalization #7
- Cache redirects #10
- Polling mechanism #9
- Query pagination #8
Feedback is encouraged π.
If you'd like to make a feature request, please submit an issue.
Example
import {useApiQuery} from 'fairlight'
const [user, userQueryActions] = useApiQuery(UserEndpoints.list(), {
fetchPolicy: 'cache-and-fetch',
deduplicate: true
})
const {data, loading, error} = user
// to refetch:
userQueryActions.refetch()
// to imperatively set the data:
userQueryActions.setData({id: 1, name: 'Jane'})
// setting the data using a function setter:
userQueryActions.setData((prev) => ({
...prev,
name: 'Jane'
}))
Details
params
fields:
Standard request params. See Api#request() for options.
opts
fields:
Field | Description |
---|---|
fetchPolicy?: 'no-cache' | 'cache-first' | 'fetch-first' | 'cache-only' | 'cache-and-fetch' |
The fetch policy to use for the request. This is passed directly to api.request under the hood. By default, this is set to cache-and-fetch but can be overridden via ApiProvider |
deduplicate?: boolean |
If true , will deduplicate equivalent requests, ensuring that it only runs once concurrently and returns an equal promise for each parallel call. This is true by default for GET requests, and false by default for POST , PUT , PATCH , and DELETE requests. |
dontReinitialize?: boolean |
If true, will keep data from previous requests until new data is received (ie. it won't reinitialize to null ). |
useErrorBoundary?: boolean |
If true, will throw on error that will bubble up to an error boundary. This is false by default, but can be overridden globally via ApiProvider . |
Returns [queryData, queryActions]
:
queryData
fields:
Field | Description |
---|---|
loading: boolean |
true if there is currently a request in-flight. |
data: object | Blob | string | null |
The response body. Will null if none has been received yet. |
error: Error | null |
If an error occurs, will be the instance of the error that was thrown. |
queryActions
fields:
Field | Description |
---|---|
refetch: (opts?: { deduplicate?: boolean, reinitialize?: boolean }) => void |
When called, triggers a data refetch. You can manually override deduplicate , which uses the value passed to the hook by default. You can also set reinitialize: true to clear data (false by default) |
setData: (responseBody: object | Blob | string | ((prev) => object | Blob | string |)) |
Manually sets the response data of the hook.If fetchPolicy is not set to no-cache , it will store the response body in the cache as well.If a function is passed to setData , the data can be set as a function of its previous data. |
Example
import {useApiQuery} from 'fairlight'
const [createUser, {creating: creatingUser}] = useApiMutation({
mutation: (firstName: string, lastName: string) => (api) => {
return api.request(UserEndpoints.create({firstName, lastName}))
},
onError: (error) => console.error(error)
onSuccess: (user) => console.log(`Created user ${user.firstName}`)
})
Details
params
fields:
Field | Description |
---|---|
mutation |
The mutation function. The return value is called with an object containing all api methods. |
onError |
Invoked if the mutation returns a rejected promise. This eliminates the need for a try /catch block. |
onSuccess |
Invoked if the mutation returns a resolved promise. |
Returns [mutate, mutationData]
:
mutate
function:
Invokes the mutation. This will call your provided mutation
function with the api
helpers. The mutating
flag will be set to true
while the mutation is occurring, and false
once it completes.
mutationData
fields:
Field | Description |
---|---|
mutating: boolean |
true if a mutation is currently in progress. |
Provides an Api
instance to a React app.
Example
import {ApiProvider} from 'fairlight'
const api = new Api({baseUrl: 'http://your-api.com/api'})
const App = () => (
<ApiProvider
api={api}
defaults={{
useApiQuery: {
fetchPolicy: 'cache-and-fetch'
},
useApiMutation: {
fetchPolicy: 'fetch-first'
}
}}
>
{/* render app here */}
</ApiProvider>
)
Details
props
:
Field | Description |
---|---|
api: Api |
Api instance to provide to your app. |
defaults: object |
Sets the defaults to use for hooks. See below for possible options. |
defaults
fields:
Field | Description |
---|---|
useApiQuery.fetchPolicy?: 'no-cache' | 'cache-first' | 'fetch-first' | 'cache-only' | 'cache-and-fetch' |
The fetchPolicy to use by default for the useApiQuery hook. Defaults to cache-and-fetch if not specified. |
useApiQuery.useErrorBoundary?: boolean |
The useErrorBoundary to use by default for the useApiQuery hook. Defaults to true if not specified. |
useMutationQuery.fetchPolicy: 'no-cache' | 'cache-first' | 'fetch-first' | 'cache-only' | 'cache-and-fetch' |
The fetchPolicy to use by default for api.request calls within useApiMutation mutation handlers. Defaults to no-cache if not specified. |
This class should be extended to provide your own endpoints for a given basePath
.
Example
class UserEndpoints extends HttpEndpoints {
static basePath = '/users'
static list(query) {
return super._get('', {query})
// {method: 'GET', url: '/users?serializedQuery'}
}
static create(body) {
return super._post('', {body})
// {method: 'POST', url: '/users'}
}
static findById(id) {
return super._get(`/${id}`)
// {method: 'GET', url: `/users/${id}`}
}
static update(id, body) {
return super._put(`/${id}`, {body})
// {method: 'PUT', url: `/users/${id}`, body}
}
static partialUpdate(id, body) {
return super._patch(`/${id}`, {body})
// {method: 'PATCH', url: `/users/${id}`, body}
}
static destroy(id) {
return super._delete(`/${id}`)
// {method: 'DELETE', url: `/users/${id}`}
}
// ad-hoc, custom request:
static requestPasswordReset(id, resetToken, body) {
return super._post(`/users/${id}`, {
body,
headers: {'x-reset-token': resetToken}
})
// {method: 'POST', url: `/users/${id}`, headers: {'x-reset-token': resetToken}, body}
}
}
Details
Static properties and methods:
Field | Description |
---|---|
static basePath?: string |
Base path of the resource. This will automatically prefix the url returned by each endpoint you define. |
static trailingSlash?: boolean |
If set to true , will append a trailing / at the end of each request. Eg. /users/:id will turn to /users/:id/ . This is false by default). |
static _get?: (path: string, init?: RequestInit): RequestParams |
Creates a get request for the given path. See below for RequestInit fields. |
static _post: (path: string, init?: RequestInit): RequestParams |
Creates a post request for the given path. See below for RequestInit fields. |
static _put: (path: string, init?: RequestInit): RequestParams |
Creates a put request for the given path. See below for RequestInit fields. |
static _patch: (path: string, init?: RequestInit): RequestParams |
Creates a patch request for the given path. See below for RequestInit fields. |
static _delete: (path: string, init?: RequestInit): RequestParams |
Creates a delete request for the given path. See below for RequestInit fields. |
static _serializeQuery: (query: object): string |
Serializes a query object passed to RequestInit#query into a search string. The default implementation uses URLSearchParams , but can be overrided in a subclass if needed. |
RequestInit
fields:
Field | Description |
---|---|
query?: { [key: string]: string | number | boolean } |
Query parameters. These will run through _serializeQuery and append to the returned url as the search string. |
headers?: { [key: string]: string } |
Custom HTTP headers to pass to the request. |
body?: object | string | Blob | BufferSource | FormData | URLSearchParams | ReadableStream<Uint8Array> |
HTTP request body (only for non-GET requests). If a plain JS object is passed, the Content-Type: 'application/json' header will automatically be set. |
This class should be extended to provide your own endpoints for a given basePath
. It provides RESTful methods that you can call to generate common requests (list
, create
, etc).
RestEndpoints
extends HttpEndpoints
and inherits all of its functionality.
Example
class UserEndpoints extends HttpEndpoints {
static basePath = '/users'
static list(query) {
return super._get('', {query})
// {method: 'GET', url: '/users?serializedQuery'}
}
static create(body) {
return super._post('', {body})
// {method: 'POST', url: '/users'}
}
static findById(id) {
return super._get(`/${id}`)
// {method: 'GET', url: `/users/${id}`}
}
static update(id, body) {
return super._put(`/${id}`, {body})
// {method: 'PUT', url: `/users/${id}`, body}
}
static partialUpdate(id, body) {
return super._patch(`/${id}`, {body})
// {method: 'PATCH', url: `/users/${id}`, body}
}
static destroy(id) {
return super._delete(`/${id}`)
// {method: 'DELETE', url: `/users/${id}`}
}
// ad-hoc, custom request:
static requestPasswordReset(id, resetToken, body) {
return super._post(`/users/${id}`, {
body,
headers: {'x-reset-token': resetToken}
})
// {method: 'POST', url: `/users/${id}`, headers: {'x-reset-token': resetToken}, body}
}
}
Details
In addition to the methods inherited from HttpEndpoints
, RestEndpoints
has the following static protected methods:
Field | Description |
---|---|
static _list?: (query?: { [key: string]: string | number | boolean }) |
GET /?search request. You can optionally pass query parameters to filter the results. |
static _create?: (body?: object | string | Blob | BufferSource | FormData | URLSearchParams | ReadableStream<Uint8Array>) |
POST / request. You can pass the request body as the first argument. |
static _findById?: (id: string | number) |
GET /:id request. The resource id is the first argument. |
static _update?: (id: string | number, body?: object | string | Blob | BufferSource | FormData | URLSearchParams | ReadableStream<Uint8Array>) |
PUT /:id request. The resource id is the first argument, and the request body is the second argument. |
`static _partialUpdate?: (id: string | number, body?: object | string | Blob | BufferSource | FormData | URLSearchParams | ReadableStream)` |
static _destroy?: (id: string | number, body?: object | string | Blob | BufferSource | FormData | URLSearchParams | ReadableStream<Uint8Array>) |
DELETE /:id request. The resource id is the first argument. |
Example
import {Api} from 'fairlight'
const api = new Api({
baseUrl: 'http://your-api.com/api',
serializeRequestJson: (body) => camelize(body),
serializeRequestJson: (body) => snakify(body)
})
Details
Constructor fields:
Field | Description |
---|---|
baseUrl?: string |
Base URL of the API. This will prefix all requests. |
serializeRequestJson?(body: object): object |
When provided, all JSON request bodies will be run through this transformation function before the API request. |
parseResponseJson?(body: object): object |
When provided, all JSON response bodies will be run through this transformation function before returning the response. |
The baseUrl
that was set via the Api
constructor.
Makes an API request and returns the parsed response body.
Example
// Recommended usage with endpoints:
const user = await api.request(UserEndpoints.list(), {
fetchPolicy: 'no-cache',
deduplicate: true // true by default for `GET` requests
})
// Full API:
const user = await api.request({
// Required fields:
url: '/users',
// Optional fields:
method: 'POST',
body: {name: 'Example User',}
headers: {'x-user-id': '12345'},
responseType: 'json',
successCodes: [200],
extraKey: 'extra_cache_key'
}, {
fetchPolicy: 'no-cache',
deduplicate: true
})
Details
params
fields:
Field | Description |
---|---|
url: string |
URL path for the request. This will be appended to the baseUrl which was passed to the Api constructor. |
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' |
HTTP method for the request. Defaults to GET if not provided. |
headers?: { [key: string]: string } |
An object of key-value headers for the request. |
body?: object | string | Blob | BufferSource | FormData | URLSearchParams | ReadableStream<Uint8Array> |
HTTP request body. If a plain JS object is passed, the Content-Type: 'application/json' header will automatically be set. |
responseType?: 'json' | 'text' | 'blob' |
Expected response body format. If provided, the response body will be parsed to the provided format. If not passed, the response body type will be inferred from the Content-Type response header. |
successCodes?: number[] |
An array of status codes which determine if the request was successful. If the response is not one of these status codes, an ApiError will be thrown. When this is not set, any status code in the 200 range (200-299) will be considered successful. |
extraKey?: string |
An additional key to be serialized into the caching key. |
opts
fields:
Field | Description |
---|---|
fetchPolicy?: 'no-cache' | 'cache-first' | 'fetch-first' | 'cache-only' | 'cache-and-fetch' |
The fetch policy to use for the request (ie. how to interact with the cache, if at all). |
deduplicate?: boolean |
If true , will deduplicate equivalent requests, ensuring that it only runs once concurrently and returns an equal promise for each parallel call. This is true by default for GET requests, and false by default for POST , PUT , PATCH , and DELETE requests. |
Returns Promise<object | Blob | string>
:
The response body.
Returns a boolean when, if true
, indicates that an equivalent matching request is currently in progress.
Example
const user = await api.requestInProgress(UserEndpoints.list())
Sets the cached response body for a given set of request params.
Example
const user = await api.writeCachedResponse(UserEndpoints.list(), [
{id: 1, name: 'Jane'},
{id: 2, name: 'Joe'}
])
Details
params
fields:
Standard request params. See Api#request() for options.
responseBody
:
The response body to cache.
Reads a response directly from the cache.
The main reason you would use this over Api#request
with a cached fetch policy is that this runs synchronously.
Note that if there is a cache miss, it will return undefined
.
Example
const user = api.readCachedResponse(UserEndpoints.findById(3))
Details
params
fields:
Standard request params. See Api#request() for options.
Returns object | Blob | string
:
The cached response body, or undefined
on cache miss.
Returns an observable which pushes each new response matching the given params.
Example
const subscription = api.onCacheUpdate(UserEndpoints.list().subscribe((users) => {
// handle updated `users` in cache
})
// invoke subscription's `unsubscribe` method to unsubscribe:
subscription.unsubscribe()
Details
params
fields:
Standard request params. See Api#request() for options.
listener: (responseBody: object | blob | string) => void
Invoked when the cache updates for the given request params.
Returns () => void
:
A function that, when called, unsubscribes the listener.
Sets a default header that is passed to all requests.
Example
api.setDefaultHeader('X-Auth-Token', '123456')
An observable for each api error. This is useful for error reporting or to handle an invalid auth token by logging out.
Example
import {ApiError} from 'fairlight'
const subscription = api.onError.subscribe((error) => {
// handle error
})
// invoke subscription's `unsubscribe` method to unsubscribe:
subscription.unsubscribe()
Details
listener: (error: Error) => void
Invoked when an error occurred. It's likely an instance of ApiError
for a non-200 status code.
Returns () => void
:
A function that, when called, unsubscribes the listener.