DRF API Toolkit
@rokoli/bnb provides a composable, Pinia-compatible, fetch-based API client for Django REST framework, that is optimized for DRF’s default behaviours.
Introduction
Why not start of with an example:
import merge from 'lodash.merge'
import { createURLBuilder } from '@rokoli/bnb/common'
import { APIListPaginated, APIRemove, createExtendableAPI } from '@rokoli/bnb/drf'
function createAPI(baseURL) {
const { api, base } = createExtendableAPI(baseURL, { mergeDriver: merge })
return {
...base,
...APIListPaginated(api),
...APIRemove(api),
}
}
const apiBase = createURLBuilder('https://example.org/api/v1')
const api = createAPI(apiBase.prefix('shows'))
const listResult = await api.list()
await api.remove(1)
import merge from 'lodash.merge'
import { createURLBuilder } from '@rokoli/bnb/common'
import { APIListPaginated, APIRemove, createExtendableAPI } from '@rokoli/bnb/drf'
function createAPI(baseURL) {
const { api, base } = createExtendableAPI(baseURL, { mergeDriver: merge })
return {
...base,
...APIListPaginated(api),
...APIRemove(api),
}
}
const apiBase = createURLBuilder('https://example.org/api/v1')
const api = createAPI(apiBase.prefix('shows'))
const listResult = await api.list()
await api.remove(1)
The base API client is created with createExtendableAPI, which only takes the baseURL and a merge driver (more on that later). It returns an api
and base
object.
api
is an internal object, that provides functions and context for the composable operations like APIRemove
.
base
contains all basic public (and mostly readonly) state that should be accessible from the outside. This includes itemMap
(a Map instance, that maps object ids to objects), items
(a list of all object) , reset
(a function to reset the internal state) and error
(the last error that occurred during an operation). All state values (itemMap
, items
and error
) are refs.
Individual API operations like list
, retrieve
, create
, update
, remove
(the counterparts to DRF’s ViewSets), can be added as needed. In the example above only list
and remove
operations were added.
Please note
The mergeDriver
is a required option and is needed to merge config objects. It takes a function that takes objects, merges them and returns a merged object. The most basic option would be:
(...objects) => Object.assign({}, ...objects)
(...objects) => Object.assign({}, ...objects)
More reasonable choices are deepmerge
or lodash.merge
.
Operations
@rokoli/bnb comes with the following built-in operations. Most operations modify the state of the API object as a side effect. list
, retrieve
, and create
add objects to it, update
updates them, remove
removes them.
All operations return destructible objects. If an operation returns more than one field, and you don’t want it assigned to your API object, feel free to omit them.
APIRetrieve
import { APIRetrieve } from '@rokoli/bnb/drf'
import { APIRetrieve } from '@rokoli/bnb/drf'
Returns the retrieve
and retrieveMultiple
functions that allow you to retrieve individual objects from the API.
retrieve
returns the retrieved object or null
on 404.
retrieveMultiple
does the same, though it takes an array of ids instead. It returns a list which as many items as ids have been provided, though they are instances of PromiseSettledResult. See the MDN documentation for more information.
Both functions add the retrieved objects to the state.
Example:
// force a request
const obj = await retrieve(1)
// request only if it’s not yet in the state
const cachedObj = await retrieve(1, { useCached: true })
// request multiple object
const results = await retrieveMultiple([1, 2, 3], { useCached: true })
// force a request
const obj = await retrieve(1)
// request only if it’s not yet in the state
const cachedObj = await retrieve(1, { useCached: true })
// request multiple object
const results = await retrieveMultiple([1, 2, 3], { useCached: true })
APICreate
import { APIRetrieve } from '@rokoli/bnb/drf'
import { APIRetrieve } from '@rokoli/bnb/drf'
Returns the create
function and allows you to create an object through the API. Returns the created object and adds it to the state.
Example:
const obj = await create({ name: 'my name' })
const obj = await create({ name: 'my name' })
APIUpdate
import { APIUpdate } from '@rokoli/bnb/drf'
import { APIUpdate } from '@rokoli/bnb/drf'
Returns the update
and partialUpdate
functions. They allow you to update an object in your API and share almost the exact same logic, with the key difference that update
uses the PUT
, whereas partialUpdate
uses the PATCH
HTTP method.
Both functions return the updated object and add it to the state.
Example:
const updatedObj = await update({ name: 'my new name', isActive: false })
const partiallyUpdatedObj = await partialUpdate({ name: 'my new name' })
const updatedObj = await update({ name: 'my new name', isActive: false })
const partiallyUpdatedObj = await partialUpdate({ name: 'my new name' })
APIRemove
import { APIRemove } from '@rokoli/bnb/drf'
import { APIRemove } from '@rokoli/bnb/drf'
Returns the remove
function and allows you to remove an object through the API. If present, it removes the object from the state.
Example:
await remove(1)
await remove(1)
APIListUnpaginated
import { APIListUnpaginated } from '@rokoli/bnb/drf'
import { APIListUnpaginated } from '@rokoli/bnb/drf'
Returns the list
function which returns an array of objects, all of which are added to the state.
Example:
// list everything
const items = await list(1)
// with query
const filteredItems = await list({
query: new URLSearchParams({ search: 'name' }),
})
// list everything
const items = await list(1)
// with query
const filteredItems = await list({
query: new URLSearchParams({ search: 'name' }),
})
APIListPaginated
import { APIListPaginated } from '@rokoli/bnb/drf'
import { APIListPaginated } from '@rokoli/bnb/drf'
This operation is used for paginated endpoints.
It returns the list
and listIsolated
functions and the count
, currentPage
, numberOfPages
, hasNext
and hasPrevious
refs.
The list
function returns an array of objects and applies the metadata to the refs.
The listIsolated
does not apply the metadata to the refs, but instead returns an object like this:
const page = 1
const {
items,
page,
count,
itemsPerPage,
numberOfPages,
hasNext,
hasPrevious,
itemRange
} = await listIsolated(page)
const page = 1
const {
items,
page,
count,
itemsPerPage,
numberOfPages,
hasNext,
hasPrevious,
itemRange
} = await listIsolated(page)
Examples:
const query = new URLSearchParams({ order: '-updated_at' })
const items = await list(1, { query })
const result = await listIsolated(1, { limit: 10, query })
const query = new URLSearchParams({ order: '-updated_at' })
const items = await list(1, { query })
const result = await listIsolated(1, { limit: 10, query })
Custom operations
No magic is involved in creating operations, and you can create your own.
If you look at the source code of the existing operations they all follow the same pattern, with some extra logic for their specific operation.
Let’s say we have a /shows
endpoint that represents shows on a radio. The DRF ViewSet for the /shows
endpoint defines an action called air
for a specific show. A POST
to /shows/:id/air
would schedule the show for airing.
Here’s the implementation:
function AirAction(api) {
async function air(id, options = undefined) {
const res = await fetch(
api.createRequest(
api.endpoint(id, 'air'),
options?.requestInit,
{ method: 'POST' },
),
)
await api.maybeThrowResponse(res)
const obj = api.itemMap.get(id)
if (obj)
api.itemMap.set(id, { ...obj, scheduledForAir: true })
}
return { air }
}
function AirAction(api) {
async function air(id, options = undefined) {
const res = await fetch(
api.createRequest(
api.endpoint(id, 'air'),
options?.requestInit,
{ method: 'POST' },
),
)
await api.maybeThrowResponse(res)
const obj = api.itemMap.get(id)
if (obj)
api.itemMap.set(id, { ...obj, scheduledForAir: true })
}
return { air }
}
This demonstrates the three key building blocks of the internal api object mentioned in the introduction.
api.endpoint
creates the request URL, based on the configured base URL.
api.createRequest
creates a Request and merges all provided request init objects to a single request configuration.
api.maybeThrowResponse
inspects the response, checks its status and throws an APIResponseError
instance if the request failed.
api.itemMap
allows access to the internal state.
How you handle the response object is up to you. In this case it doesn’t return anything, but only modifies the internal state.
Configuring credentials and request headers
If you want to pass credential, headers or other request configuration you have a few options to pass a RequestInit options object.
You can either define the options globally, per API endpoint or per API call. Please note that these options are all merged and that API operation configuration overrides per endpoint configuration which in turn overrides global configuration.
Global Configuration
You can configure the request options globally for all API request by calling defineDefaultAPIStoreOptions
.
import { defineDefaultAPIStoreOptions } from '@rokoli/bnb/drf'
defineDefaultAPIStoreOptions({
getRequestDefaults(): RequestInit {
return {
mode: 'cors',
headers: {
'X-Token': 'abc123'
}
}
}
})
import { defineDefaultAPIStoreOptions } from '@rokoli/bnb/drf'
defineDefaultAPIStoreOptions({
getRequestDefaults(): RequestInit {
return {
mode: 'cors',
headers: {
'X-Token': 'abc123'
}
}
}
})
Per API endpoint
You can configure the request options for each API by passing the appropriate option to createExtendableAPI
.
const baseURL = createURLBuilder('https://example.org/api')
const { api, base } = createExtendableAPI(baseURL.prefix('my-endpoint'), {
getRequestDefaults(): RequestInit {
return {
mode: 'cors',
headers: {
'X-Token': 'abc123'
}
}
}
})
const baseURL = createURLBuilder('https://example.org/api')
const { api, base } = createExtendableAPI(baseURL.prefix('my-endpoint'), {
getRequestDefaults(): RequestInit {
return {
mode: 'cors',
headers: {
'X-Token': 'abc123'
}
}
}
})
Per API operation
You can pass an options object with the request options to each of the builtin API operations. The options argument allows you to pass a requestInit
property.
const requestInit = {
mode: 'cors',
headers: {
'X-Token': 'abc123',
},
}
api.retrieve(1, { requestInit })
const requestInit = {
mode: 'cors',
headers: {
'X-Token': 'abc123',
},
}
api.retrieve(1, { requestInit })
Integration with Pinia
The DRF API toolkit perfectly integrates with Pinia’s Composition stores.
Take the following store as example:
import { defineStore } from 'pinia'
import { createURLBuilder } from '@rokoli/bnb/common'
import { APIListUnpaginated, APIRetrieve, createExtendableAPI } from '@rokoli/bnb/drf'
const apiBaseURL = createURLBuilder('https://example.org/api')
export const useHostStore = defineStore('hosts', () => {
const endpoint = apiBaseURL.prefix('hosts')
const { api, base } = createExtendableAPI < Host > endpoint
return {
...base,
...APIListUnpaginated(api),
...APIRetrieve(api),
}
})
import { defineStore } from 'pinia'
import { createURLBuilder } from '@rokoli/bnb/common'
import { APIListUnpaginated, APIRetrieve, createExtendableAPI } from '@rokoli/bnb/drf'
const apiBaseURL = createURLBuilder('https://example.org/api')
export const useHostStore = defineStore('hosts', () => {
const endpoint = apiBaseURL.prefix('hosts')
const { api, base } = createExtendableAPI < Host > endpoint
return {
...base,
...APIListUnpaginated(api),
...APIRetrieve(api),
}
})