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 items } 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) return obj } } return { retrieve } } 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) return { ...base, ...APIListUnpaginated(api), ...APIRetrieve(api), ...APICreate(api), ...APIUpdate(api), ...APIRemove(api), } }) }