Skip to content
Snippets Groups Projects
api.ts 14.4 KiB
Newer Older
  • Learn to ignore specific revisions
  • import { computedAsync } from '@vueuse/core'
    
    import { merge } from 'lodash'
    
    import { defineStore } from 'pinia'
    
      MaybeRefOrGetter,
      readonly,
      ref,
      Ref,
      shallowReadonly,
      toRef,
    
    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
    
      constructor(message: string, response: Response, data: unknown) {
    
        super(message)
        this.response = response
    
    type APIObject = { id: ID }
    
    type ExtendableAPI<T extends APIObject> = {
    
      itemMap: Ref<Map<T['id'], T>>
    
      maybeRaiseResponse: (res: Response) => Promise<void>
    
      createRequest: (
        url: string,
        customRequestData: RequestInit | undefined,
        defaultRequestData?: RequestInit | undefined,
      ) => Request
    }
    
    type APIStoreOptions<T extends APIObject> = {
    
      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) {
    
          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,
    
      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 & {
    
      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)
          }
    
    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
    
          query.set('limit', _limit.toString())
          query.set('offset', ((_page - 1) * _limit).toString())
    
          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
    
        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> {
    
          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> {
    
          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: {
    
        retrieve: ReturnType<typeof APIRetrieve<T>>['retrieve']
      },
    
      id: MaybeRefOrGetter<T['id'] | null>,
    
      const obj = computed<T | null>(() => {
    
        return _id.value !== null ? store.itemMap.get(_id.value) ?? null : null
    
      })
      const isLoading = ref(false)
      watchEffect(async () => {
    
          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}` }
                  }
                })
    
    
    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>
    
        noPageAutoReset?: boolean
    
      } = {},
    ) {
      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
        })
      }
    
    
      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 }
    }