import { defineStore } from 'pinia' import { merge } from 'lodash' import { computed, ComputedRef, MaybeRefOrGetter, readonly, ref, Ref, shallowReadonly, toRef, watchEffect, } from 'vue' export const SERVER_ERRORS_GLOBAL: unique symbol = Symbol('ERRORS_GLOBAL') export type ID = number | string type ErrorDetail = { message: string code: string } type ErrorMap = Record<string, ErrorDetail[]> & { [SERVER_ERRORS_GLOBAL]: ErrorDetail[] } export class APIError extends Error { constructor(message: string) { super(message) } } export class APIResponseError extends APIError { response: Response data: unknown constructor(message: string, response: Response, data: unknown) { super(message) this.response = response this.data = data } } type APIObject = { id: ID } type ExtendableAPI<T extends APIObject> = { itemMap: Ref<Map<ID, T>> endpoint: URLBuilder maybeRaiseResponse: (res: Response) => Promise<void> createRequest: ( url: string, customRequestData: RequestInit | undefined, defaultRequestData?: RequestInit | undefined, ) => Request } type APIStoreOptions<T extends APIObject> = { getRequestDefaults?: () => RequestInit } type PaginatedResults<T> = { count: number next: string | null previous: string | null results: T[] } 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>() async function maybeRaiseResponse(response: Response) { if (!response.ok) { let data = null try { data = await response.json() } catch (e) { // pass } const _error = new APIResponseError( `Failure response when executing when interacting with ${response.url}`, response, data, ) error.value = _error throw _error } } function createRequest( url: string, customRequestData: RequestInit | undefined, defaultRequestData?: RequestInit | undefined, ) { return new Request( url, merge( options?.getRequestDefaults?.() ?? {}, defaultRequestData ?? {}, customRequestData ?? {}, ), ) } function reset() { itemMap.value.clear() } return { base: { error: readonly(error), items, itemMap: shallowReadonly(itemMap), reset, }, 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)) await 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 APIListPaginated<T extends APIObject>( api: ExtendableAPI<T>, mode: 'offset' | 'page' = 'offset', limit = 20, ) { const count = ref<number>(0) const currentPage = ref<number>(1) const nextPage = ref<string | null>(null) const hasNext = computed(() => nextPage.value !== null) const previousPage = ref<string | null>(null) const hasPrevious = computed(() => previousPage.value !== null) async function list(page?: number, requestInit?: RequestInit): Promise<T[]> { page = page ?? currentPage.value const query = new URLSearchParams() if (mode === 'offset') { query.set('limit', limit.toString()) query.set('offset', ((page - 1) * limit).toString()) } else { query.set('pageSize', limit.toString()) query.set('page', page.toString()) } const res = await fetch(api.createRequest(api.endpoint(query), requestInit)) await api.maybeRaiseResponse(res) const data: PaginatedResults<T> = await res.json() currentPage.value = page count.value = data.count nextPage.value = data.next previousPage.value = data.previous for (const item of data.results) { api.itemMap.value.set(item.id, item) } return data.results } function reset() { api.itemMap.value.clear() currentPage.value = 1 count.value = 0 nextPage.value = null previousPage.value = null } return { list, reset, hasNext, hasPrevious, count: readonly(count), currentPage: readonly(currentPage), } } export function APIRetrieve<T extends APIObject>(api: ExtendableAPI<T>) { async function retrieve( id: ID, requestInit?: RequestInit, options?: { useCached?: boolean }, ): Promise<T | null> { if (options?.useCached && api.itemMap.value.has(id)) { return api.itemMap.value.get(id) as T } const res = await fetch(api.createRequest(api.endpoint(id.toString()), requestInit)) if (res.status === 404) { api.itemMap.value.delete(id) return null } else { await 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, TData = Partial<T>>(api: ExtendableAPI<T>) { async function update(id: ID, data: TData | 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), }), ) await 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, TData = Partial<Omit<T, 'id'>>>( api: ExtendableAPI<T>, ) { async function create(data: TData | 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), }), ) await 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' }), ) await 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), } }) } export function useAPIObject<T extends APIObject>( store: { itemMap: Map<ID, T> retrieve: ReturnType<typeof APIRetrieve<T>>['retrieve'] }, id: MaybeRefOrGetter<ID | null>, ) { const _id = toRef(id) const obj = computed<T | null>(() => { return _id.value !== null ? store.itemMap.get(_id.value) ?? null : null }) const isLoading = ref(false) watchEffect(async () => { if (_id.value) { isLoading.value = true try { // Force an API request in case the object is not yet in the store. await store.retrieve(_id.value, undefined, { useCached: true }) } finally { isLoading.value = false } } }) return { obj, isLoading } } export function useServerErrors(error: Ref<Error | undefined>) { return computed<ErrorMap>(() => { const _error: Error | undefined = error.value const result: ErrorMap = { [SERVER_ERRORS_GLOBAL]: [] } if (_error instanceof APIResponseError) { const { status } = _error.response if (status >= 500) { result[SERVER_ERRORS_GLOBAL].push({ message: '', code: 'server.unknown' }) } if (status >= 400) { if (_error.data !== null && typeof _error.data === 'object') { for (const [field, _errors] of Object.entries(_error.data)) { const errors = Array.isArray(_errors) ? _errors : [_errors] result[field] = errors.map((err: string | ErrorDetail) => { if (typeof err === 'string') { return { message: err, code: '' } } else { return { message: err.message, code: `server.${err.code}` } } }) } } } } return result }) } type FieldName = string | typeof SERVER_ERRORS_GLOBAL type MappedFields<T> = { [K in keyof T]: T[K] } export function useServerFieldErrors( error: Ref<Error | undefined>, ...fields: (FieldName | FieldName[])[] ) { const serverErrors = useServerErrors(error) const consumedFields = new Set(fields.flat()) const result: ComputedRef<ErrorDetail[]>[] = [] for (const field of fields) { const fieldNames: FieldName[] = Array.isArray(field) ? field : [field] const fieldErrors = computed(() => { const errors: ErrorDetail[] = [] for (const fieldName of fieldNames) { errors.push(...(serverErrors.value[fieldName] ?? [])) } return errors }) result.push(fieldErrors) } // contains all errors for which no individual error container was created const remainingErrors = computed(() => { const errors: ErrorDetail[] = [] for (const fieldName of Object.keys(serverErrors.value)) { if (!consumedFields.has(fieldName)) { errors.push(...serverErrors.value[fieldName]) } } return errors }) result.push(remainingErrors) return result as MappedFields<ComputedRef<ErrorDetail[]>[]> }