Skip to content

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:

js
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:

js
(...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

js
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:

js
// 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

js
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:

js
const obj = await create({ name: 'my name' })
const obj = await create({ name: 'my name' })

APIUpdate

js
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:

js
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

js
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:

js
await remove(1)
await remove(1)

APIListUnpaginated

js
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:

js
// 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

js
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:

js
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:

js
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:

js
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.

js
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.

js
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.

js
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:

js
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),
  }
})