Newer
Older
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[]
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<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,
)
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,
}
}
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)
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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 { 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)
...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
})
}
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
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[]>[]>
}