diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 548df083..6dcc24a8 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -41,6 +41,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", + "axios": "^1.6.8", "use-sync-external-store": "^1.2.1", "valibot": "^0.28.1" }, diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts new file mode 100644 index 00000000..945d8317 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -0,0 +1,215 @@ +import { + CODER_AUTH_HEADER_KEY, + CoderClient, + disabledClientError, +} from './CoderClient'; +import type { IdentityApi } from '@backstage/core-plugin-api'; +import { UrlSync } from './UrlSync'; +import { rest } from 'msw'; +import { mockServerEndpoints, server, wrappedGet } from '../testHelpers/server'; +import { CanceledError } from 'axios'; +import { delay } from '../utils/time'; +import { mockWorkspacesList } from '../testHelpers/mockCoderAppData'; +import type { Workspace, WorkspacesResponse } from '../typesConstants'; +import { + getMockConfigApi, + getMockDiscoveryApi, + getMockIdentityApi, + mockCoderAuthToken, + mockCoderWorkspacesConfig, +} from '../testHelpers/mockBackstageData'; + +type ConstructorApis = Readonly<{ + identityApi: IdentityApi; + urlSync: UrlSync; +}>; + +function getConstructorApis(): ConstructorApis { + const configApi = getMockConfigApi(); + const discoveryApi = getMockDiscoveryApi(); + const urlSync = new UrlSync({ + apis: { configApi, discoveryApi }, + }); + + const identityApi = getMockIdentityApi(); + return { urlSync, identityApi }; +} + +describe(`${CoderClient.name}`, () => { + describe('syncToken functionality', () => { + it('Will load the provided token into the client if it is valid', async () => { + const client = new CoderClient({ apis: getConstructorApis() }); + + const syncResult = await client.syncToken(mockCoderAuthToken); + expect(syncResult).toBe(true); + + let serverToken: string | null = null; + server.use( + rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => { + serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); + return res(ctx.status(200)); + }), + ); + + await client.sdk.getAuthenticatedUser(); + expect(serverToken).toBe(mockCoderAuthToken); + }); + + it('Will NOT load the provided token into the client if it is invalid', async () => { + const client = new CoderClient({ apis: getConstructorApis() }); + + const syncResult = await client.syncToken('Definitely not valid'); + expect(syncResult).toBe(false); + + let serverToken: string | null = null; + server.use( + rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => { + serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); + return res(ctx.status(200)); + }), + ); + + await client.sdk.getAuthenticatedUser(); + expect(serverToken).toBe(null); + }); + + it('Will propagate any other error types to the caller', async () => { + const client = new CoderClient({ + // Setting the timeout to 0 will make requests instantly fail from the + // next microtask queue tick + requestTimeoutMs: 0, + apis: getConstructorApis(), + }); + + server.use( + rest.get(mockServerEndpoints.authenticatedUser, async (_, res, ctx) => { + // MSW is so fast that sometimes it can respond before a forced + // timeout; have to introduce artificial delay (that shouldn't matter + // as long as the abort logic goes through properly) + await delay(2_000); + return res(ctx.status(200)); + }), + ); + + await expect(() => { + return client.syncToken(mockCoderAuthToken); + }).rejects.toThrow(CanceledError); + }); + }); + + describe('cleanupClient functionality', () => { + it('Will prevent any new SDK requests from going through', async () => { + const client = new CoderClient({ apis: getConstructorApis() }); + client.cleanupClient(); + + // Request should fail, even though token is valid + await expect(() => { + return client.syncToken(mockCoderAuthToken); + }).rejects.toThrow(disabledClientError); + + await expect(() => { + return client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + }).rejects.toThrow(disabledClientError); + }); + + it('Will abort any pending requests', async () => { + const client = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: getConstructorApis(), + }); + + // Sanity check to ensure that request can still go through normally + const workspacesPromise1 = client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + + await expect(workspacesPromise1).resolves.toEqual({ + workspaces: mockWorkspacesList, + count: mockWorkspacesList.length, + }); + + const workspacesPromise2 = client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + client.cleanupClient(); + await expect(() => workspacesPromise2).rejects.toThrow(); + }); + }); + + // Eventually the Coder SDK is going to get too big to test every single + // function. Focus tests on the functionality specifically being patched in + // for Backstage + describe('Coder SDK', () => { + it('Will remap all workspace icon URLs to use the proxy URL if necessary', async () => { + const apis = getConstructorApis(); + const client = new CoderClient({ + apis, + initialToken: mockCoderAuthToken, + }); + + server.use( + wrappedGet(mockServerEndpoints.workspaces, (_, res, ctx) => { + const withRelativePaths = mockWorkspacesList.map(ws => { + return { + ...ws, + template_icon: '/emojis/blueberry.svg', + }; + }); + + return res( + ctx.status(200), + ctx.json({ + workspaces: withRelativePaths, + count: withRelativePaths.length, + }), + ); + }), + ); + + const { workspaces } = await client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + + const { urlSync } = apis; + const apiEndpoint = await urlSync.getApiEndpoint(); + + const allWorkspacesAreRemapped = !workspaces.some(ws => + ws.template_icon.startsWith(apiEndpoint), + ); + + expect(allWorkspacesAreRemapped).toBe(true); + }); + + it('Lets the user search for workspaces by repo URL', async () => { + const client = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: getConstructorApis(), + }); + + const { workspaces } = await client.sdk.getWorkspacesByRepo( + { q: 'owner:me' }, + mockCoderWorkspacesConfig, + ); + + const buildParameterGroups = await Promise.all( + workspaces.map(ws => + client.sdk.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); + + for (const paramGroup of buildParameterGroups) { + const atLeastOneParamMatchesForGroup = paramGroup.some(param => { + return param.value === mockCoderWorkspacesConfig.repoUrl; + }); + + expect(atLeastOneParamMatchesForGroup).toBe(true); + } + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts new file mode 100644 index 00000000..047c08ca --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -0,0 +1,375 @@ +import globalAxios, { + AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig as RequestConfig, +} from 'axios'; +import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; +import { + type Workspace, + CODER_API_REF_ID_PREFIX, + WorkspacesRequest, + WorkspacesResponse, + User, +} from '../typesConstants'; +import type { UrlSync } from './UrlSync'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; +import { CoderSdk } from './MockCoderSdk'; + +export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; +const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; + +/** + * A version of the main Coder SDK API, with additional Backstage-specific + * methods and properties. + */ +export type BackstageCoderSdk = Readonly< + CoderSdk & { + getWorkspacesByRepo: ( + request: WorkspacesRequest, + config: CoderWorkspacesConfig, + ) => Promise; + } +>; + +type CoderClientApi = Readonly<{ + sdk: BackstageCoderSdk; + + /** + * Validates a new token, and loads it only if it is valid. + * Return value indicates whether the token is valid. + */ + syncToken: (newToken: string) => Promise; + + /** + * Cleans up a client instance, removing its links to all external systems. + */ + cleanupClient: () => void; +}>; + +const sharedCleanupAbortReason = new DOMException( + 'Coder Client instance has been manually cleaned up', + 'AbortError', +); + +// Can't make this value readonly at the type level because it has +// non-enumerable properties, and Object.freeze causes errors. Just have to +// treat this like a constant +export const disabledClientError = new Error( + 'Requests have been disabled for this client. Please create a new client', +); + +type ConstructorInputs = Readonly<{ + initialToken?: string; + requestTimeoutMs?: number; + + apis: Readonly<{ + urlSync: UrlSync; + identityApi: IdentityApi; + }>; +}>; + +export class CoderClient implements CoderClientApi { + private readonly urlSync: UrlSync; + private readonly identityApi: IdentityApi; + private readonly axios: AxiosInstance; + + private readonly requestTimeoutMs: number; + private readonly cleanupController: AbortController; + private readonly trackedEjectionIds: Set; + + private loadedSessionToken: string | undefined; + readonly sdk: BackstageCoderSdk; + + constructor(inputs: ConstructorInputs) { + const { + apis, + initialToken, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + } = inputs; + const { urlSync, identityApi } = apis; + + this.urlSync = urlSync; + this.identityApi = identityApi; + this.axios = globalAxios.create(); + + this.loadedSessionToken = initialToken; + this.requestTimeoutMs = requestTimeoutMs; + + this.cleanupController = new AbortController(); + this.trackedEjectionIds = new Set(); + + this.sdk = this.getBackstageCoderSdk(this.axios); + this.addBaseRequestInterceptors(); + } + + private addRequestInterceptor( + requestInterceptor: ( + config: RequestConfig, + ) => RequestConfig | Promise, + errorInterceptor?: (error: unknown) => unknown, + ): number { + const ejectionId = this.axios.interceptors.request.use( + requestInterceptor, + errorInterceptor, + ); + + this.trackedEjectionIds.add(ejectionId); + return ejectionId; + } + + private removeRequestInterceptorById(ejectionId: number): boolean { + // Even if we somehow pass in an ID that hasn't been associated with the + // Axios instance, that's a noop. No harm in calling method no matter what + this.axios.interceptors.request.eject(ejectionId); + + if (!this.trackedEjectionIds.has(ejectionId)) { + return false; + } + + this.trackedEjectionIds.delete(ejectionId); + return true; + } + + private addBaseRequestInterceptors(): void { + // Configs exist on a per-request basis; mutating the config for a new + // request won't mutate any configs for requests that are currently pending + const baseRequestInterceptor = async ( + config: RequestConfig, + ): Promise => { + // Front-load the setup steps that rely on external APIs, so that if any + // fail, the request bails out early before modifying the config + const proxyApiEndpoint = await this.urlSync.getApiEndpoint(); + const bearerToken = (await this.identityApi.getCredentials()).token; + + config.baseURL = proxyApiEndpoint; + config.signal = this.getTimeoutAbortSignal(); + + // The Axios docs have incredibly confusing wording about how multiple + // interceptors work. They say the interceptors are "run in the order + // added", implying that the first interceptor you add will always run + // first. That is not true - they're run in reverse order, so the newer + // interceptors will always run before anything else. Only add token from + // this base interceptor if a newer interceptor hasn't already added one + if (config.headers[CODER_AUTH_HEADER_KEY] === undefined) { + config.headers[CODER_AUTH_HEADER_KEY] = this.loadedSessionToken; + } + + if (bearerToken) { + config.headers.Authorization = `Bearer ${bearerToken}`; + } + + return config; + }; + + const baseErrorInterceptor = (error: unknown): unknown => { + const errorIsFromCleanup = + error instanceof DOMException && + error.name === sharedCleanupAbortReason.name && + error.message === sharedCleanupAbortReason.message; + + // Manually aborting a request is always treated as an error, even if we + // 100% expect it. Just scrub the error if it's from the cleanup + if (errorIsFromCleanup) { + return undefined; + } + + return error; + }; + + this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); + } + + private getBackstageCoderSdk( + axiosInstance: AxiosInstance, + ): BackstageCoderSdk { + const baseSdk = new CoderSdk(axiosInstance); + + const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { + const workspacesRes = await baseSdk.getWorkspaces(request); + const remapped = await this.remapWorkspaceIconUrls( + workspacesRes.workspaces, + ); + + return { + ...workspacesRes, + workspaces: remapped, + }; + }; + + const getWorkspacesByRepo = async ( + request: WorkspacesRequest, + config: CoderWorkspacesConfig, + ): Promise => { + const { workspaces } = await baseSdk.getWorkspaces(request); + const paramResults = await Promise.allSettled( + workspaces.map(ws => + this.sdk.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); + + const matchedWorkspaces: Workspace[] = []; + for (const [index, res] of paramResults.entries()) { + if (res.status === 'rejected') { + continue; + } + + for (const param of res.value) { + const include = + config.repoUrlParamKeys.includes(param.name) && + param.value === config.repoUrl; + + if (include) { + // Doing type assertion just in case noUncheckedIndexedAccess + // compiler setting ever gets turned on; this shouldn't ever break, + // but it's technically not type-safe + matchedWorkspaces.push(workspaces[index] as Workspace); + break; + } + } + } + + return { + workspaces: matchedWorkspaces, + count: matchedWorkspaces.length, + }; + }; + + return { + ...baseSdk, + getWorkspaces, + getWorkspacesByRepo, + }; + } + + /** + * Creates a combined abort signal that will abort when the client is cleaned + * up, but will also enforce request timeouts + */ + private getTimeoutAbortSignal(): AbortSignal { + // AbortSignal.any would do exactly what we need to, but it's too new for + // certain browsers to be reliable. Have to wire everything up manually + const timeoutController = new AbortController(); + + const timeoutId = window.setTimeout(() => { + const reason = new DOMException('Signal timed out', 'TimeoutException'); + timeoutController.abort(reason); + }, this.requestTimeoutMs); + + const cleanupSignal = this.cleanupController.signal; + cleanupSignal.addEventListener( + 'abort', + () => { + window.clearTimeout(timeoutId); + timeoutController.abort(cleanupSignal.reason); + }, + + // Attaching the timeoutController signal here makes it so that if the + // timeout resolves, this event listener will automatically be removed + { signal: timeoutController.signal }, + ); + + return timeoutController.signal; + } + + private async remapWorkspaceIconUrls( + workspaces: readonly Workspace[], + ): Promise { + const assetsRoute = await this.urlSync.getAssetsEndpoint(); + + return workspaces.map(ws => { + const templateIconUrl = ws.template_icon; + if (!templateIconUrl.startsWith('/')) { + return ws; + } + + return { + ...ws, + template_icon: `${assetsRoute}${templateIconUrl}`, + }; + }); + } + + /* *************************************************************************** + * All public functions should be defined as arrow functions to ensure they + * can be passed around React without risk of losing their `this` context + ****************************************************************************/ + + syncToken = async (newToken: string): Promise => { + // Because this newly-added interceptor will run before any other + // interceptors, you could make it so that the syncToken request will + // disable all other requests while validating. Chose not to do that because + // of React Query background re-fetches. As long as the new token is valid, + // they won't notice any difference at all, even though the token will have + // suddenly changed out from under them + const validationId = this.addRequestInterceptor(config => { + config.headers[CODER_AUTH_HEADER_KEY] = newToken; + return config; + }); + + try { + // Actual request type doesn't matter; just need to make some kind of + // dummy request. Should favor requests that all users have access to and + // that don't require request bodies + const dummyUser = await this.sdk.getAuthenticatedUser(); + + // Most of the time, we're going to trust the types returned back from the + // server without doing any type-checking, but because this request does + // deal with auth, we're going to do some extra validation steps + assertValidUser(dummyUser); + + this.loadedSessionToken = newToken; + return true; + } catch (err) { + const tokenIsInvalid = + err instanceof AxiosError && err.response?.status === 401; + if (tokenIsInvalid) { + return false; + } + + throw err; + } finally { + // Logic in finally blocks always run, even after the function has + // returned a value or thrown an error + this.removeRequestInterceptorById(validationId); + } + }; + + cleanupClient = (): void => { + this.trackedEjectionIds.forEach(id => { + this.axios.interceptors.request.eject(id); + }); + + this.trackedEjectionIds.clear(); + this.cleanupController.abort(sharedCleanupAbortReason); + this.loadedSessionToken = undefined; + + // Not using this.addRequestInterceptor, because we don't want to track this + // interceptor at all. It should never be ejected once the client has been + // disabled + this.axios.interceptors.request.use(() => { + throw disabledClientError; + }); + }; +} + +function assertValidUser(value: unknown): asserts value is User { + if (value === null || typeof value !== 'object') { + throw new Error('Returned JSON value is not an object'); + } + + const hasFields = + 'id' in value && + typeof value.id === 'string' && + 'username' in value && + typeof value.username === 'string'; + + if (!hasFields) { + throw new Error( + 'User object is missing expected fields for authentication request', + ); + } +} + +export const coderClientApiRef = createApiRef({ + id: `${CODER_API_REF_ID_PREFIX}.coder-client`, +}); diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts new file mode 100644 index 00000000..4245a65a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts @@ -0,0 +1,62 @@ +/** + * @file This is a temporary (and significantly limited) implementation of the + * "Coder SDK" that will eventually be imported from Coder core + * + * @todo Replace this with a full, proper implementation, and then expose it to + * plugin users. + */ +import globalAxios, { type AxiosInstance } from 'axios'; +import { + type User, + type WorkspacesRequest, + type WorkspaceBuildParameter, + type WorkspacesResponse, +} from '../typesConstants'; + +type CoderSdkApi = { + getAuthenticatedUser: () => Promise; + getWorkspaces: (request: WorkspacesRequest) => Promise; + getWorkspaceBuildParameters: ( + workspaceBuildId: string, + ) => Promise; +}; + +export class CoderSdk implements CoderSdkApi { + private readonly axios: AxiosInstance; + + constructor(axiosInstance?: AxiosInstance) { + this.axios = axiosInstance ?? globalAxios.create(); + } + + getWorkspaces = async ( + request: WorkspacesRequest, + ): Promise => { + const urlParams = new URLSearchParams({ + q: request.q ?? '', + limit: String(request.limit || 0), + after_id: request.after_id ?? '', + offset: String(request.offset || 0), + }); + + const response = await this.axios.get( + `/workspaces?${urlParams.toString()}`, + ); + + return response.data; + }; + + getWorkspaceBuildParameters = async ( + workspaceBuildId: string, + ): Promise => { + const response = await this.axios.get( + `/workspacebuilds/${workspaceBuildId}/parameters`, + ); + + return response.data; + }; + + getAuthenticatedUser = async (): Promise => { + const response = await this.axios.get('/users/me'); + return response.data; + }; +} diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts index 7776fadb..4932edea 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -4,7 +4,7 @@ import { getMockConfigApi, getMockDiscoveryApi, mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, + mockBackstageApiEndpoint, mockBackstageUrlRoot, } from '../testHelpers/mockBackstageData'; @@ -23,7 +23,7 @@ describe(`${UrlSync.name}`, () => { const cachedUrls = urlSync.getCachedUrls(); expect(cachedUrls).toEqual({ baseUrl: mockBackstageUrlRoot, - apiRoute: mockBackstageProxyEndpoint, + apiRoute: mockBackstageApiEndpoint, assetsRoute: mockBackstageAssetsEndpoint, }); }); diff --git a/plugins/backstage-plugin-coder/src/api/api.ts b/plugins/backstage-plugin-coder/src/api/api.ts deleted file mode 100644 index ac083724..00000000 --- a/plugins/backstage-plugin-coder/src/api/api.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { parse } from 'valibot'; -import type { IdentityApi } from '@backstage/core-plugin-api'; -import { BackstageHttpError } from './errors'; -import type { UrlSync } from './UrlSync'; -import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { - type CoderAuth, - assertValidCoderAuth, -} from '../components/CoderProvider'; -import { - type Workspace, - type WorkspaceAgentStatus, - workspaceBuildParametersSchema, - workspacesResponseSchema, -} from '../typesConstants'; - -export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; -export const REQUEST_TIMEOUT_MS = 20_000; - -export async function getCoderApiRequestInit( - authToken: string, - identity: IdentityApi, -): Promise { - const headers: HeadersInit = { - [CODER_AUTH_HEADER_KEY]: authToken, - }; - - try { - const credentials = await identity.getCredentials(); - if (credentials.token) { - headers.Authorization = `Bearer ${credentials.token}`; - } - } catch (err) { - if (err instanceof Error) { - throw err; - } - - throw new Error( - "Unable to parse user information for Coder requests. Please ensure that your Backstage deployment is integrated to use Backstage's Identity API", - ); - } - - return { - headers, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }; -} - -type TempPublicUrlSyncApi = Readonly<{ - getApiEndpoint: UrlSync['getApiEndpoint']; - getAssetsEndpoint: UrlSync['getAssetsEndpoint']; -}>; - -export type FetchInputs = Readonly<{ - auth: CoderAuth; - identityApi: IdentityApi; - urlSyncApi: TempPublicUrlSyncApi; -}>; - -type WorkspacesFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; - } ->; - -export async function getWorkspaces( - fetchInputs: WorkspacesFetchInputs, -): Promise { - const { coderQuery, auth, identityApi, urlSyncApi } = fetchInputs; - assertValidCoderAuth(auth); - - const urlParams = new URLSearchParams({ - q: coderQuery, - limit: '0', - }); - - const requestInit = await getCoderApiRequestInit(auth.token, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const response = await fetch( - `${apiEndpoint}/workspaces?${urlParams.toString()}`, - requestInit, - ); - - if (!response.ok) { - throw new BackstageHttpError( - `Unable to retrieve workspaces for query (${coderQuery})`, - response, - ); - } - - if (!response.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - response, - ); - } - - const json = await response.json(); - const { workspaces } = parse(workspacesResponseSchema, json); - - const assetsUrl = await urlSyncApi.getAssetsEndpoint(); - const withRemappedImgUrls = workspaces.map(ws => { - const templateIcon = ws.template_icon; - if (!templateIcon.startsWith('/')) { - return ws; - } - - return { - ...ws, - template_icon: `${assetsUrl}${templateIcon}`, - }; - }); - - return withRemappedImgUrls; -} - -type BuildParamsFetchInputs = Readonly< - FetchInputs & { - workspaceBuildId: string; - } ->; - -async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { - const { urlSyncApi, auth, workspaceBuildId, identityApi } = inputs; - assertValidCoderAuth(auth); - - const requestInit = await getCoderApiRequestInit(auth.token, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const res = await fetch( - `${apiEndpoint}/workspacebuilds/${workspaceBuildId}/parameters`, - requestInit, - ); - - if (!res.ok) { - throw new BackstageHttpError( - `Failed to retreive build params for workspace ID ${workspaceBuildId}`, - res, - ); - } - - if (!res.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - res, - ); - } - - const json = await res.json(); - return parse(workspaceBuildParametersSchema, json); -} - -type WorkspacesByRepoFetchInputs = Readonly< - WorkspacesFetchInputs & { - workspacesConfig: CoderWorkspacesConfig; - } ->; - -export async function getWorkspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): Promise { - const workspaces = await getWorkspaces(inputs); - - const paramResults = await Promise.allSettled( - workspaces.map(ws => - getWorkspaceBuildParameters({ - ...inputs, - workspaceBuildId: ws.latest_build.id, - }), - ), - ); - - const { workspacesConfig } = inputs; - const matchedWorkspaces: Workspace[] = []; - - for (const [index, res] of paramResults.entries()) { - if (res.status === 'rejected') { - continue; - } - - for (const param of res.value) { - const include = - workspacesConfig.repoUrlParamKeys.includes(param.name) && - param.value === workspacesConfig.repoUrl; - - if (include) { - // Doing type assertion just in case noUncheckedIndexedAccess compiler - // setting ever gets turned on; this shouldn't ever break, but it's - // technically not type-safe - matchedWorkspaces.push(workspaces[index] as Workspace); - break; - } - } - } - - return matchedWorkspaces; -} - -export function getWorkspaceAgentStatuses( - workspace: Workspace, -): readonly WorkspaceAgentStatus[] { - const uniqueStatuses: WorkspaceAgentStatus[] = []; - - for (const resource of workspace.latest_build.resources) { - if (resource.agents === undefined) { - continue; - } - - for (const agent of resource.agents) { - const status = agent.status; - if (!uniqueStatuses.includes(status)) { - uniqueStatuses.push(status); - } - } - } - - return uniqueStatuses; -} diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index a6507790..b10ecfe2 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,7 +1,8 @@ import type { UseQueryOptions } from '@tanstack/react-query'; -import type { Workspace } from '../typesConstants'; +import type { Workspace, WorkspacesRequest } from '../typesConstants'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { type FetchInputs, getWorkspaces, getWorkspacesByRepo } from './api'; +import type { BackstageCoderSdk } from './CoderClient'; +import type { CoderAuth } from '../components/CoderProvider'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; @@ -41,50 +42,61 @@ function getSharedWorkspacesQueryKey(coderQuery: string) { return [CODER_QUERY_KEY_PREFIX, 'workspaces', coderQuery] as const; } -type WorkspacesFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; - } ->; +type WorkspacesFetchInputs = Readonly<{ + auth: CoderAuth; + coderSdk: BackstageCoderSdk; + coderQuery: string; +}>; -export function workspaces( - inputs: WorkspacesFetchInputs, -): UseQueryOptions { - const enabled = inputs.auth.isAuthenticated; +export function workspaces({ + auth, + coderSdk, + coderQuery, +}: WorkspacesFetchInputs): UseQueryOptions { + const enabled = auth.isAuthenticated; return { - queryKey: getSharedWorkspacesQueryKey(inputs.coderQuery), - queryFn: () => getWorkspaces(inputs), + queryKey: getSharedWorkspacesQueryKey(coderQuery), enabled, - keepPreviousData: enabled && inputs.coderQuery !== '', + keepPreviousData: enabled && coderQuery !== '', refetchInterval: getCoderWorkspacesRefetchInterval, + queryFn: async () => { + const res = await coderSdk.getWorkspaces({ + q: coderQuery, + limit: 0, + }); + + return res.workspaces; + }, }; } type WorkspacesByRepoFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; + WorkspacesFetchInputs & { workspacesConfig: CoderWorkspacesConfig; } >; -export function workspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): UseQueryOptions { - // Disabling query object when there is no query text for performance reasons; +export function workspacesByRepo({ + coderQuery, + coderSdk, + auth, + workspacesConfig, +}: WorkspacesByRepoFetchInputs): UseQueryOptions { + // Disabling query when there is no query text for performance reasons; // searching through every workspace with an empty string can be incredibly // slow. - const enabled = - inputs.auth.isAuthenticated && inputs.coderQuery.trim() !== ''; + const enabled = auth.isAuthenticated && coderQuery.trim() !== ''; return { - queryKey: [ - ...getSharedWorkspacesQueryKey(inputs.coderQuery), - inputs.workspacesConfig, - ], - queryFn: () => getWorkspacesByRepo(inputs), + queryKey: [...getSharedWorkspacesQueryKey(coderQuery), workspacesConfig], enabled, keepPreviousData: enabled, refetchInterval: getCoderWorkspacesRefetchInterval, + queryFn: async () => { + const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; + const res = await coderSdk.getWorkspacesByRepo(request, workspacesConfig); + return res.workspaces; + }, }; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 745e6dc2..852abce1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -12,13 +12,12 @@ import { useQueryClient, } from '@tanstack/react-query'; import { BackstageHttpError } from '../../api/errors'; -import { getCoderApiRequestInit } from '../../api/api'; import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; -import { useUrlSync } from '../../hooks/useUrlSync'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { coderClientApiRef } from '../../api/CoderClient'; +import { useApi } from '@backstage/core-plugin-api'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -98,35 +97,22 @@ export function useCoderAuth(): CoderAuth { type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - const identityApi = useApi(identityApiRef); - const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - const { api: urlSyncApi } = useUrlSync(); - // Need to split hairs, because the query object can be disabled. Only want to // expose the initializing state if the app mounts with a token already in // localStorage const [authToken, setAuthToken] = useState(readAuthToken); const [readonlyInitialAuthToken] = useState(authToken); + const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); + const coderClient = useApi(coderClientApiRef); const queryIsEnabled = authToken !== ''; + const authValidityQuery = useQuery({ queryKey: [...sharedAuthQueryKey, authToken], + queryFn: () => coderClient.syncToken(authToken), enabled: queryIsEnabled, keepPreviousData: queryIsEnabled, refetchOnWindowFocus: query => query.state.data !== false, - queryFn: async () => { - // In this case, the request doesn't actually matter. Just need to make any - // kind of dummy request to validate the auth - const requestInit = await getCoderApiRequestInit(authToken, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const response = await fetch(`${apiEndpoint}/users/me`, requestInit); - - if (response.status >= 400 && response.status !== 401) { - throw new BackstageHttpError('Failed to complete request', response); - } - - return response.status !== 401; - }, }); const authState = generateAuthState({ diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 1b6b87da..955aae28 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -1,8 +1,8 @@ -import React, { PropsWithChildren } from 'react'; +import React from 'react'; import { renderHook } from '@testing-library/react'; import { act, waitFor } from '@testing-library/react'; -import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; +import { TestApiProvider } from '@backstage/test-utils'; import { configApiRef, discoveryApiRef, @@ -27,6 +27,7 @@ import { renderHookAsCoderEntity, } from '../../testHelpers/setup'; import { UrlSync, urlSyncApiRef } from '../../api/UrlSync'; +import { CoderClient, coderClientApiRef } from '../../api/CoderClient'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -50,47 +51,6 @@ describe(`${CoderProvider.name}`, () => { expect(result.current).toBe(mockAppConfig); } }); - - // Our documentation pushes people to define the config outside a component, - // just to stabilize the memory reference for the value, and make sure that - // memoization caches don't get invalidated too often. This test is just a - // safety net to catch what happens if someone forgets - test('Context value will change by reference on re-render if defined inline inside a parent', () => { - const ParentComponent = ({ children }: PropsWithChildren) => { - const configThatChangesEachRender = { ...mockAppConfig }; - - const discoveryApi = getMockDiscoveryApi(); - const configApi = getMockConfigApi(); - const urlSyncApi = new UrlSync({ - apis: { discoveryApi, configApi }, - }); - - return wrapInTestApp( - - - {children} - - , - ); - }; - - const { result, rerender } = renderHook(useCoderAppConfig, { - wrapper: ParentComponent, - }); - - const firstResult = result.current; - rerender(); - - expect(result.current).not.toBe(firstResult); - expect(result.current).toEqual(firstResult); - }); }); describe('Auth', () => { @@ -100,10 +60,16 @@ describe(`${CoderProvider.name}`, () => { const renderUseCoderAuth = () => { const discoveryApi = getMockDiscoveryApi(); const configApi = getMockConfigApi(); - const urlSyncApi = new UrlSync({ + const identityApi = getMockIdentityApi(); + + const urlSync = new UrlSync({ apis: { discoveryApi, configApi }, }); + const coderClientApi = new CoderClient({ + apis: { urlSync, identityApi }, + }); + return renderHook(useCoderAuth, { wrapper: ({ children }) => ( { [identityApiRef, getMockIdentityApi()], [configApiRef, configApi], [discoveryApiRef, discoveryApi], - [urlSyncApiRef, urlSyncApi], + + [urlSyncApiRef, urlSync], + [coderClientApiRef, coderClientApi], ]} > { it('Should display a fallback UI element instead of a broken image when the image fails to load', async () => { const workspaceName = 'blah'; - const imgPath = `${mockBackstageProxyEndpoint}/wrongUrlPal.png`; + const imgPath = `${mockBackstageApiEndpoint}/wrongUrlPal.png`; await renderInCoderEnvironment({ children: ( diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index 26b68daf..f7292e51 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -9,7 +9,7 @@ import { type Theme, makeStyles } from '@material-ui/core'; import { useId } from '../../hooks/hookPolyfills'; import { useCoderAppConfig } from '../CoderProvider'; -import { getWorkspaceAgentStatuses } from '../../api/api'; +import { getWorkspaceAgentStatuses } from '../../utils/workspaces'; import type { Workspace, WorkspaceStatus } from '../../typesConstants'; import { WorkspacesListIcon } from './WorkspacesListIcon'; diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts new file mode 100644 index 00000000..8fbec12c --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts @@ -0,0 +1,7 @@ +import { useApi } from '@backstage/core-plugin-api'; +import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; + +export function useCoderSdk(): BackstageCoderSdk { + const coderClient = useApi(coderClientApiRef); + return coderClient.sdk; +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index ea8405bd..a3b22d3d 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -1,10 +1,8 @@ import { useQuery } from '@tanstack/react-query'; - import { workspaces, workspacesByRepo } from '../api/queryOptions'; -import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; -import { useUrlSync } from './useUrlSync'; -import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; +import { useCoderSdk } from './useCoderSdk'; +import { useCoderAuth } from '../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; @@ -16,19 +14,12 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useCoderAuth(); - const identityApi = useApi(identityApiRef); - const { api: urlSyncApi } = useUrlSync(); + const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ - coderQuery, - auth, - identityApi, - urlSyncApi, - workspacesConfig, - }) - : workspaces({ coderQuery, auth, identityApi, urlSyncApi }); + ? workspacesByRepo({ auth, coderSdk, coderQuery, workspacesConfig }) + : workspaces({ auth, coderSdk, coderQuery }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index acc5b282..164242f7 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -6,13 +6,13 @@ import { type UseUrlSyncResult, useUrlSync } from './useUrlSync'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import { mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, + mockBackstageApiEndpoint, mockBackstageUrlRoot, getMockConfigApi, } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint: string = mockBackstageProxyEndpoint; + let proxyEndpoint: string = mockBackstageApiEndpoint; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; @@ -34,6 +34,7 @@ function renderUseUrlSync() { return { ...renderResult, + urlSync, updateMockProxyEndpoint: (newEndpoint: string) => { proxyEndpoint = newEndpoint; }, @@ -52,18 +53,19 @@ describe(`${useUrlSync.name}`, () => { state: { baseUrl: mockBackstageUrlRoot, assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageProxyEndpoint, + apiRoute: mockBackstageApiEndpoint, }, }), ); }); it('Should re-render when URLs change via the UrlSync class', async () => { - const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const { result, urlSync, updateMockProxyEndpoint } = renderUseUrlSync(); const initialState = result.current.state; updateMockProxyEndpoint(altProxyUrl); - await act(() => result.current.api.getApiEndpoint()); + await act(() => urlSync.getApiEndpoint()); + const newState = result.current.state; expect(newState).not.toEqual(initialState); }); @@ -71,7 +73,7 @@ describe(`${useUrlSync.name}`, () => { describe('Render helpers', () => { it('isEmojiUrl should correctly detect whether a URL is valid', async () => { - const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const { result, urlSync, updateMockProxyEndpoint } = renderUseUrlSync(); // Test for URL that is valid and matches the URL from UrlSync const url1 = `${mockBackstageAssetsEndpoint}/emoji`; @@ -84,7 +86,7 @@ describe(`${useUrlSync.name}`, () => { // Test for URL that was valid when the React app started up, but then // UrlSync started giving out a completely different URL updateMockProxyEndpoint(altProxyUrl); - await act(() => result.current.api.getApiEndpoint()); + await act(() => urlSync.getApiEndpoint()); expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(false); }); }); diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts index 9ec95ff7..d51fb097 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -1,25 +1,10 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useApi } from '@backstage/core-plugin-api'; -import { - type UrlSyncSnapshot, - type UrlSync, - urlSyncApiRef, -} from '../api/UrlSync'; +import { type UrlSyncSnapshot, urlSyncApiRef } from '../api/UrlSync'; export type UseUrlSyncResult = Readonly<{ state: UrlSyncSnapshot; - /** - * @todo This is a temporary property that is being used until the - * CoderClientApi is created, and can consume the UrlSync class directly. - * - * Delete this entire property once the new class is ready. - */ - api: Readonly<{ - getApiEndpoint: UrlSync['getApiEndpoint']; - getAssetsEndpoint: UrlSync['getAssetsEndpoint']; - }>; - /** * A collection of functions that can safely be called from within a React * component's render logic to get derived values. @@ -38,11 +23,6 @@ export function useUrlSync(): UseUrlSyncResult { return { state, - api: { - getApiEndpoint: urlSyncApi.getApiEndpoint, - getAssetsEndpoint: urlSyncApi.getAssetsEndpoint, - }, - renderHelpers: { isEmojiUrl: url => { return url.startsWith(`${state.assetsRoute}/emoji`); diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index ec09da33..5dad65dc 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -4,9 +4,11 @@ import { createApiFactory, discoveryApiRef, configApiRef, + identityApiRef, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; import { UrlSync, urlSyncApiRef } from './api/UrlSync'; +import { CoderClient, coderClientApiRef } from './api/CoderClient'; export const coderPlugin = createPlugin({ id: 'coder', @@ -24,6 +26,18 @@ export const coderPlugin = createPlugin({ }); }, }), + createApiFactory({ + api: coderClientApiRef, + deps: { + urlSync: urlSyncApiRef, + identityApi: identityApiRef, + }, + factory: ({ urlSync, identityApi }) => { + return new CoderClient({ + apis: { urlSync, identityApi }, + }); + }, + }), ], }); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index fffd265c..28e258f5 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -33,6 +33,7 @@ import { defaultUrlPrefixes, urlSyncApiRef, } from '../api/UrlSync'; +import { CoderClient, coderClientApiRef } from '../api/CoderClient'; /** * This is the key that Backstage checks from the entity data to determine the @@ -71,7 +72,7 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; * The string literal expression is complicated, but hover over it to see what * the final result is. */ -export const mockBackstageProxyEndpoint = +export const mockBackstageApiEndpoint = `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; /** @@ -298,6 +299,14 @@ export function getMockApiList(): readonly ApiTuple[] { }, }); + const mockCoderClient = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: { + urlSync: mockUrlSyncApi, + identityApi: mockIdentityApi, + }, + }); + return [ // APIs that Backstage ships with normally [errorApiRef, mockErrorApi], @@ -308,5 +317,6 @@ export function getMockApiList(): readonly ApiTuple[] { // Custom APIs specific to the Coder plugin [urlSyncApiRef, mockUrlSyncApi], + [coderClientApiRef, mockCoderClient], ]; } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts index 6e122aad..ce63590f 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts @@ -1,5 +1,5 @@ import type { Workspace, WorkspaceBuildParameter } from '../typesConstants'; -import { cleanedRepoUrl } from './mockBackstageData'; +import { cleanedRepoUrl, mockBackstageApiEndpoint } from './mockBackstageData'; /** * The main mock for a workspace whose repo URL matches cleanedRepoUrl @@ -7,7 +7,7 @@ import { cleanedRepoUrl } from './mockBackstageData'; export const mockWorkspaceWithMatch: Workspace = { id: 'workspace-with-match', name: 'Test-Workspace', - template_icon: '/emojis/dog.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/dog.svg`, owner_name: 'lil brudder', latest_build: { id: 'workspace-with-match-build', @@ -30,7 +30,7 @@ export const mockWorkspaceWithMatch: Workspace = { export const mockWorkspaceWithMatch2: Workspace = { id: 'workspace-with-match-2', name: 'Another-Test', - template_icon: '/emojis/z.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/z.svg`, owner_name: 'Coach Z', latest_build: { id: 'workspace-with-match-2-build', @@ -51,7 +51,7 @@ export const mockWorkspaceWithMatch2: Workspace = { export const mockWorkspaceNoMatch: Workspace = { id: 'workspace-no-match', name: 'No-match', - template_icon: '/emojis/star.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/star.svg`, owner_name: 'homestar runner', latest_build: { id: 'workspace-no-match-build', @@ -74,7 +74,7 @@ export const mockWorkspaceNoMatch: Workspace = { export const mockWorkspaceNoParameters: Workspace = { id: 'workspace-no-parameters', name: 'No-parameters', - template_icon: '/emojis/cheese.png', + template_icon: `${mockBackstageApiEndpoint}/emojis/cheese.png`, owner_name: 'The Cheat', latest_build: { id: 'workspace-no-parameters-build', diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 71d21145..47751269 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -15,12 +15,14 @@ import { mockWorkspaceBuildParameters, } from './mockCoderAppData'; import { + mockBackstageAssetsEndpoint, mockBearerToken, mockCoderAuthToken, - mockBackstageProxyEndpoint as root, + mockBackstageApiEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; -import { CODER_AUTH_HEADER_KEY } from '../api/api'; +import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; +import { User } from '../typesConstants'; type RestResolver = ResponseResolver< RestRequest, @@ -69,7 +71,7 @@ export function wrapInDefaultMiddleware( }, resolver); } -function wrappedGet( +export function wrappedGet( path: string, resolver: RestResolver, ): RestHandler { @@ -77,8 +79,14 @@ function wrappedGet( return rest.get(path, wrapped); } +export const mockServerEndpoints = { + workspaces: `${root}/workspaces`, + authenticatedUser: `${root}/users/me`, + workspaceBuildParameters: `${root}/workspacebuilds/:workspaceBuildId/parameters`, +} as const satisfies Record; + const mainTestHandlers: readonly RestHandler[] = [ - wrappedGet(`${root}/workspaces`, (req, res, ctx) => { + wrappedGet(mockServerEndpoints.workspaces, (req, res, ctx) => { const queryText = String(req.url.searchParams.get('q')); let returnedWorkspaces: Workspace[]; @@ -99,23 +107,27 @@ const mainTestHandlers: readonly RestHandler[] = [ ); }), - wrappedGet( - `${root}/workspacebuilds/:workspaceBuildId/parameters`, - (req, res, ctx) => { - const buildId = String(req.params.workspaceBuildId); - const selectedParams = mockWorkspaceBuildParameters[buildId]; + wrappedGet(mockServerEndpoints.workspaceBuildParameters, (req, res, ctx) => { + const buildId = String(req.params.workspaceBuildId); + const selectedParams = mockWorkspaceBuildParameters[buildId]; - if (selectedParams !== undefined) { - return res(ctx.status(200), ctx.json(selectedParams)); - } + if (selectedParams !== undefined) { + return res(ctx.status(200), ctx.json(selectedParams)); + } - return res(ctx.status(404)); - }, - ), + return res(ctx.status(404)); + }), // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me`, (_, res, ctx) => { - return res(ctx.status(200)); + wrappedGet(mockServerEndpoints.authenticatedUser, (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + id: '1', + avatar_url: `${mockBackstageAssetsEndpoint}/blueberry.png`, + username: 'blueberry', + }), + ); }), ]; diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index b92d0cdb..788a2dba 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -98,3 +98,20 @@ export type WorkspacesResponse = Output; export type WorkspaceBuildParameter = Output< typeof workspaceBuildParameterSchema >; + +export type WorkspacesRequest = Readonly<{ + after_id?: string; + limit?: number; + offset?: number; + q?: string; +}>; + +// This is actually the MinimalUser type from Coder core (User extends from +// ReducedUser, which extends from MinimalUser). Don't need all the properties +// until we roll out full SDK support, so going with the least privileged +// type definition for now +export type User = Readonly<{ + id: string; + username: string; + avatar_url: string; +}>; diff --git a/plugins/backstage-plugin-coder/src/utils/time.ts b/plugins/backstage-plugin-coder/src/utils/time.ts new file mode 100644 index 00000000..b37ce94b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/time.ts @@ -0,0 +1,9 @@ +export function delay(timeoutMs: number): Promise { + if (!Number.isInteger(timeoutMs) || timeoutMs < 0) { + throw new Error('Cannot delay by non-integer or negative values'); + } + + return new Promise(resolve => { + window.setTimeout(resolve, timeoutMs); + }); +} diff --git a/plugins/backstage-plugin-coder/src/utils/workspaces.ts b/plugins/backstage-plugin-coder/src/utils/workspaces.ts new file mode 100644 index 00000000..c36b6d4b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/workspaces.ts @@ -0,0 +1,22 @@ +import type { Workspace, WorkspaceAgentStatus } from '../typesConstants'; + +export function getWorkspaceAgentStatuses( + workspace: Workspace, +): readonly WorkspaceAgentStatus[] { + const uniqueStatuses: WorkspaceAgentStatus[] = []; + + for (const resource of workspace.latest_build.resources) { + if (resource.agents === undefined) { + continue; + } + + for (const agent of resource.agents) { + const status = agent.status; + if (!uniqueStatuses.includes(status)) { + uniqueStatuses.push(status); + } + } + } + + return uniqueStatuses; +} diff --git a/yarn.lock b/yarn.lock index b060021e..d1df1176 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9960,6 +9960,15 @@ axios@^1.0.0, axios@^1.4.0, axios@^1.6.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" + integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -13528,6 +13537,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"