Newer
Older
import { computedAsync } from '@vueuse/core'
import { merge } from 'lodash'
import { defineStore } from 'pinia'
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[]
constructor(message: string) {
super(message)
}
}
export class APIResponseError extends APIError {
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<T['id'], T>>
endpoint: URLBuilder
maybeRaiseResponse: (res: Response) => Promise<void>
createRequest: (
url: string,
customRequestData: RequestInit | undefined,
defaultRequestData?: RequestInit | undefined,
) => Request
}
type APIStoreOptions<T extends APIObject> = {

Konrad Mohrfeldt
committed
getRequestDefaults?: () => RequestInit | undefined
type BaseRequestOptions = {
requestInit?: RequestInit | undefined
}
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<T['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,
)
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()
}
base: {
error: readonly(error),
itemMap: shallowReadonly(itemMap),
createRequest,
endpoint,
maybeRaiseResponse,
itemMap,
}
}
type ListOperationOptions = BaseRequestOptions & {
limit?: number | undefined
noStoreCommit?: boolean | undefined
query?: URLSearchParams | undefined
}
export function APIListUnpaginated<T extends APIObject>(api: ExtendableAPI<T>) {
async function list(options: Omit<ListOperationOptions, 'limit'> = {}): Promise<T[]> {
const query = options.query ?? new URLSearchParams()
const init = options.requestInit
const res = await fetch(api.createRequest(api.endpoint(query), init))
await api.maybeRaiseResponse(res)
const items: T[] = await res.json()
if (options.noStoreCommit !== true) {
for (const item of items) {
api.itemMap.value.set(item.id, item)
}
return items
}
return { list }
}
export type PaginatedListResult<T> = {
items: T[]
page: number
count: number
numberOfPages: number
itemsPerPage: number
itemRange: [number, number]
hasNext: boolean
hasPrevious: boolean
}
export function APIListPaginated<T extends APIObject>(
api: ExtendableAPI<T>,
mode: 'offset' | 'page' = 'offset',
limit = 20,
) {
const count = ref(0)
const currentPage = ref(1)
const numberOfPages = ref(0)
const hasNext = ref(false)
const hasPrevious = ref(false)
async function listIsolated(
page: number,
options: ListOperationOptions = {},
): Promise<PaginatedListResult<T>> {
const _page = page ?? currentPage.value
const _limit = options.limit ?? limit
const query = options.query ?? new URLSearchParams()
const init = options.requestInit
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), init))
await api.maybeRaiseResponse(res)
const data: PaginatedResults<T> = await res.json()
const { results, count, next, previous } = data
if (options?.noStoreCommit !== true) {
for (const item of results) {
api.itemMap.value.set(item.id, item)
}
}
return {
items: results,
page: _page,
count,
itemsPerPage: _limit,
numberOfPages: Math.ceil(count / _limit),
hasNext: next !== null,
hasPrevious: previous !== null,
itemRange: [(_page - 1) * _limit + 1, Math.min(count, _page * _limit)],
}
async function list(page?: number, options: ListOperationOptions = {}): Promise<T[]> {
const _page = page ?? currentPage.value
const data = await listIsolated(_page, options)
currentPage.value = _page
count.value = data.count
numberOfPages.value = data.numberOfPages
hasNext.value = data.hasNext
hasPrevious.value = data.hasPrevious
return data.items
}
function reset() {
api.itemMap.value.clear()
currentPage.value = 1
count.value = 0
hasNext.value = false
hasPrevious.value = false
}
return {
list,
listIsolated,
reset,
hasNext,
hasPrevious,
count: readonly(count),
currentPage: readonly(currentPage),
}
}
export function APIRetrieve<T extends APIObject>(api: ExtendableAPI<T>) {
type RetrieveOptions = { requestInit?: RequestInit | undefined; useCached?: boolean | undefined }
async function retrieve(id: T['id'], options: RetrieveOptions = {}): 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()), options.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)
async function retrieveMultiple(
ids: T['id'][],
options: RetrieveOptions = {},
): Promise<PromiseSettledResult<T | null>[]> {
return await Promise.allSettled(ids.map((id) => retrieve(id, options)))
}
return { retrieve, retrieveMultiple }
export function APIUpdate<
T extends APIObject,
TUpdateData = Partial<T>,
TPartialUpdateData = Partial<TUpdateData>,
>(api: ExtendableAPI<T>) {
function updateWith<TData>(method: 'PUT' | 'PATCH') {
return async function (id: T['id'], data: FormData | TData, options: BaseRequestOptions = {}) {
const res = await fetch(
api.createRequest(api.endpoint(id.toString()), options.requestInit, {
method,
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
}
const update = updateWith<TUpdateData>('PUT')
const partialUpdate = updateWith<TPartialUpdateData>('PATCH')
return { update, partialUpdate }
export function APICreate<T extends APIObject, TData = Partial<Omit<T, 'id'>>>(
api: ExtendableAPI<T>,
) {
async function create(data: TData | FormData, options: BaseRequestOptions = {}): Promise<T> {
const res = await fetch(
api.createRequest(api.endpoint(), options.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: T['id'], options: BaseRequestOptions = {}): Promise<void> {
const res = await fetch(
api.createRequest(api.endpoint(id.toString()), options.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)
...base,
...APIListUnpaginated(api),
...APIRetrieve(api),
...APICreate(api),
...APIUpdate(api),
...APIRemove(api),
export function useAPIObject<T extends APIObject>(
store: {
itemMap: Map<T['id'], T>
retrieve: ReturnType<typeof APIRetrieve<T>>['retrieve']
},
id: MaybeRefOrGetter<T['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, { 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
})
}
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
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[]>[]>
}
export function usePaginatedList<T>(
list: (page: number, options: ListOperationOptions) => Promise<PaginatedListResult<T>>,
page: MaybeRefOrGetter<number>,
options: {
limit?: MaybeRefOrGetter<number | undefined>
query?: MaybeRefOrGetter<URLSearchParams | undefined>
} = {},
) {
const _page = toRef(page)
const _limit = toRef(options.limit)
const _query = toRef(options.query ?? new URLSearchParams())
const isLoading = ref(false)
const canResetPage = isRef(page) && !isReadonly(page)
if (options.noPageAutoReset !== true && canResetPage) {
watch([_query, _limit], () => {
;(_page as Ref<number>).value = 1
})
}
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
const result = computedAsync(
(onCancel) => {
const controller = new AbortController()
onCancel(() => controller.abort())
return list(_page.value, {
limit: _limit.value,
requestInit: { signal: controller.signal },
query: _query.value,
})
},
{
items: [],
page: 1,
count: 0,
itemsPerPage: _limit.value ?? 20,
numberOfPages: 1,
hasNext: false,
hasPrevious: false,
itemRange: [0, 0],
},
{
evaluating: isLoading,
shallow: true,
},
)
return { result, isLoading }
}