fairlight
v1.0.1
Published
Illuminate your REST API with React hooks and a centralized cache.
Downloads
105
Maintainers
Readme
fairlight
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
- Motivation
- Comparison to similar libraries
- Feature Roadmap
- API Documentation
Installation & Setup
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.
Basic Usage
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)
Guide
Response Cache
Interacting with the cache when requesting data
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.
Using the cache directly
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.
How request cache keys are determined
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.
Re-fetching a query
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.
Dependent queries
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()
Manually updating query data
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.
Custom response body parsing
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'
Success response codes
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`
Custom query string serialization
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`
}
}
Setting default headers (ie. an auth token) for all requests
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.
Testing
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'))
})
Error handling
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.
Typescript
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`
Usage with Redux
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))
}
}
Server-side rendering (SSR)
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
Motivation
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.
Comparison to similar libraries
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.
Feature Roadmap
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.
API Documentation
useApiQuery(params: object, opts?: object)
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'
}))
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. |
useApiMutation(params: object)
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}`)
})
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. |
ApiProvider
Provides an Api
instance to a React app.
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>
)
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. |
HttpEndpoints
This class should be extended to provide your own endpoints for a given basePath
.
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}
}
}
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. |
RestEndpoints
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.
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}
}
}
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<Uint8Array>)
| PATCH /:id
request. The resource id
is the first argument, and the request body
is the second argument. |
| static _destroy?: (id: string \| number, body?: object \| string \| Blob \| BufferSource \| FormData \| URLSearchParams \| ReadableStream<Uint8Array>)
| DELETE /:id
request. The resource id
is the first argument. |
Api
Constructor
import {Api} from 'fairlight'
const api = new Api({
baseUrl: 'http://your-api.com/api',
serializeRequestJson: (body) => camelize(body),
serializeRequestJson: (body) => snakify(body)
})
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. |
baseUrl
The baseUrl
that was set via the Api
constructor.
request(params: object, opts?: object)
Makes an API request and returns the parsed response body.
// 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
})
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.
requestInProgress(params: object)
Returns a boolean when, if true
, indicates that an equivalent matching request is currently in progress.
const user = await api.requestInProgress(UserEndpoints.list())
params
fields:
Standard request params. See Api#request() for options.
Returns:
boolean
writeCachedResponse(params: object, responseBody?: Blob | object | string)
Sets the cached response body for a given set of request params.
const user = await api.writeCachedResponse(UserEndpoints.list(), [
{id: 1, name: 'Jane'},
{id: 2, name: 'Joe'}
])
params
fields:
Standard request params. See Api#request() for options.
responseBody
:
The response body to cache.
readCachedResponse(params: object)
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
.
const user = api.readCachedResponse(UserEndpoints.findById(3))
params
fields:
Standard request params. See Api#request() for options.
Returns object | Blob | string
:
The cached response body, or undefined
on cache miss.
onCacheUpdate(params: object)
Returns an observable which pushes each new response matching the given params.
const subscription = api.onCacheUpdate(UserEndpoints.list().subscribe((users) => {
// handle updated `users` in cache
})
// invoke subscription's `unsubscribe` method to unsubscribe:
subscription.unsubscribe()
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.
setDefaultHeader(key: string, value: string)
Sets a default header that is passed to all requests.
api.setDefaultHeader('X-Auth-Token', '123456')
onError
An observable for each api error. This is useful for error reporting or to handle an invalid auth token by logging out.
import {ApiError} from 'fairlight'
const subscription = api.onError.subscribe((error) => {
// handle error
})
// invoke subscription's `unsubscribe` method to unsubscribe:
subscription.unsubscribe()
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.