Skip to main content
Glama
pinia_api_tools.ts26.7 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ // see pinia docs for more info about plugins - https://pinia.vuejs.org/core-concepts/plugins.html#augmenting-a-store /* NOTES / TODOS / IDEAS - vue query differentiates between `isFetching` (fetching at any time) and `isLoading` (fetching and no data / first load) - set up optimistic update/rollback tooling - set up helpers to clear request statuses - attach multiple tags to a request, can then clear all request statuses that have a tag (and maybe wildcards) - better tooling for making multiple requests together / tracking multiple statuses - review tooling/types around keyed request statuses (ie FETCH_THING/1, FETCH_THING/2) - return request status directly from action - better caching control on requests (ie mark requests with options to only request once, or some timeout, etc) - allow request keys to use non-string values - maybe allow objects instead of only arrays */ import { PiniaPlugin, PiniaPluginContext } from "pinia"; import { AxiosError, AxiosInstance, AxiosResponse } from "axios"; import { computed, ComputedRef, reactive, Ref, unref } from "vue"; import * as _ from "lodash-es"; import { createDeferredPromise, DeferredPromise, promiseDelay, } from "@si/ts-lib"; import { ulid } from "ulid"; import opentelemetry, { Span } from "@opentelemetry/api"; import { UseAsyncStateReturn } from "@vueuse/core"; import { describePattern, URLPattern } from "../utils/api_utils"; const tracer = opentelemetry.trace.getTracer("si-vue"); export type RequestUlid = string; // TODO: need to rework these types, and be more flexible... See vue-query for ideas type RawRequestStatusKeyArg = string | number | undefined | null; type RequestStatusKeyArg = RawRequestStatusKeyArg | Ref<RawRequestStatusKeyArg>; // this helper filters an object to only the keys that extend a specific type // see https://www.piotrl.net/typescript-condition-subset-types/ type SubType<Base, CheckExtends> = Pick< Base, { [Key in keyof Base]: Base[Key] extends CheckExtends ? Key : never; }[keyof Base] >; // here we are filtering all the actions down to those that return an ApiRequest object only type ApiRequestActionsOnly<A> = SubType< A, ( ...args: any ) => Promise<ApiRequest<unknown, unknown> | typeof ApiRequest.noop> >; // augment pinia TS types for our plugin - see https://pinia.vuejs.org/core-concepts/plugins.html#typescript declare module "pinia" { /* eslint-disable @typescript-eslint/no-unused-vars */ // adds new custom "options" for defineStore fn // export interface DefineStoreOptionsBase<S, Store> {} // augments the store itself export interface PiniaCustomProperties<Id, S, G, A> { getRequestStatus( requestKey: keyof ApiRequestActionsOnly<A>, // will allow only action names that return an ApiRequest ...keyedByArgs: RequestStatusKeyArg[] ): ComputedRef<ApiRequestStatus>; getRequestStatuses( requestKey: keyof ApiRequestActionsOnly<A>, // will allow only action names that return an ApiRequest keyedByArgs: RequestStatusKeyArg[] | ComputedRef<RequestStatusKeyArg[]>, ): ComputedRef<Record<string, ApiRequestStatus>>; clearRequestStatus( requestKey: keyof ApiRequestActionsOnly<A>, // will allow only action names that return an ApiRequest ...keyedByArgs: RequestStatusKeyArg[] ): void; RETRY_CONFLICT(requestUlid: RequestUlid): Promise<ApiRequest>; } // augments the store's state export interface PiniaCustomStateProperties<S> { apiRequestStatuses: RawRequestStatusesByKey; } } export class ApiRequest< Response = any, RequestParams = Record<string, unknown>, > { // these are used to attach the result which can be used directly by the caller static noop = Symbol("API_REQUEST_NOOP"); // most data and request status info should be used via the store, but it is useful sometimes rawResponseData: Response | undefined; rawResponseError: Error | AxiosError | undefined; rawSuccess?: boolean; // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor( public requestSpec: ApiRequestDescription<Response, RequestParams>, ) { if (!this.requestSpec.api) { this.requestSpec.api = (this.constructor as any).api; } if (!this.requestSpec.method) this.requestSpec.method = "get"; } // we use a getter to get the result so that we can add further type restrictions // ie, checking success guarantees data is present get result(): | { success: true; data: Response; } | { success: false; err: Error; errBody?: any; statusCode?: number | undefined; data?: Response extends undefined ? never : undefined; } { /* eslint-disable @typescript-eslint/no-non-null-assertion */ if (this.rawSuccess === undefined) { throw new Error("You must await the request to access the result"); } if (this.rawSuccess) { return { success: true, data: this.rawResponseData! }; } else { return { success: false, // the raw error object - usually an AxiosError err: this.rawResponseError!, // the (json) body of the failed request, if applicable ...(this.rawResponseError instanceof AxiosError && { errBody: this.rawResponseError.response?.data, statusCode: this.rawResponseError.response?.status, }), }; } } setSuccessfulResult(data: Response | undefined) { this.rawSuccess = true; this.rawResponseData = data; } setFailedResult(err: AxiosError | Error) { this.rawSuccess = false; this.rawResponseError = err; } } export function registerApi(axiosInstance: AxiosInstance) { class ApiRequestForSpecificApi< Response = any, RequestParams = Record<string, unknown>, > extends ApiRequest<Response, RequestParams> { static api = axiosInstance; } return ApiRequestForSpecificApi; } // types to describe our api request definitions type ApiRequestDescriptionGenerator = (payload: any) => ApiRequestDescription; type OptimisticReturn = (() => void) | void; type OptimisticFn = (requestUlid: RequestUlid) => OptimisticReturn; export type ApiRequestDescription< Response = any, RequestParams = Record<string, unknown>, > = { api?: AxiosInstance; /** http request method, defaults to "get" */ method?: "get" | "patch" | "post" | "put" | "delete"; // defaults to "get" if empty /** url to request, or url pattern for improved instrumentation when the url path constains data */ url?: string | URLPattern; /** request data, passed as querystring for GET, body for everything else */ params?: RequestParams; /** if a multipart form is being sent in a put/post/patch */ formData?: FormData; /** additional args to key the request status */ keyRequestStatusBy?: RawRequestStatusKeyArg | RawRequestStatusKeyArg[]; /** function to call if request is successfull (2xx) - usually contains changes to the store */ onSuccess?(response: Response): Promise<void> | void; /** function to call if request fails (>=400) - not common */ onFail?(response: any, requestUlid: RequestUlid): any | void; /** additional headers to pass with request */ headers?: Record<string, any>; /** additional axios options */ options?: Record<string, any>; // TODO: pull in axios options type? /** optional optimistic update fn to call before api request is made, should return a rollback fn called on api error */ optimistic?: OptimisticFn; /** add artificial delay (in ms) before fetching */ _delay?: number; /** * Set true to *not* pass the request ULID as a query parameter or JSON parameter. * (It is always passed as a header.) */ passRequestUlidInHeadersOnly?: boolean; }; /** type describing how we store the request statuses */ type RawApiRequestStatus = { requestedAt: Date; receivedAt?: Date; completedAt?: Date; lastSuccessAt?: Date; payload?: any; error?: AxiosResponse | { data: { error: { message: string } } }; completed?: DeferredPromise<any>; }; /** type describing the computed getter with some convenience properties */ export type ApiRequestStatus = Partial<RawApiRequestStatus> & { isRequested: boolean; isPending: boolean; isFirstLoad: boolean; isError: boolean; isSuccess: boolean; errorMessage?: string; errorCode?: string; }; type RawRequestStatusesByKey = Record<string, RawApiRequestStatus>; export type ConflictsForRetry = Record<RequestUlid, [string, ApiRequest]>; const TRACKING_KEY_SEPARATOR = "%"; export const initPiniaApiToolkitPlugin = (config: { api: AxiosInstance }) => { const plugin: PiniaPlugin = ({ // pinia, // app, store, options: storeOptions, }: PiniaPluginContext) => { if (store.$id === "heimdall") return; /* eslint-disable no-param-reassign */ // bail if plugin already called - not sure if necessary but previous pinia version needed it if (store.apiRequestStatuses) return; // have to attach our new state to both the store itself and store.$state store.apiRequestStatuses = reactive({} as RawRequestStatusesByKey); (store.$state as any).apiRequestStatuses = store.apiRequestStatuses; // make available to devtools if (import.meta.env.DEV) { // eslint-disable-next-line no-underscore-dangle store._customProperties.add("apiRequestStatuses"); } // triggers a named api request passing in a payload // this makes the api request, tracks the request status, handles errors, etc // TODO: probably will rework this a bit to get better type-checking async function triggerApiRequest( actionName: string, requestSpec: ApiRequestDescription, requestUlid: RequestUlid, ): Promise<any> { /* eslint-disable no-param-reassign,consistent-return */ // console.log('trigger api request', actionName, requestSpec); // determine the key we will use when storing the request status // most requests are tracked only by their name, for example LOGIN // but some requests we may want to track multiple instances of and split by id or other params // for example GET_THING%1, GET_THING%2 or GET_OAUTH_ACCOUNT%google%abc123 const trackingKeyArray: RawRequestStatusKeyArg[] = [actionName]; if (requestSpec.keyRequestStatusBy) { if (_.isArray(requestSpec.keyRequestStatusBy)) { trackingKeyArray.push(...requestSpec.keyRequestStatusBy); } else { trackingKeyArray.push(requestSpec.keyRequestStatusBy); } } const trackingKey = trackingKeyArray.join(TRACKING_KEY_SEPARATOR); // check if we have already have a pending identical request (same tracking key, and identical payload) // if so, we can skip triggering the new api call // TODO: probably need to add more options here for caching/dedupe request/logic // ex: let us skip certain requests if already successful, not just pending const existingRequest = store.getRequestStatus(trackingKey).value; if ( existingRequest.isPending && _.isEqual(existingRequest.payload, requestSpec.params) ) { // return original promise so caller can use the result directly if necessary return existingRequest.completed?.promise; } if (!requestSpec.params) requestSpec.params = {}; if ( !requestSpec.passRequestUlidInHeadersOnly && requestSpec.method !== "get" ) { requestSpec.params.requestUlid = requestUlid; } // mark the request as pending in the store // and attach a deferred promise we'll resolve when completed // which we'll use to not make the same request multiple times at the same time, but still be able to await the result const completed = createDeferredPromise(); store.$patch((state) => { state.apiRequestStatuses[trackingKey] = { requestedAt: new Date(), payload: requestSpec.params, completed, // do not clear "last success at" so we know if this request has ever succeeded lastSuccessAt: state.apiRequestStatuses[trackingKey]?.lastSuccessAt, }; }); // if optimistic update logic is defined, we trigger it here, before actually making the API request // that fn should return a fn to call which rolls back any optimistic updates in case the request fails let optimisticRollbackFn: OptimisticReturn; if (requestSpec.optimistic) { optimisticRollbackFn = requestSpec.optimistic(requestUlid); } const { method, url, params: requestParams, options, formData, onSuccess, onFail, } = requestSpec; let { headers } = requestSpec; let _url: string; let urlName; if (Array.isArray(url)) { [_url, urlName] = describePattern(url); } else if (typeof url === "string") { urlName = url; // string _url = url; } else { throw Error("URL is required"); } const name = `${method?.toUpperCase()} ${urlName}`; return tracer.startActiveSpan(name, async (span: Span) => { const time = window.performance.getEntriesByType( "navigation", )[0] as PerformanceNavigationTiming; const dns_duration = time.domainLookupEnd - time.domainLookupStart; const tcp_duration = time.connectEnd - time.connectStart; span.setAttributes({ "http.body": formData ? "multipart form" : JSON.stringify(requestParams), "http.url": _url, "http.method": method, "si.requestUlid": requestUlid, dns_duration, tcp_duration, "si.workspace.id": store.workspaceId, "si.change_set.id": store.changeSetId, ...(formData && requestParams ? { "http.params": JSON.stringify(requestParams) } : {}), }); try { if (!headers) headers = {}; headers["X-SI-REQUEST-ULID"] = requestUlid; opentelemetry.propagation.inject( opentelemetry.context.active(), headers, ); // the api (axios instance) to use can be set several ways: // - passed in with the specific request (probably not common) // - use registerApi(api) to create new SpecificApiRequest class with api attached // - fallback to default api that was set when initializing the plugin const api = requestSpec.api || config.api; // add artificial delay - helpful to test loading states in UI when using local API which is very fast if (import.meta.env.VITE_DELAY_API_REQUESTS) { await promiseDelay( parseInt(import.meta.env.VITE_DELAY_API_REQUESTS as string), ); } else if (requestSpec._delay) { await promiseDelay(requestSpec._delay); } // actually trigger the API request (uses the axios instance that was passed in) // may need to handle registering multiple apis if we need to hit more than 1 let request; if (method === "get") { request = await api({ method, url: _url, ...(headers && { headers }), params: requestParams, ...options, }); } else { // delete, post, patch, put. Axios's types forbid formData on the // request if method is not one of these , so we have to do branch // on the method types to make a formData request if (formData) { headers["Content-Type"] = "multipart/form-data"; request = await api({ method, url: _url, ...(headers && { headers }), data: formData, params: requestParams, ...options, }); } else { request = await api({ method, url: _url, ...(headers && { headers }), data: requestParams, ...options, }); } } // request was successful if reaching here // because axios throws an error if http status >= 400, timeout, etc // TODO: trigger global success hook that can be added on plugin init (or split by api) // mark request as received, which in absence of an error also means successful // TODO: we may want to reverse the order here of calling success and marking received? // ideally we would mark received at the same time as the changes made during onSuccess, but not sure it's possible store.$patch((state) => { state.apiRequestStatuses[trackingKey].lastSuccessAt = new Date(); state.apiRequestStatuses[trackingKey].receivedAt = new Date(); }); // call success handler if one was defined - this will usually be what updates the store // we may want to bundle this change together with onSuccess somehow? maybe doesnt matter? if (request) { if (typeof onSuccess === "function") { await onSuccess.call(store, request.data); } completed.resolve({ data: request.data, }); span.setAttributes({ "http.status_code": request.status }); } else { completed.resolve({}); } span.end(); return await completed.promise; // normally we want to get any response data from the store directly // but there are cases where its useful to be able to get it from the return value // like redirecting to a newly created ID, so we return the api response } catch (err: any) { store.$patch((state) => { state.apiRequestStatuses[trackingKey].receivedAt = new Date(); }); /* eslint-disable-next-line no-console */ console.log(err); // TODO: trigger global error hook that can be added on plugin init (or split by api) // if we made an optimistic update, we'll roll it back here if (optimisticRollbackFn) optimisticRollbackFn(); // call explicit failure handler if one is defined (usually rare) if (typeof onFail === "function") { const convertedData = onFail(err, requestUlid); if (convertedData) { err.response = { ...err.response, data: convertedData, }; } } // mark the request as failure and store the error info store.$patch((state) => { const apiRequestStatus = state.apiRequestStatuses[ trackingKey ] as ApiRequestStatus; // TODO maybe use Axios.isAxiosError instead, but don't want to change behavior right now if (err.response) { apiRequestStatus.error = (err as AxiosError).response; span.setAttributes({ "http.status_code": apiRequestStatus.error?.status, }); } else { // if error was not http error or had no response body // we still want some kind of fallback message to show // and we keep it in a similar format to what the http error response bodies apiRequestStatus.error = { data: { error: { message: err.message, }, }, }; } }); // return false so caller can easily detect a failure completed.resolve({ error: err, }); span.end(); return await completed.promise; } }); } async function fireActionResult( actionName: string, actionResult: ApiRequest, requestUlid: RequestUlid, ) { const request = actionResult; const triggerResult = await triggerApiRequest( actionName, request.requestSpec, requestUlid, ); if (!triggerResult) { throw new Error(`No trigger result for ${actionName}`); } if (triggerResult.error) { request.setFailedResult(triggerResult.error); } else { request.setSuccessfulResult(triggerResult.data); } } // wrap each action in a fn that will take an action result that is an ApiRequest // and actually trigger the request, waiting to finish until the request is complete function wrapApiAction( actionName: string, originalActionFn: (...args: any[]) => any, ) { // NOTE - have to be careful here to deal with non-async actions properly return async function wrappedActionFn(...args: any[]) { const requestUlid = ulid(); if ("registerRequestsBegin" in store) { store.registerRequestsBegin(requestUlid, actionName); } const actionResult: any = await originalActionFn(...args); if (actionResult instanceof ApiRequest) { await fireActionResult(actionName, actionResult, requestUlid); } if ("registerRequestsEnd" in store) { store.registerRequestsEnd(requestUlid); } return actionResult; }; } const apiRequestActions: any = {}; _.each(storeOptions.actions, (actionDef: any, actionName: string) => { // we wrap all async actions with a function that checks if the result is an ApiRequest // and if so, triggers the api call // TODO: this means we must mark our api actions as async... might want something more bulletproof here? const isAsync = actionDef.constructor.name === "AsyncFunction"; if (isAsync) { apiRequestActions[actionName] = wrapApiAction( actionName, store[actionName], ); } else { // added this warning to make sure api actions are async, but probably want to do something else const originalAction = store[actionName]; apiRequestActions[actionName] = (...args: any[]) => { const actionResult = originalAction(...args); if (actionResult instanceof ApiRequest) { throw new Error( `ApiActions must be async! - mark ${actionName} as async`, ); } return actionResult; }; } }); // helper to get the current status of a request in a format that is easy to work with const getRequestStatus = ( requestKey: string, ...keyedByArgs: RawRequestStatusKeyArg[] ) => { return computed(() => { const rawKeyedByArgs = _.map(keyedByArgs, unref); const fullKey = [requestKey, ..._.compact(rawKeyedByArgs)].join( TRACKING_KEY_SEPARATOR, ); const rawStatus = store.$state.apiRequestStatuses[fullKey]; if (!rawStatus?.requestedAt) { return { isRequested: false, isFirstLoad: false, isPending: false, isError: false, isSuccess: false, }; } return { ...rawStatus, isRequested: true, isPending: !rawStatus.receivedAt, isFirstLoad: !rawStatus.receivedAt && !rawStatus.lastSuccessAt, isSuccess: !!rawStatus.receivedAt && !rawStatus.error, isError: !!rawStatus.error, ...(rawStatus.error && { errorMessage: getApiStatusRequestErrorMessage(rawStatus.error), errorCode: rawStatus.error.data?.error?.type, }), }; }); }; const getRequestStatuses = ( requestKey: string, arrayOfArgs: string[] | ComputedRef<string[]>, ) => { return computed(() => { return _.mapValues( _.keyBy(unref(arrayOfArgs)), (arg: string) => getRequestStatus(requestKey, arg).value, ); }); }; const clearRequestStatus = ( requestKey: string, ...keyedByArgs: RawRequestStatusKeyArg[] ) => { const rawKeyedByArgs = _.map(keyedByArgs, unref); const fullKey = [requestKey, ..._.compact(rawKeyedByArgs)].join( TRACKING_KEY_SEPARATOR, ); delete store.$state.apiRequestStatuses[fullKey]; }; return { getRequestStatus, getRequestStatuses, clearRequestStatus, ...apiRequestActions, }; }; return plugin; }; export function getCombinedRequestStatus( statuses: ComputedRef<ApiRequestStatus>[], ) { return computed<ApiRequestStatus>(() => { return { isRequested: _.every(statuses, { isRequested: true }), isFirstLoad: _.some(statuses, { isFirstLoad: true }), isPending: _.some(statuses, { isPending: true }), isSuccess: _.every(statuses, { isSuccess: true }), isError: _.some(statuses, { isError: true }), // TODO: do we want to return the first error? an array of errors? }; }); } type AnyStatus = { requestStatus?: ApiRequestStatus; asyncState?: UseAsyncStateReturn<unknown, unknown[], boolean>; }; /** Get the error message from an ApiRequestStatus or UseAsyncState */ export function getErrorMessage({ requestStatus, asyncState }: AnyStatus) { return ( requestStatus?.errorMessage ?? getApiStatusRequestErrorMessage( asyncState?.error.value as ApiRequestStatus["error"], ) ?? (asyncState?.error.value as Error | undefined)?.message ); } export type LoadStatus = "uninitialized" | "loading" | "error" | "success"; /** Get the state of an ApiRequestStatus or UseAsyncState */ export function getLoadStatus({ requestStatus, asyncState, }: AnyStatus): LoadStatus { if (requestStatus?.isPending || asyncState?.isLoading.value) return "loading"; if (requestStatus?.isError || asyncState?.error.value) return "error"; if (requestStatus?.isSuccess || asyncState?.isReady.value) return "success"; return "uninitialized"; } function getApiStatusRequestErrorMessage( error: ApiRequestStatus["error"], ): string | undefined { // TODO the statusText bit doesn't seem to ever happen return ( error?.data?.error?.message || error?.data?.message || (error as any)?.statusText ); } /** * Turns the response from an API action into an async function * that returns data on success and throws error on error. */ export async function apiData<T>(request: Promise<ApiRequest<T>>) { const { result } = await request; if (!result.success) throw result.err; return result.data; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server