Newer
Older
import { defineStore } from 'pinia'
import { computed, readonly, ref, Ref } from 'vue'
type ID = number | string
constructor(message: string) {
super(message)
}
}
export 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 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>()
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 ?? {}),
})
}
function reset() {
itemMap.value.clear()
}
base: {
error: readonly(error),
items: readonly(items),
itemMap: readonly(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))
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 }
}
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
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('page_size', limit.toString())
query.set('page', page.toString())
}
const res = await fetch(api.createRequest(api.endpoint(query), requestInit))
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 {
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),
}),
)
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> {
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
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)
...base,
...APIListUnpaginated(api),
...APIRetrieve(api),
...APICreate(api),
...APIUpdate(api),
...APIRemove(api),