UNPKG

@volverjs/data

Version:

Repository pattern implementation with a tiny HttpClient based on Fetch API.

631 lines (605 loc) 19.7 kB
import type { ParamMap } from 'src/types' import type { App, Ref } from 'vue' import type { HttpClientInputTemplate, HttpClientInstanceOptions, HttpClientMethod, HttpClientRequestOptions, HttpClientResponse, HttpClientUrlTemplate, HTTPError } from '../HttpClient' import type { RepositoryHttpOptions, RepositoryHttpReadOptions } from '../RepositoryHttp' import { computed, readonly, ref, unref } from 'vue' import { HttpClient, } from '../HttpClient' import { RepositoryHttp, } from '../RepositoryHttp' export enum HttpRequestStatus { loading = 'loading', error = 'error', success = 'success', idle = 'idle', } function defineHttpRequestStatus() { const status = ref<HttpRequestStatus>(HttpRequestStatus.idle) const isLoading = computed(() => status.value === HttpRequestStatus.loading) const isError = computed(() => status.value === HttpRequestStatus.error) const isSuccess = computed(() => status.value === HttpRequestStatus.success) return { status, isLoading, isError, isSuccess, } } type HttpClientRequestOptionsWithImmediate = HttpClientRequestOptions & { immediate?: boolean } type HttpClientComposableRequestOptions = | HttpClientRequestOptionsWithImmediate | Ref<HttpClientRequestOptionsWithImmediate> type HttpClientComposableInputTemplate = | HttpClientInputTemplate | Ref<HttpClientInputTemplate> type RepositoryHttpReadOptionsWithImmediate = RepositoryHttpReadOptions & { immediate?: boolean } type RepositoryHttpComposableReadOptions = | RepositoryHttpReadOptionsWithImmediate | Ref<RepositoryHttpReadOptionsWithImmediate> const httpClientInstances: Map<string, HttpClient> = new Map() const GLOBAL_SCOPE = 'global' class HttpClientPlugin extends HttpClient { private _scope: string constructor(options: HttpClientInstanceOptions & { scope: string }) { super(options) this._scope = options.scope } get scope() { return this._scope } public install(app: App, { globalName = 'vvHttp' } = {}) { if (app.config.globalProperties[`$${globalName}`]) { throw new Error(`globalName already exist: ${globalName}`) } app.config.globalProperties[`$${globalName}`] = this } } /** * Create a new instance of a HttpClientPlugin. * @param options - The options for the http client {@link HttpClientInstanceOptions HttpClientInstanceOptions & { scope: string }} * @param options.scope - The scope (name) of the HttpClient instance * @returns The instance of the HttpClientPlugin, see {@link HttpClientPlugin} * @example * ```typescript * import { createApp } from 'vue' * import { createHttpClient } from '@volverjs/data/vue' * import App from './App.vue' * * const app = createApp(App) * const client = createHttpClient({ * prefixUrl: 'https://my.api.com' * }) * app.use(client) * ``` * * Multiple instances with `scope` * ```typescript * import { createApp } from 'vue' * import { createHttpClient } from '@volverjs/data/vue' * import App from './App.vue' * * const app = createApp(App) * const client = createHttpClient({ * prefixUrl: 'https://my.api-v2.com', * scope: 'apiV2' * }) * * app.use(client, { globalName: 'httpClientV2' }) * * const { requestPost } = useHttpClient('apiV2') * const { isLoading, isError, error, execute } = requestPost<User>( * 'user', * computed(() => ({ immediate: false, json: data.value })), * ) * ``` */ export function createHttpClient({ scope = GLOBAL_SCOPE, ...options }: HttpClientInstanceOptions & { scope?: string } = {}) { if (httpClientInstances.has(scope)) { throw new Error(`httpClient with scope ${scope} already exist`) } const client = new HttpClientPlugin({ ...options, scope }) httpClientInstances.set(scope, client) return client } /** * Use the composition API to remove an existing HttpClient instance (remove of http global instance is not permitted) * @param scope - The scope (name) of the HttpClient instance to remove * @returns - Boolean success or not */ export function removeHttpClient(scope: string): boolean { if (scope === GLOBAL_SCOPE) { throw new Error('You cannot remove httpClient global instance') } if (!httpClientInstances.has(scope)) { throw new Error(`httpClient with scope ${scope} not exist`) } return httpClientInstances.delete(scope) } /** * Use the composition API to get the HttpClient instance and reactive methods. * @remarks * If `HttpClientPlugin` is not created with `createHttpClient` and installed first, `useHttpClient` throw the error "HttpClient instance not found". * @param scope - The scope (name) of the HttpClient instance * @example * ```html * <template> * <div> * <button @click="execute()">Execute</button> * <div v-if="isLoading">Loading...</div> * <div v-if="isError">{{ error }}</div> * <div v-if="data">{{ data.name }}</div> * </div> * </template> * * <script lang="ts" setup> * import { ref, computed } from 'vue' * import { useHttpClient } from '@volverjs/data/vue' * * const { client } = useHttpClient() * const isLoading = ref(false) * const isError = computed(() => error.value !== undefined) * const error = ref() * const data = ref<User>() * * type User = { * id: number, * name: string * } * * const execute = async () => { * isLoading.value = true * try { * const response = await client.get('user/1') * data.value = await response.json<User>() * } catch (e) { * error.value = e.message * } finally { * isLoading.value = false * } * } * </script> * ``` * @example * ```html * <template> * <div> * <button @click="execute()">Execute</button> * <div v-if="isLoading">Loading...</div> * <div v-if="isError">{{ error }}</div> * <div v-if="data">{{ data.name }}</div> * </div> * </template> * * <script setup> * import { useHttpClient } from '@volverjs/data/vue' * * type User = { * id: number, * name: string * } * * const { requestGet } = useHttpClient() * const { * isLoading, * isError, * error, * data, * execute, * } = requestGet<User>('user/1', { immediate: false }) * </script> * ``` * @example * ```html * <template> * <form @submit.prevent="execute()"> * <div v-if="isLoading">Loading...</div> * <div v-if="isError">{{ error }}</div> * <input type="text" v-model="data.name" /> * <button type="submit">Submit</button> * </form> * </template> * * <script lang="ts" setup> * import { useHttpClient } from '@volverjs/data/vue' * * type User = { * id: number * name: string * } * * const data = ref<Partial<User>>({ name: '' }) * * const { requestPost } = useHttpClient() * const { isLoading, isSuccess, isError, error, execute } = requestPost<User>( * 'user', * computed(() => ({ immediate: false, json: data.value })), * ) * </script> * ``` */ export function useHttpClient(scope = GLOBAL_SCOPE) { const client = httpClientInstances.get(scope) if (!client) { throw new Error('HttpClient instance not found') } const request = <T = unknown>( method: HttpClientMethod, url: HttpClientComposableInputTemplate, options: HttpClientComposableRequestOptions = {}, ) => { const { status, isLoading, isError, isSuccess } = defineHttpRequestStatus() const immediate = unref(options).immediate ?? true const error = ref<HTTPError>() const data = ref<T>() const response = ref<HttpClientResponse>() const execute = ( newUrl: HttpClientInputTemplate = unref(url), newOptions: HttpClientRequestOptions = unref(options), ) => { status.value = HttpRequestStatus.loading error.value = undefined data.value = undefined const { responsePromise, abort, signal } = client.request( method, newUrl, newOptions, ) responsePromise .then((result) => { response.value = result return result.json<T>() }) .then((parsed) => { data.value = parsed status.value = HttpRequestStatus.success }) .catch((e) => { if (!signal.aborted) { error.value = e as HTTPError status.value = HttpRequestStatus.error return } status.value = HttpRequestStatus.idle }) return { responsePromise, abort, signal } } return { execute, isLoading, isSuccess, isError, error: readonly(error), data, response, ...(immediate ? execute() : {}), } } return { client, request, requestGet: <T>( url: HttpClientComposableInputTemplate, options: HttpClientComposableRequestOptions = {}, ) => request<T>('get', url, options), requestPost: <T>( url: HttpClientComposableInputTemplate, options: HttpClientComposableRequestOptions = {}, ) => request<T>('post', url, options), requestPut: <T>( url: HttpClientComposableInputTemplate, options: HttpClientComposableRequestOptions = {}, ) => request<T>('put', url, options), requestDelete: <T>( url: HttpClientComposableInputTemplate, options: HttpClientComposableRequestOptions = {}, ) => request<T>('delete', url, options), requestHead: <T>( url: HttpClientComposableInputTemplate, options: HttpClientComposableRequestOptions = {}, ) => request<T>('head', url, options), requestPatch: <T>( url: HttpClientComposableInputTemplate, options: HttpClientComposableRequestOptions = {}, ) => request<T>('patch', url, options), } } /** * Use the composition API to get a new instance of a RepositoryHttp. * @remarks * If `HttpClientPlugin` is not created with `createHttpClient` and installed first, `useRepositoryHttp` throw the error "HttpClient instance not found". * @param template - The template string for the repository * @param options - The options for the repository {@link RepositoryHttpOptions} * @returns The instance of the RepositoryHttp, see {@link RepositoryHttp} * @example * ```html * <template> * <div> * <button @click="execute">Execute</button> * <div v-if="isLoading">Loading...</div> * <div v-if="isError">{{ error }}</div> * <div v-if="data?.[0]">{{ data?.[0].name }}</div> * </div> * </template> * * <script lang="ts" setup> * import { ref, computed } from 'vue' * import { useRepositoryHttp, type HTTPError } from '@volverjs/data/vue' * * type User = { * id: number, * name: string * } * * const { repository } = useRepositoryHttp<User>('users/:id') * const isLoading = ref(false) * const isError = computed(() => error.value !== undefined) * const error = ref<HTTPError>() * const item = ref<User>() * * const execute = async () => { * isLoading.value = true * try { * const { request } = repository.read({ id: 1 }) * const response = await request * item.value = response.item * } catch (e) { * error.value = e.message * } finally { * isLoading.value = false * } * } * </script> * ``` * @example * ```html * <template> * <div> * <button @click="execute">Execute</button> * <div v-if="isLoading">Loading...</div> * <div v-if="isError">{{ error }}</div> * <div v-if="item">{{ item.name }}</div> * </div> * </template> * * <script lang="ts" setup> * import { useRepositoryHttp } from '@volverjs/data/vue' * * type User = { * id: number, * name: string * } * * const { read } = useRepositoryHttp<User>('users/:id') * const { isLoading, isSuccess, isError, error, item, execute } = read( * { id: 1 }, * { immediate: false } * ) * </script> * ``` */ export function useRepositoryHttp<T = unknown, TResponse = unknown>(template: string | HttpClientUrlTemplate, options?: RepositoryHttpOptions<T, TResponse>) { const { client } = useHttpClient(options?.httpClientScope) const repository = new RepositoryHttp<T, TResponse>( client, template, options, ) const create = ( payload: T | Ref<T> | T[] | Ref<T[]> | undefined, params: ParamMap = {}, options: HttpClientComposableRequestOptions = {}, ) => { const { status, isLoading, isError, isSuccess } = defineHttpRequestStatus() const immediate = unref(options).immediate ?? true const error = ref<HTTPError>() const data = ref<T[]>() const item = ref<T>() const metadata = ref<ParamMap>() const execute = ( newPayload = unref(payload), newParams: ParamMap = unref(params), newOptions: RepositoryHttpReadOptions = unref(options), ) => { status.value = HttpRequestStatus.loading error.value = undefined item.value = undefined data.value = undefined const { abort, responsePromise } = repository.create( newPayload, newParams, newOptions, ) responsePromise .then((result) => { data.value = result.data item.value = result.item metadata.value = result.metadata if (result.aborted) { status.value = HttpRequestStatus.idle return } status.value = HttpRequestStatus.success }) .catch((e) => { error.value = e as HTTPError status.value = HttpRequestStatus.error }) return { abort, responsePromise } } return { execute, isLoading, isSuccess, isError, error: readonly(error), data, metadata, ...(immediate ? execute() : {}), } } const read = ( params: ParamMap | Ref<ParamMap>, options: RepositoryHttpComposableReadOptions = {}, ) => { const { status, isLoading, isError, isSuccess } = defineHttpRequestStatus() const immediate = unref(options).immediate ?? true const error = ref<HTTPError>() const data = ref<T[]>() const item = ref<T>() const metadata = ref<ParamMap>() const execute = ( newParams: ParamMap = unref(params), newOptions: RepositoryHttpReadOptions = unref(options), ) => { status.value = HttpRequestStatus.loading error.value = undefined item.value = undefined data.value = undefined const { abort, responsePromise } = repository.read( newParams, newOptions, ) responsePromise .then((result) => { data.value = result.data item.value = result.item metadata.value = result.metadata if (result.aborted) { status.value = HttpRequestStatus.idle return } status.value = HttpRequestStatus.success }) .catch((e) => { error.value = e as HTTPError status.value = HttpRequestStatus.error }) return { abort, responsePromise } } return { execute, isLoading, isSuccess, isError, error: readonly(error), data, item, metadata, ...(immediate ? execute() : {}), } } const update = ( payload: T | Ref<T> | T[] | Ref<T[]> | undefined, params: ParamMap = {}, options: HttpClientComposableRequestOptions = {}, ) => { const { status, isLoading, isError, isSuccess } = defineHttpRequestStatus() const immediate = unref(options).immediate ?? true const error = ref<HTTPError>() const data = ref<T[]>() const item = ref<T>() const metadata = ref<ParamMap>() const execute = ( newPayload = unref(payload), newParams: ParamMap = unref(params), newOptions: RepositoryHttpReadOptions = unref(options), ) => { status.value = HttpRequestStatus.loading error.value = undefined item.value = undefined data.value = undefined const { abort, responsePromise } = repository.update( newPayload, newParams, newOptions, ) responsePromise .then((result) => { data.value = result.data item.value = result.item metadata.value = result.metadata if (result.aborted) { status.value = HttpRequestStatus.idle return } status.value = HttpRequestStatus.success }) .catch((e) => { error.value = e as HTTPError status.value = HttpRequestStatus.error }) return { abort, responsePromise } } return { execute, isLoading, isSuccess, isError, error: readonly(error), data, metadata, ...(immediate ? execute() : {}), } } const remove = ( params: ParamMap | Ref<ParamMap>, options: HttpClientComposableRequestOptions = {}, ) => { const { status, isLoading, isError, isSuccess } = defineHttpRequestStatus() const immediate = unref(options).immediate ?? true const error = ref<HTTPError>() const execute = ( newParams: ParamMap = unref(params), newOptions: RepositoryHttpReadOptions = unref(options), ) => { status.value = HttpRequestStatus.loading error.value = undefined const { abort, responsePromise } = repository.remove( newParams, newOptions, ) responsePromise .then((result) => { if (result.aborted) { status.value = HttpRequestStatus.idle return } status.value = HttpRequestStatus.success }) .catch((e) => { error.value = e as HTTPError status.value = HttpRequestStatus.error }) return { abort, responsePromise } } return { execute, isLoading, isSuccess, isError, error: readonly(error), ...(immediate ? execute() : {}), } } return { repository, read, create, update, remove, } }