Skip to main content
Glama
index.ts18.7 kB
import { computed, ComputedRef, inject, Ref, ref, unref, watch } from "vue"; import { AxiosInstance, AxiosResponse } from "axios"; import { Span, trace } from "@opentelemetry/api"; import { RouteLocationRaw } from "vue-router"; import { authApiInstance as auth, sdfApiInstance as sdf, } from "@/store/apis.web"; import { changeSetExists, muspelheimStatuses } from "@/store/realtime/heimdall"; import router from "@/router"; import { ChangeSetId } from "@/api/sdf/dal/change_set"; import * as heimdall from "@/store/realtime/heimdall"; import { assertIsDefined, Context } from "../types"; import * as rainbow from "../logic_composables/rainbow_counter"; import { reset } from "../logic_composables/navigation_stack"; export * as componentTypes from "./component"; export * as funcRunTypes from "./func_run"; const tracer = trace.getTracer("si-vue"); export enum routes { ActionAdd = "ActionAdd", ActionCancel = "ActionCancel", ActionFuncRunId = "ActionFuncRunId", ActionHold = "ActionHold", ActionRetry = "ActionRetry", ActionQueuedDetails = "ActionQueuedDetails", ApplyChangeSet = "ApplyChangeSet", AuditLogs = "AuditLogs", AuditLogsForComponent = "AuditLogsForComponent", ChangeSetApprovalStatus = "ChangeSetApprovalStatus", ChangeSetApprove = "ChangeSetApprove", ChangeSetCancelApprovalRequest = "ChangeSetCancelApprovalRequest", ChangeSetInitializeAndApply = "ChangeSetInitializeAndApply", ChangeSetRename = "ChangeSetRename", ChangeSetReopen = "ChangeSetReopen", ChangeSetRequestApproval = "ChangeSetRequestApproval", ComponentDebug = "ComponentDebug", ComponentsOnHead = "ComponentsOnHead", CreateComponent = "CreateComponent", CreateSecret = "CreateSecret", CreateView = "CreateView", DeleteComponents = "DeleteComponents", DeleteDefaultSubscriptionSource = "DeleteDefaultSubscriptionSource", DeleteView = "DeleteView", DuplicateComponents = "DuplicateComponents", EnqueueAttributeValue = "EnqueueAttributeValue", FuncRun = "FuncRun", FuncRunByAv = "FuncRunByAv", FuncRunLogs = "FuncRunLogs", GetFuncRunsPaginated = "GetFuncRunsPaginated", GetPublicKey = "GetPublicKey", RefreshAction = "RefreshAction", RestoreComponents = "RestoreComponents", SetDefaultSubscriptionSource = "SetDefaultSubscriptionSource", MgmtFuncRun = "MgmtFuncRun", MgmtFuncGetJobState = "MgmtFuncGetJobState", MgmtFuncGetLatest = "MgmtFuncGetLatest", UpdateComponentAttributes = "UpdateComponentAttributes", UpdateComponentManage = "UpdateComponentManage", UpdateComponentName = "UpdateComponentName", UpdateView = "UpdateView", UpgradeComponents = "UpgradeComponents", ViewAddComponents = "ViewAddComponents", ViewEraseComponents = "ViewEraseComponents", CreateChangeSet = "CreateChangeSet", AbandonChangeSet = "AbandonChangeSet", Workspaces = "Workspaces", ChangeSets = "ChangeSets", WorkspaceListUsers = "WorkspaceListUsers", GenerateApiToken = "GenerateApiToken", CheckDismissedOnboarding = "CheckDismissedOnboarding", DismissOnboarding = "DismissOnboarding", } /** * Once we implement the action API calls in here * Those routes would also exist in here */ const CAN_MUTATE_ON_HEAD: readonly routes[] = [ routes.ActionCancel, routes.ActionHold, routes.ActionRetry, routes.ChangeSetRename, routes.CreateChangeSet, routes.AbandonChangeSet, routes.EnqueueAttributeValue, routes.RefreshAction, routes.ChangeSetInitializeAndApply, routes.GenerateApiToken, routes.DismissOnboarding, routes.CheckDismissedOnboarding, ] as const; const COMPRESSED_ROUTES: readonly routes[] = [ routes.UpdateComponentAttributes, ] as const; const _routes: Record<routes, string> = { AbandonChangeSet: "/abandon", ActionAdd: "/action/add", ActionCancel: "/action/<id>/cancel", ActionFuncRunId: "/action/<id>/func_run_id", ActionHold: "/action/<id>/put_on_hold", ActionRetry: "/action/<id>/retry", ActionQueuedDetails: "/action/<id>/queued_details", ApplyChangeSet: "/apply", AuditLogs: "/audit-logs", AuditLogsForComponent: "/audit-logs/<componentId>", ChangeSetApprovalStatus: "/approval_status", ChangeSetApprove: "/approve", ChangeSetCancelApprovalRequest: "/cancel_approval_request", ChangeSetRename: "/rename", ChangeSetReopen: "/reopen", ChangeSetRequestApproval: "/request_approval", ComponentDebug: "/components/<id>/debug", CreateComponent: "/views/<viewId>/component", CreateSecret: "/components/<id>/secret", CreateView: "/views", DeleteComponents: "/components/delete", DeleteDefaultSubscriptionSource: "/components/<id>/attributes/default_source", DeleteView: "/views/<viewId>", DuplicateComponents: "/views/<viewId>/duplicate_components", EnqueueAttributeValue: "/components/<id>/attributes/enqueue", FuncRun: "/funcs/runs/<id>", FuncRunByAv: "/funcs/runs/latest_av/<id>/logs", FuncRunLogs: "/funcs/runs/<id>/logs", GetFuncRunsPaginated: "/funcs/runs/paginated", GetPublicKey: "/components/<id>/secret/public_key", MgmtFuncGetJobState: "/management/state/<funcRunId>", MgmtFuncGetLatest: "/management/component/<componentId>/latest", MgmtFuncRun: "/management/prototype/<prototypeId>/<componentId>/<viewId>", RefreshAction: "/action/refresh/<componentId>", RestoreComponents: "/components/restore", SetDefaultSubscriptionSource: "/components/<id>/attributes/default_source", UpdateComponentAttributes: "/components/<id>/attributes", UpdateComponentManage: "/components/<id>/manage", UpdateComponentName: "/components/<id>/name", UpdateView: "/views/<viewId>", UpgradeComponents: "/components/upgrade", ViewAddComponents: "/views/<viewId>/add_components", ViewEraseComponents: "/views/<viewId>/erase_components", // URLs without the default `change-set/:id` section ChangeSets: "/change-sets", CreateChangeSet: "/change-sets/create_change_set", ChangeSetInitializeAndApply: "/change-sets/create_initialize_apply", ComponentsOnHead: "/change-sets/components_on_head", WorkspaceListUsers: "/users", // Auth Api Endpoints Workspaces: "/workspaces", GenerateApiToken: "/authTokens", CheckDismissedOnboarding: "users/<userPk>/firstTimeModal", DismissOnboarding: "users/<userPk>/dismissFirstTimeModal", } as const; const AUTH_API_ROUTES = [ _routes.Workspaces, _routes.GenerateApiToken, _routes.CheckDismissedOnboarding, _routes.DismissOnboarding, ]; // the mechanics type Obs = { requested: Ref<boolean>; success: Ref<boolean>; inFlight: Ref<boolean>; bifrosting: Ref<boolean>; isWatched: boolean; span?: Span; label?: string; changeSetIdExecutedAgainst?: string; }; type LabeledObs = Obs & Required<Pick<Obs, "label">>; const setLabel = (obs: Obs, label: string): LabeledObs => { return { ...obs, label, }; }; type ApiContext = Pick< Context, "changeSetId" | "workspacePk" | "onHead" | "user" >; export const apiContextForChangeSet = ( ctx: Context, changeSetId: ChangeSetId, ): ApiContext => { return { workspacePk: ctx.workspacePk, user: ctx.user, changeSetId: computed(() => changeSetId), onHead: computed(() => ctx.headChangeSetId.value === changeSetId), }; }; export type DoResponse<R, A> = { req: AxiosResponse<R>; newChangeSetId: string | undefined; errorMessage: string | undefined; endpointArgs: A; }; export class APICall<Response, Args> { workspaceId: string; changeSetId: string; path: string; ctx: ApiContext; canMutateHead: boolean; mustCompress: boolean; description: string; obs: LabeledObs; lobbyRequired: boolean; endpointArgs: Args; constructor( ctx: ApiContext, path: string, canMutateHead: boolean, mustCompress: boolean, description: string, obs: LabeledObs, endpointArgs: Args, ) { this.ctx = ctx; const workspaceId = unref(ctx.workspacePk); const changeSetId = unref(ctx.changeSetId); this.workspaceId = workspaceId; this.changeSetId = changeSetId; this.path = path; this.canMutateHead = canMutateHead; this.mustCompress = mustCompress; this.description = description; this.obs = obs; this.lobbyRequired = false; this.endpointArgs = endpointArgs; } pathWithArgs(): string { let path = this.path.slice(); // slice() acts like a clone if (this.endpointArgs) { Object.entries(this.endpointArgs).forEach(([k, v]) => { // tsc gets a little confused in that `k` could be a symbol? path = path.replace(`<${k.toString()}>`, v as string); }); } return path; } url(): string { if ( [ _routes.Workspaces, _routes.CheckDismissedOnboarding, _routes.DismissOnboarding, ].includes(this.path) ) { return this.pathWithArgs(); } if ([_routes.GenerateApiToken].includes(this.path)) { return `workspaces/${this.workspaceId}${this.pathWithArgs()}`; } if ( [ _routes.ChangeSets, _routes.CreateChangeSet, _routes.ChangeSetInitializeAndApply, _routes.WorkspaceListUsers, _routes.ComponentsOnHead, ].includes(this.path) ) { return `v2/workspaces/${this.workspaceId}${this.pathWithArgs()}`; } const API_PREFIX = `v2/workspaces/${this.workspaceId}/change-sets/${this.changeSetId}`; return `${API_PREFIX}${this.pathWithArgs()}`; } api(): AxiosInstance { if (AUTH_API_ROUTES.includes(this.path)) { return auth; } else return sdf; } async do<D = Record<string, unknown>>( method: string, data: D, params?: URLSearchParams, ): Promise<DoResponse<Response, Args>> { const start = performance.now(); this.obs.requested.value = true; this.obs.inFlight.value = true; this.obs.bifrosting.value = true; this.obs.span = tracer.startSpan("watchedApi"); this.obs.span.setAttributes({ workspaceId: this.ctx.workspacePk.value, "api.on_head": this.ctx.onHead.value, userPk: this.ctx.user?.pk, "http.url": this.path, "api.label": this.obs.label, "api.is_watched": false, "http.method": method, "http.params": params?.toString(), "http.body": JSON.stringify(data), }); let newChangeSetId; if (!this.canMutateHead && this.ctx.onHead.value) { newChangeSetId = await this.makeChangeSet(); } this.obs.span.setAttributes({ changeSetId: newChangeSetId ?? this.ctx.changeSetId.value, }); rainbow.add(this.changeSetId, this.obs.label); this.obs.changeSetIdExecutedAgainst = this.changeSetId; let formattedData: D | ArrayBuffer = data; const headers: Record<string, string> = {}; if (this.mustCompress) { const textEncoder = new TextEncoder(); const readableStream = new ReadableStream({ start(controller) { controller.enqueue(textEncoder.encode(JSON.stringify(data))); controller.close(); }, }); const compressedStream = readableStream.pipeThrough( new CompressionStream("gzip"), ); formattedData = await new Response(compressedStream).arrayBuffer(); headers["Content-Encoding"] = "gzip"; } const req = await this.api()<Response>({ method, headers, url: this.url(), params, data: formattedData, validateStatus: (_status) => true, // don't throw exception on 4/5xxx }); const end = performance.now(); this.obs.span.setAttributes({ "http.status_code": req.status, // "watched" API "duration" will contain "how long it took for the data to update" // this will just be the http call time + latency, good to have both "http.duration": end - start, }); this.obs.inFlight.value = false; if (ok(req)) this.obs.success.value = true; if (!this.obs.isWatched) { rainbow.remove(this.changeSetId, this.obs.label); if (this.obs.span) this.obs.span.end(); } // We have two shapes of errors from sdf: data.error as a string and data.error.message as a string // This code extracts both of those as an errorMessage value for the caller. let errorMessage; const err = req.data instanceof Object && "error" in req.data ? req.data.error : undefined; if (typeof err === "string") { errorMessage = err; } else if ( err instanceof Object && "message" in err && typeof err.message === "string" ) { errorMessage = err.message; } return { req, newChangeSetId, errorMessage, endpointArgs: this.endpointArgs, }; } async delete<D = Record<string, unknown>>(data: D, params?: URLSearchParams) { return this.do("DELETE", data, params); } async get(params?: URLSearchParams) { this.obs.requested.value = true; this.obs.inFlight.value = true; const req = await this.api()<Response>({ method: "GET", url: this.url(), params, }); if (ok(req)) this.obs.success.value = true; this.obs.inFlight.value = false; return req; } async makeChangeSet() { const req = await this.api()<{ id: string }>({ method: "POST", url: `v2/workspaces/${this.workspaceId}/change-sets/create_change_set`, data: { name: this.description }, }); if (req.status === 200) { const newChangeSetId = req.data.id; // following API calls will use the new changeSetId this.changeSetId = newChangeSetId; return newChangeSetId; } else if (req.status === 202) { this.lobbyRequired = true; const newChangeSetId = req.data.id; this.changeSetId = newChangeSetId; return newChangeSetId; } else throw new Error("Unable to make change set"); } async put<D = Record<string, unknown>>(data: D, params?: URLSearchParams) { return this.do("PUT", data, params); } async post<D = Record<string, unknown>>(data: D, params?: URLSearchParams) { return this.do("POST", data, params); } // very odd, i tried having a private `innerPostPut` to pass `method = "POST" | "PUT"` // just to avoid duplicating the body... but something about the typing was breaking // and it didn't make sense... can revisit later } export const ok = (req: AxiosResponse) => { switch (req.status) { case 200: case 201: return true; default: return false; } }; type ApiRequestStatus = { isRequested: boolean; isPending: boolean; isFirstLoad: boolean; isError: boolean; isSuccess: boolean; }; export type EndpointArgs = Record<string, string>; export type UseApi<A = EndpointArgs> = { ok: (req: AxiosResponse) => boolean; endpoint: <Response>(key: routes, args?: A) => APICall<Response, A>; inFlight: Ref<boolean, boolean>; bifrosting: Ref<boolean, boolean>; requestStatuses: ComputedRef<ApiRequestStatus>; // eslint-disable-next-line @typescript-eslint/no-explicit-any setWatchFn: (fn: () => any) => void; navigateToNewChangeSet: ( to: RouteLocationRaw, newChangeSetId: string, ) => Promise<void>; }; export const useApi = <SpecificArgs extends EndpointArgs = EndpointArgs>( ctx?: ApiContext, ): UseApi<SpecificArgs> => { if (!ctx) ctx = inject<Context>("CONTEXT"); assertIsDefined(ctx); const obs: Obs = { requested: ref(false), success: ref(false), inFlight: ref(false), bifrosting: ref(false), isWatched: false, }; // You have to run endpoint BEFORE you call setWatchFn or it will break let labeledObs: LabeledObs; // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any let apiCall: APICall<any, SpecificArgs>; const endpoint = <Response>(key: routes, args?: SpecificArgs) => { const path = _routes[key]; const needsArgs = path.includes("<") && path.includes(">"); if (!args && needsArgs) { throw new Error(`Endpoint ${key}, ${path} requires arguments`); } assertIsDefined(ctx); const canMutateHead = CAN_MUTATE_ON_HEAD.includes(key); const mustCompress = COMPRESSED_ROUTES.includes(key); const argList = args ? Object.entries(args).flatMap((m) => m) : []; const desc = `${ key === routes.DeleteComponents ? "Remove Component" : key } ${argList.join(": ")} by ${ ctx.user?.name } on ${new Date().toLocaleDateString()}`; labeledObs = setLabel(obs, `${key}.${argList.join(".")}`); const call = new APICall<Response, SpecificArgs>( ctx, path, canMutateHead, mustCompress, desc, labeledObs, args ?? ({} as SpecificArgs), ); apiCall = call; return call; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const setWatchFn = (fn: () => any) => { labeledObs.isWatched = true; if (labeledObs.span) labeledObs.span.setAttribute("api.is_watched", true); const timeout = setTimeout(() => { if (labeledObs.span) { labeledObs.span.setAttributes({ timed_out: true, }); labeledObs.span.end(); } }, 60000); watch( fn, () => { assertIsDefined(ctx); clearTimeout(timeout); labeledObs.bifrosting.value = false; rainbow.remove( labeledObs.changeSetIdExecutedAgainst ?? ctx.changeSetId.value, labeledObs.label, ); if (labeledObs.span) labeledObs.span.end(); }, { once: true }, ); }; const INTERVAL = 50; // 50ms const MAX_WAIT_IN_SEC = 10; const MAX_RETRY = (MAX_WAIT_IN_SEC * 1000) / INTERVAL; // "how many attempts to reach N seconds?" const navigateToNewChangeSet = async ( to: RouteLocationRaw, newChangeSetId: string, ) => { await new Promise<void>((resolve, reject) => { let retry = 0; const interval = setInterval(async () => { assertIsDefined(ctx); if (retry >= MAX_RETRY) { clearInterval(interval); reject(); } const exists = await changeSetExists( ctx.workspacePk.value, newChangeSetId, ); if (exists) { clearInterval(interval); muspelheimStatuses.value[newChangeSetId] = true; resolve(); } retry += 1; }, INTERVAL); }); assertIsDefined(ctx); heimdall.showInterest(ctx.workspacePk.value, newChangeSetId); await router.push(to); reset(); }; const requestStatuses = computed<ApiRequestStatus>(() => { return { isRequested: obs.requested.value, isPending: obs.inFlight.value, isFirstLoad: false, isError: !obs.success.value, isSuccess: obs.success.value, }; }); return { ok, endpoint, inFlight: obs.inFlight, bifrosting: obs.bifrosting, requestStatuses, setWatchFn, navigateToNewChangeSet, }; };

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