Newer
Older
import { defineStore } from 'pinia'
import { computed, readonly, ref, Ref, shallowReadonly, watchEffect } from 'vue'
import { merge } from 'lodash'
type ID = number | string
type ErrorDetail = {
message: string
code: string
}
type ErrorMap = Record<string, ErrorDetail[]> & {
_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),
items: shallowReadonly(items),
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)
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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: Ref<ID | null>,
) {
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 = { _global: [] }
if (_error instanceof APIResponseError) {
const { status } = _error.response
if (status >= 500) {
result._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]) as ErrorDetail[]
result[field] = errors.map(({ message, code }) => ({ message, code: `server.${code}` }))
}
}
}
}
return result
})
}