Skip to content
Snippets Groups Projects
api.ts 5.86 KiB
Newer Older
import { defineStore } from 'pinia'
import { computed, ref, Ref } from 'vue'

type ID = number | string

class APIError extends Error {
  constructor(message: string) {
    super(message)
  }
}

class APIResponseError extends APIError {
  response: Response

  constructor(message: string, response: Response) {
    super(message)
    this.response = response
  }
}

type APIObject = { id: ID }

type ExtendableAPI<T extends APIObject> = {
  itemMap: Ref<Map<ID, T>>
  endpoint: URLBuilder
  maybeRaiseResponse: (res: Response) => void
  createRequest: (
    url: string,
    customRequestData: RequestInit | undefined,
    defaultRequestData?: RequestInit | undefined,
  ) => Request
}

type APIStoreOptions<T extends APIObject> = {
  getRequestDefaults?: () => RequestInit
type URLToken = string | number
type URLBuilder = (...subPaths: URLToken[] | [...URLToken[], URLToken | URLSearchParams]) => string
type PrefixableURLBuilder = URLBuilder & {
  prefix: (...prefixes: URLToken[]) => PrefixableURLBuilder
}

function createURLBuilder(basepath: string, useTrailingSlash = true): PrefixableURLBuilder {
  // Strip all trailing slashes from the basepath.
  // We handle slashes when building URLs.
  basepath = basepath.replace(/\/*$/, '')

  const buildURL: PrefixableURLBuilder = (...subPaths) => {
    let params
    if (subPaths.at(-1) instanceof URLSearchParams) {
      params = subPaths.pop()
    }
    if (subPaths.some((path) => String(path).includes('/'))) {
      throw new Error('Subpaths must not contain slashes')
    }
    const subPath = subPaths.length > 0 ? '/' + subPaths.join('/') : ''
    const url = basepath + subPath + (useTrailingSlash ? '/' : '')
    return params ? url + `?${params}` : url
  }

  buildURL.prefix = function (...prefixes: URLToken[]) {
    return createURLBuilder(buildURL(...prefixes), useTrailingSlash)
  }

  return buildURL
}

export const createTankURL = createURLBuilder(import.meta.env.VUE_APP_API_TANK, false)
export const createSteeringURL = createURLBuilder(import.meta.env.VUE_APP_API_STEERING)
export function createExtendableAPI<T extends APIObject>(
  endpoint: URLBuilder,
  options?: APIStoreOptions<T>,
  const itemMap = ref<Map<ID, T>>(new Map())
  const items = computed<T[]>(() => Array.from(itemMap.value.values()))
  const error = ref<Error>()

  function maybeRaiseResponse(response: Response) {
    if (!response.ok) {
      const _error = new APIResponseError(
        `Failure response when executing when interacting with ${response.url}`,
        response,
      )
      error.value = _error
      throw _error
  function createRequest(
    url: string,
    customRequestData: RequestInit | undefined,
    defaultRequestData?: RequestInit | undefined,
  ) {
    return new Request(url, {
      ...(defaultRequestData ?? {}),
      ...(options?.getRequestDefaults?.() ?? {}),
      ...(customRequestData ?? {}),
    })
  }
  return {
    base: { items, error },
    createRequest,
    endpoint,
    maybeRaiseResponse,
    itemMap,
  }
}
export function APIListUnpaginated<T extends APIObject>(api: ExtendableAPI<T>) {
  async function list(requestInit?: RequestInit): Promise<T[]> {
    const res = await fetch(api.createRequest(api.endpoint(), requestInit))
    api.maybeRaiseResponse(res)
    const items: T[] = await res.json()
    for (const item of items) {
      api.itemMap.value.set(item.id, item)
  return { list }
}
export function APIRetrieve<T extends APIObject>(api: ExtendableAPI<T>) {
  async function retrieve(id: ID, requestInit?: RequestInit): Promise<T | null> {
    const res = await fetch(api.createRequest(api.endpoint(id.toString()), requestInit))
    if (res.status === 404) {
      api.itemMap.value.delete(id)
      return null
    } else {
      api.maybeRaiseResponse(res)
      const obj: T = await res.json()
      api.itemMap.value.set(obj.id, obj)
export function APIUpdate<T extends APIObject>(api: ExtendableAPI<T>) {
  async function update(
    id: ID,
    data: Partial<T> | FormData,
    requestInit?: RequestInit,
  ): Promise<T> {
    const res = await fetch(
      api.createRequest(api.endpoint(id.toString()), requestInit, {
        method: 'PUT',
        headers: data instanceof FormData ? undefined : { 'Content-Type': 'application/json' },
        body: data instanceof FormData ? data : JSON.stringify(data),
      }),
    )
    api.maybeRaiseResponse(res)
    const obj: T = await res.json()
    api.itemMap.value.set(obj.id, obj)
    return obj
  }
  return { update }
}

export function APICreate<T extends APIObject>(api: ExtendableAPI<T>) {
  async function create(data: Partial<T> | FormData, requestInit?: RequestInit): Promise<T> {
    const res = await fetch(
      api.createRequest(api.endpoint(), requestInit, {
        method: 'POST',
        headers: data instanceof FormData ? undefined : { 'Content-Type': 'application/json' },
        body: data instanceof FormData ? data : JSON.stringify(data),
      }),
    )
    api.maybeRaiseResponse(res)
    const obj = await res.json()
    api.itemMap.value.set(obj.id, obj)
    return obj
  }

  return { create }
}

export function APIRemove<T extends APIObject>(api: ExtendableAPI<T>) {
  async function remove(id: ID, requestInit?: RequestInit): Promise<void> {
    const res = await fetch(
      api.createRequest(api.endpoint(id.toString()), requestInit, { method: 'DELETE' }),
    )
    api.maybeRaiseResponse(res)
    api.itemMap.value.delete(id)
  }

  return { remove }
}

export function createUnpaginatedAPIStore<T extends APIObject>(
  storeId: string,
  endpoint: URLBuilder,
  options?: APIStoreOptions<T>,
) {
  return defineStore(storeId, () => {
    const { base, ...api } = createExtendableAPI<T>(endpoint, options)
      ...base,
      ...APIListUnpaginated(api),
      ...APIRetrieve(api),
      ...APICreate(api),
      ...APIUpdate(api),
      ...APIRemove(api),