From dd2dc38c78303918a9f44ed716654b5a4ad36362 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 30 Apr 2024 12:09:32 -0400 Subject: [PATCH] refactor: reorganize API logic and create class/hook for simplifying proxy logic (#124) * wip: commit progress on UrlSync class/hook * refactor: consolidate emoji-testing logic * docs: update comments for clarity * refactor: rename helpers to renderHelpers * wip: finish initial implementation of UrlSync * chore: finish tests for UrlSync class * chore: add mock DiscoveryApi helper * chore: finish tests for useUrlSync * refactor: consolidate mock URL logic for useUrlSync * fix: update test helper to use API list * fix: remove unneeded imports * fix: get tests for all current code passing * fix: remove typo * fix: update useUrlSync to expose underlying api * refactor: increase data hiding for hook * fix: make useUrlSync tests less dependent on implementation details * refactor: remove reliance on baseUrl argument for fetch calls * refactor: split Backstage error type into separate file * refactor: clean up imports for api file * refactor: split main query options into separate file * consolidate how mock endpoints are defined * fix: remove base URL from auth calls * refactor: consolidate almost all auth logic into CoderAuthProvider * move api file into api directory * fix: revert prop that was changed for debugging * fix: revert prop definition * refactor: extract token-checking logic into middleware for server * refactor: move shared auth key to queryOptions file * docs: add reminder about arrow functions * fix: remove configApi from embedded class properties * fix: update query logic to remove any whitespace --- plugins/backstage-plugin-coder/package.json | 1 + .../src/api/UrlSync.test.ts | 90 ++++++++++ .../backstage-plugin-coder/src/api/UrlSync.ts | 157 +++++++++++++++++ .../src/{ => api}/api.ts | 159 ++++-------------- .../backstage-plugin-coder/src/api/errors.ts | 27 +++ .../src/api/queryOptions.ts | 90 ++++++++++ .../CoderProvider/CoderAuthProvider.tsx | 41 +++-- .../CoderProvider/CoderProvider.test.tsx | 23 ++- .../CoderProvider/CoderProvider.tsx | 4 +- .../WorkspacesListIcon.tsx | 9 +- .../WorkspacesListItem.tsx | 2 +- .../src/hooks/useBackstageEndpoints.test.ts | 26 --- .../src/hooks/useBackstageEndpoints.ts | 19 --- .../src/hooks/useCoderWorkspacesQuery.ts | 14 +- .../src/hooks/useUrlSync.test.tsx | 91 ++++++++++ .../src/hooks/useUrlSync.ts | 52 ++++++ plugins/backstage-plugin-coder/src/plugin.ts | 22 ++- .../src/testHelpers/mockBackstageData.ts | 82 ++++++++- .../src/testHelpers/server.ts | 21 ++- .../src/testHelpers/setup.tsx | 45 +---- .../src/typesConstants.ts | 11 ++ .../src/utils/StateSnapshotManager.ts | 21 ++- yarn.lock | 51 +++--- 23 files changed, 748 insertions(+), 310 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/UrlSync.test.ts create mode 100644 plugins/backstage-plugin-coder/src/api/UrlSync.ts rename plugins/backstage-plugin-coder/src/{ => api}/api.ts (53%) create mode 100644 plugins/backstage-plugin-coder/src/api/errors.ts create mode 100644 plugins/backstage-plugin-coder/src/api/queryOptions.ts delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts create mode 100644 plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index e48c8f21..548df083 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", + "use-sync-external-store": "^1.2.1", "valibot": "^0.28.1" }, "peerDependencies": { diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts new file mode 100644 index 00000000..7776fadb --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -0,0 +1,90 @@ +import { type UrlSyncSnapshot, UrlSync } from './UrlSync'; +import { type DiscoveryApi } from '@backstage/core-plugin-api'; +import { + getMockConfigApi, + getMockDiscoveryApi, + mockBackstageAssetsEndpoint, + mockBackstageProxyEndpoint, + mockBackstageUrlRoot, +} from '../testHelpers/mockBackstageData'; + +// Tests have to assume that DiscoveryApi and ConfigApi will always be in sync, +// and can be trusted as being equivalent-ish ways of getting at the same source +// of truth. If they're ever not, that's a bug with Backstage itself +describe(`${UrlSync.name}`, () => { + it('Has cached URLs ready to go when instantiated', () => { + const urlSync = new UrlSync({ + apis: { + configApi: getMockConfigApi(), + discoveryApi: getMockDiscoveryApi(), + }, + }); + + const cachedUrls = urlSync.getCachedUrls(); + expect(cachedUrls).toEqual({ + baseUrl: mockBackstageUrlRoot, + apiRoute: mockBackstageProxyEndpoint, + assetsRoute: mockBackstageAssetsEndpoint, + }); + }); + + it('Will update cached URLs if getApiEndpoint starts returning new values (for any reason)', async () => { + let baseUrl = mockBackstageUrlRoot; + const mockDiscoveryApi: DiscoveryApi = { + getBaseUrl: async () => baseUrl, + }; + + const urlSync = new UrlSync({ + apis: { + configApi: getMockConfigApi(), + discoveryApi: mockDiscoveryApi, + }, + }); + + const initialSnapshot = urlSync.getCachedUrls(); + baseUrl = 'blah'; + + await urlSync.getApiEndpoint(); + const newSnapshot = urlSync.getCachedUrls(); + expect(initialSnapshot).not.toEqual(newSnapshot); + + expect(newSnapshot).toEqual({ + baseUrl: 'blah', + apiRoute: 'blah/coder/api/v2', + assetsRoute: 'blah/coder', + }); + }); + + it('Lets external systems subscribe and unsubscribe to cached URL changes', async () => { + let baseUrl = mockBackstageUrlRoot; + const mockDiscoveryApi: DiscoveryApi = { + getBaseUrl: async () => baseUrl, + }; + + const urlSync = new UrlSync({ + apis: { + configApi: getMockConfigApi(), + discoveryApi: mockDiscoveryApi, + }, + }); + + const onChange = jest.fn(); + urlSync.subscribe(onChange); + + baseUrl = 'blah'; + await urlSync.getApiEndpoint(); + + expect(onChange).toHaveBeenCalledWith({ + baseUrl: 'blah', + apiRoute: 'blah/coder/api/v2', + assetsRoute: 'blah/coder', + } satisfies UrlSyncSnapshot); + + urlSync.unsubscribe(onChange); + onChange.mockClear(); + baseUrl = mockBackstageUrlRoot; + + await urlSync.getApiEndpoint(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts new file mode 100644 index 00000000..ae05294b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -0,0 +1,157 @@ +/** + * @file This is basically a fancier version of Backstage's built-in + * DiscoveryApi that is designed to work much better with React. Its hook + * counterpart is useUrlSync. + * + * The class helps with synchronizing URLs between Backstage classes and React + * UI components. It will: + * 1. Make sure URLs are cached so that they can be accessed directly and + * synchronously from the UI + * 2. Make sure that there are mechanisms for binding value changes to React + * state, so that if the URLs change over time, React components can + * re-render correctly + * + * As of April 2024, there are two main built-in ways of getting URLs from + * Backstage config values: + * 1. ConfigApi (offers synchronous methods, but does not have direct access to + * the proxy config - you have to stitch together the full path yourself) + * 2. DiscoveryApi (has access to proxy config, but all methods are async) + * + * Both of these work fine inside event handlers and effects, but are never safe + * to put directly inside render logic. They're not pure functions, so they + * can't be used as derived values, and they don't go through React state, so + * they're completely disconnected from React's render cycles. + */ +import { + type DiscoveryApi, + type ConfigApi, + createApiRef, +} from '@backstage/core-plugin-api'; +import { + type Subscribable, + type SubscriptionCallback, + CODER_API_REF_ID_PREFIX, +} from '../typesConstants'; +import { StateSnapshotManager } from '../utils/StateSnapshotManager'; + +// This is the value we tell people to use inside app-config.yaml +export const CODER_PROXY_PREFIX = '/coder'; + +const BASE_URL_KEY_FOR_CONFIG_API = 'backend.baseUrl'; +const PROXY_URL_KEY_FOR_DISCOVERY_API = 'proxy'; + +type UrlPrefixes = Readonly<{ + proxyPrefix: string; + apiRoutePrefix: string; + assetsRoutePrefix: string; +}>; + +export const defaultUrlPrefixes = { + proxyPrefix: `/api/proxy`, + apiRoutePrefix: '/api/v2', + assetsRoutePrefix: '', // Deliberately left as empty string +} as const satisfies UrlPrefixes; + +export type UrlSyncSnapshot = Readonly<{ + baseUrl: string; + apiRoute: string; + assetsRoute: string; +}>; + +type Subscriber = SubscriptionCallback; + +type ConstructorInputs = Readonly<{ + urlPrefixes?: Partial; + apis: Readonly<{ + discoveryApi: DiscoveryApi; + configApi: ConfigApi; + }>; +}>; + +const proxyRouteReplacer = /\/api\/proxy.*?$/; + +type UrlSyncApi = Subscribable & + Readonly<{ + getApiEndpoint: () => Promise; + getAssetsEndpoint: () => Promise; + getCachedUrls: () => UrlSyncSnapshot; + }>; + +export class UrlSync implements UrlSyncApi { + private readonly discoveryApi: DiscoveryApi; + private readonly urlCache: StateSnapshotManager; + private urlPrefixes: UrlPrefixes; + + constructor(setup: ConstructorInputs) { + const { apis, urlPrefixes = {} } = setup; + const { discoveryApi, configApi } = apis; + + this.discoveryApi = discoveryApi; + this.urlPrefixes = { ...defaultUrlPrefixes, ...urlPrefixes }; + + const proxyRoot = this.getProxyRootFromConfigApi(configApi); + this.urlCache = new StateSnapshotManager({ + initialSnapshot: this.prepareNewSnapshot(proxyRoot), + }); + } + + // ConfigApi is literally only used because it offers a synchronous way to + // get an initial URL to use from inside the constructor. Should not be used + // beyond initial constructor call, so it's not being embedded in the class + private getProxyRootFromConfigApi(configApi: ConfigApi): string { + const baseUrl = configApi.getString(BASE_URL_KEY_FOR_CONFIG_API); + return `${baseUrl}${this.urlPrefixes.proxyPrefix}`; + } + + private prepareNewSnapshot(newProxyUrl: string): UrlSyncSnapshot { + const { assetsRoutePrefix, apiRoutePrefix } = this.urlPrefixes; + + return { + baseUrl: newProxyUrl.replace(proxyRouteReplacer, ''), + assetsRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${assetsRoutePrefix}`, + apiRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${apiRoutePrefix}`, + }; + } + + /* *************************************************************************** + * All public functions should be defined as arrow functions to ensure they + * can be passed around React without risk of losing their `this` context + ****************************************************************************/ + + getApiEndpoint = async (): Promise => { + const proxyRoot = await this.discoveryApi.getBaseUrl( + PROXY_URL_KEY_FOR_DISCOVERY_API, + ); + + const newSnapshot = this.prepareNewSnapshot(proxyRoot); + this.urlCache.updateSnapshot(newSnapshot); + return newSnapshot.apiRoute; + }; + + getAssetsEndpoint = async (): Promise => { + const proxyRoot = await this.discoveryApi.getBaseUrl( + PROXY_URL_KEY_FOR_DISCOVERY_API, + ); + + const newSnapshot = this.prepareNewSnapshot(proxyRoot); + this.urlCache.updateSnapshot(newSnapshot); + return newSnapshot.assetsRoute; + }; + + getCachedUrls = (): UrlSyncSnapshot => { + return this.urlCache.getSnapshot(); + }; + + unsubscribe = (callback: Subscriber): void => { + this.urlCache.unsubscribe(callback); + }; + + subscribe = (callback: Subscriber): (() => void) => { + this.urlCache.subscribe(callback); + return () => this.unsubscribe(callback); + }; +} + +export const urlSyncApiRef = createApiRef({ + id: `${CODER_API_REF_ID_PREFIX}.url-sync`, +}); diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api/api.ts similarity index 53% rename from plugins/backstage-plugin-coder/src/api.ts rename to plugins/backstage-plugin-coder/src/api/api.ts index d11248eb..ac083724 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api/api.ts @@ -1,26 +1,23 @@ import { parse } from 'valibot'; -import { type UseQueryOptions } from '@tanstack/react-query'; - -import { CoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; +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, - WorkspaceAgentStatus, -} from './typesConstants'; -import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider'; -import { IdentityApi } from '@backstage/core-plugin-api'; - -export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; - -const PROXY_ROUTE_PREFIX = '/api/proxy/coder'; -export const API_ROUTE_PREFIX = `${PROXY_ROUTE_PREFIX}/api/v2`; -export const ASSETS_ROUTE_PREFIX = PROXY_ROUTE_PREFIX; +} from '../typesConstants'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; export const REQUEST_TIMEOUT_MS = 20_000; -async function getCoderApiRequestInit( +export async function getCoderApiRequestInit( authToken: string, identity: IdentityApi, ): Promise { @@ -49,34 +46,15 @@ async function getCoderApiRequestInit( }; } -// Makes it easier to expose HTTP responses in the event of errors and also -// gives TypeScript a faster way to type-narrow on those errors -export class BackstageHttpError extends Error { - #response: Response; - - constructor(errorMessage: string, response: Response) { - super(errorMessage); - this.name = 'HttpError'; - this.#response = response; - } - - get status() { - return this.#response.status; - } - - get ok() { - return this.#response.ok; - } - - get contentType() { - return this.#response.headers.get('content_type'); - } -} +type TempPublicUrlSyncApi = Readonly<{ + getApiEndpoint: UrlSync['getApiEndpoint']; + getAssetsEndpoint: UrlSync['getAssetsEndpoint']; +}>; -type FetchInputs = Readonly<{ +export type FetchInputs = Readonly<{ auth: CoderAuth; - baseUrl: string; - identity: IdentityApi; + identityApi: IdentityApi; + urlSyncApi: TempPublicUrlSyncApi; }>; type WorkspacesFetchInputs = Readonly< @@ -85,10 +63,10 @@ type WorkspacesFetchInputs = Readonly< } >; -async function getWorkspaces( +export async function getWorkspaces( fetchInputs: WorkspacesFetchInputs, ): Promise { - const { baseUrl, coderQuery, auth, identity } = fetchInputs; + const { coderQuery, auth, identityApi, urlSyncApi } = fetchInputs; assertValidCoderAuth(auth); const urlParams = new URLSearchParams({ @@ -96,9 +74,10 @@ async function getWorkspaces( limit: '0', }); - const requestInit = await getCoderApiRequestInit(auth.token, identity); + const requestInit = await getCoderApiRequestInit(auth.token, identityApi); + const apiEndpoint = await urlSyncApi.getApiEndpoint(); const response = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/workspaces?${urlParams.toString()}`, + `${apiEndpoint}/workspaces?${urlParams.toString()}`, requestInit, ); @@ -119,6 +98,7 @@ async function getWorkspaces( 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('/')) { @@ -127,7 +107,7 @@ async function getWorkspaces( return { ...ws, - template_icon: `${baseUrl}${ASSETS_ROUTE_PREFIX}${templateIcon}`, + template_icon: `${assetsUrl}${templateIcon}`, }; }); @@ -141,12 +121,13 @@ type BuildParamsFetchInputs = Readonly< >; async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { - const { baseUrl, auth, workspaceBuildId, identity } = inputs; + const { urlSyncApi, auth, workspaceBuildId, identityApi } = inputs; assertValidCoderAuth(auth); - const requestInit = await getCoderApiRequestInit(auth.token, identity); + const requestInit = await getCoderApiRequestInit(auth.token, identityApi); + const apiEndpoint = await urlSyncApi.getApiEndpoint(); const res = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/workspacebuilds/${workspaceBuildId}/parameters`, + `${apiEndpoint}/workspacebuilds/${workspaceBuildId}/parameters`, requestInit, ); @@ -234,85 +215,3 @@ export function getWorkspaceAgentStatuses( return uniqueStatuses; } - -export function isWorkspaceOnline(workspace: Workspace): boolean { - const latestBuildStatus = workspace.latest_build.status; - const isAvailable = - latestBuildStatus !== 'stopped' && - latestBuildStatus !== 'stopping' && - latestBuildStatus !== 'pending'; - - if (!isAvailable) { - return false; - } - - const statuses = getWorkspaceAgentStatuses(workspace); - return statuses.every( - status => status === 'connected' || status === 'connecting', - ); -} - -export function workspaces( - inputs: WorkspacesFetchInputs, -): UseQueryOptions { - const enabled = inputs.auth.status === 'authenticated'; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery], - queryFn: () => getWorkspaces(inputs), - enabled, - keepPreviousData: enabled && inputs.coderQuery !== '', - }; -} - -export function workspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): UseQueryOptions { - const enabled = - inputs.auth.status === 'authenticated' && inputs.coderQuery !== ''; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery, 'repo'], - queryFn: () => getWorkspacesByRepo(inputs), - enabled, - keepPreviousData: enabled, - }; -} - -type AuthValidationInputs = Readonly<{ - baseUrl: string; - authToken: string; - identity: IdentityApi; -}>; - -async function isAuthValid(inputs: AuthValidationInputs): Promise { - const { baseUrl, authToken, identity } = inputs; - - // 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, identity); - const response = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/users/me`, - requestInit, - ); - - if (response.status >= 400 && response.status !== 401) { - throw new BackstageHttpError('Failed to complete request', response); - } - - return response.status !== 401; -} - -export const authQueryKey = [CODER_QUERY_KEY_PREFIX, 'auth'] as const; - -export function authValidation( - inputs: AuthValidationInputs, -): UseQueryOptions { - const enabled = inputs.authToken !== ''; - return { - queryKey: [...authQueryKey, inputs.authToken], - queryFn: () => isAuthValid(inputs), - enabled, - keepPreviousData: enabled, - }; -} diff --git a/plugins/backstage-plugin-coder/src/api/errors.ts b/plugins/backstage-plugin-coder/src/api/errors.ts new file mode 100644 index 00000000..924eba6d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/errors.ts @@ -0,0 +1,27 @@ +// Makes it easier to expose HTTP responses in the event of errors and also +// gives TypeScript a faster way to type-narrow on those errors +export class BackstageHttpError extends Error { + #response: Response; + + constructor(errorMessage: string, response: Response) { + super(errorMessage); + this.name = 'HttpError'; + this.#response = response; + } + + static isInstance(value: unknown): value is BackstageHttpError { + return value instanceof BackstageHttpError; + } + + get status() { + return this.#response.status; + } + + get ok() { + return this.#response.ok; + } + + get contentType() { + return this.#response.headers.get('content_type'); + } +} diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts new file mode 100644 index 00000000..a6507790 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -0,0 +1,90 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import type { Workspace } from '../typesConstants'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; +import { type FetchInputs, getWorkspaces, getWorkspacesByRepo } from './api'; + +export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; + +// Defined here and not in CoderAuthProvider.ts to avoid circular dependency +// issues +export const sharedAuthQueryKey = [CODER_QUERY_KEY_PREFIX, 'auth'] as const; + +const PENDING_REFETCH_INTERVAL_MS = 5_000; +const BACKGROUND_REFETCH_INTERVAL_MS = 60_000; + +function getCoderWorkspacesRefetchInterval( + workspaces?: readonly Workspace[], +): number | false { + if (workspaces === undefined) { + // Boolean false indicates that no periodic refetching should happen (but + // a refetch can still happen in the background in response to user action) + return false; + } + + const areAnyWorkspacesPending = workspaces.some(ws => { + if (ws.latest_build.status === 'pending') { + return true; + } + + return ws.latest_build.resources.some(resource => { + const agents = resource.agents; + return agents?.some(agent => agent.status === 'connecting') ?? false; + }); + }); + + return areAnyWorkspacesPending + ? PENDING_REFETCH_INTERVAL_MS + : BACKGROUND_REFETCH_INTERVAL_MS; +} + +function getSharedWorkspacesQueryKey(coderQuery: string) { + return [CODER_QUERY_KEY_PREFIX, 'workspaces', coderQuery] as const; +} + +type WorkspacesFetchInputs = Readonly< + FetchInputs & { + coderQuery: string; + } +>; + +export function workspaces( + inputs: WorkspacesFetchInputs, +): UseQueryOptions { + const enabled = inputs.auth.isAuthenticated; + + return { + queryKey: getSharedWorkspacesQueryKey(inputs.coderQuery), + queryFn: () => getWorkspaces(inputs), + enabled, + keepPreviousData: enabled && inputs.coderQuery !== '', + refetchInterval: getCoderWorkspacesRefetchInterval, + }; +} + +type WorkspacesByRepoFetchInputs = Readonly< + FetchInputs & { + coderQuery: string; + workspacesConfig: CoderWorkspacesConfig; + } +>; + +export function workspacesByRepo( + inputs: WorkspacesByRepoFetchInputs, +): UseQueryOptions { + // Disabling query object 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() !== ''; + + return { + queryKey: [ + ...getSharedWorkspacesQueryKey(inputs.coderQuery), + inputs.workspacesConfig, + ], + queryFn: () => getWorkspacesByRepo(inputs), + enabled, + keepPreviousData: enabled, + refetchInterval: getCoderWorkspacesRefetchInterval, + }; +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 8dd9a741..745e6dc2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -11,14 +11,13 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; - +import { BackstageHttpError } from '../../api/errors'; +import { getCoderApiRequestInit } from '../../api/api'; import { - BackstageHttpError, CODER_QUERY_KEY_PREFIX, - authQueryKey, - authValidation, -} from '../../api'; -import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; + sharedAuthQueryKey, +} from '../../api/queryOptions'; +import { useUrlSync } from '../../hooks/useUrlSync'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -99,9 +98,9 @@ export function useCoderAuth(): CoderAuth { type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); + 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 @@ -109,9 +108,25 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const [authToken, setAuthToken] = useState(readAuthToken); const [readonlyInitialAuthToken] = useState(authToken); - const authValidityQuery = useQuery({ - ...authValidation({ baseUrl, authToken, identity }), + const queryIsEnabled = authToken !== ''; + const authValidityQuery = useQuery({ + queryKey: [...sharedAuthQueryKey, 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({ @@ -158,7 +173,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const queryError = event.query.state.error; const shouldRevalidate = !isRefetchingTokenQuery && - queryError instanceof BackstageHttpError && + BackstageHttpError.isInstance(queryError) && queryError.status === 401; if (!shouldRevalidate) { @@ -166,7 +181,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { } isRefetchingTokenQuery = true; - await queryClient.refetchQueries({ queryKey: authQueryKey }); + await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); isRefetchingTokenQuery = false; }); @@ -240,7 +255,7 @@ function generateAuthState({ }; } - if (authValidityQuery.error instanceof BackstageHttpError) { + if (BackstageHttpError.isInstance(authValidityQuery.error)) { const deploymentLikelyUnavailable = authValidityQuery.error.status === 504 || (authValidityQuery.error.status === 200 && 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 41e75bee..1b6b87da 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -5,6 +5,7 @@ import { act, waitFor } from '@testing-library/react'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; import { configApiRef, + discoveryApiRef, errorApiRef, identityApiRef, } from '@backstage/core-plugin-api'; @@ -15,6 +16,7 @@ import { type CoderAuth, useCoderAuth } from './CoderAuthProvider'; import { getMockConfigApi, + getMockDiscoveryApi, getMockErrorApi, getMockIdentityApi, mockAppConfig, @@ -24,6 +26,7 @@ import { getMockQueryClient, renderHookAsCoderEntity, } from '../../testHelpers/setup'; +import { UrlSync, urlSyncApiRef } from '../../api/UrlSync'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -56,11 +59,19 @@ describe(`${CoderProvider.name}`, () => { const ParentComponent = ({ children }: PropsWithChildren) => { const configThatChangesEachRender = { ...mockAppConfig }; + const discoveryApi = getMockDiscoveryApi(); + const configApi = getMockConfigApi(); + const urlSyncApi = new UrlSync({ + apis: { discoveryApi, configApi }, + }); + return wrapInTestApp( @@ -87,13 +98,21 @@ describe(`${CoderProvider.name}`, () => { // core to the functionality. In this case, you do need to bring in the full // CoderProvider const renderUseCoderAuth = () => { + const discoveryApi = getMockDiscoveryApi(); + const configApi = getMockConfigApi(); + const urlSyncApi = new UrlSync({ + apis: { discoveryApi, configApi }, + }); + return renderHook(useCoderAuth, { wrapper: ({ children }) => ( & const shouldRetryRequest = (failureCount: number, error: unknown): boolean => { const isBelowThreshold = failureCount < MAX_FETCH_FAILURES; - if (!(error instanceof BackstageHttpError)) { + if (!BackstageHttpError.isInstance(error)) { return isBelowThreshold; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx index 23623a72..079189a9 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx @@ -1,5 +1,5 @@ import React, { ForwardedRef, HTMLAttributes, useState } from 'react'; -import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; +import { useUrlSync } from '../../hooks/useUrlSync'; import { Theme, makeStyles } from '@material-ui/core'; type WorkspaceListIconProps = Readonly< @@ -56,11 +56,8 @@ export const WorkspacesListIcon = ({ ...delegatedProps }: WorkspaceListIconProps) => { const [hasError, setHasError] = useState(false); - const { assetsProxyUrl } = useBackstageEndpoints(); - - const styles = useStyles({ - isEmoji: src.startsWith(`${assetsProxyUrl}/emoji`), - }); + const { renderHelpers } = useUrlSync(); + const styles = useStyles({ isEmoji: renderHelpers.isEmojiUrl(src) }); return (
{ - it('Should provide pre-formatted URLs for interacting with Backstage endpoints', async () => { - const { result } = await renderHookAsCoderEntity(useBackstageEndpoints); - - expect(result.current).toEqual( - expect.objectContaining({ - baseUrl: mockBackstageUrlRoot, - assetsProxyUrl: mockBackstageAssetsEndpoint, - apiProxyUrl: mockBackstageProxyEndpoint, - }), - ); - }); -}); diff --git a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts b/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts deleted file mode 100644 index 7defa50f..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -import { ASSETS_ROUTE_PREFIX, API_ROUTE_PREFIX } from '../api'; - -export type UseBackstageEndpointResult = Readonly<{ - baseUrl: string; - assetsProxyUrl: string; - apiProxyUrl: string; -}>; - -export function useBackstageEndpoints(): UseBackstageEndpointResult { - const backstageConfig = useApi(configApiRef); - const baseUrl = backstageConfig.getString('backend.baseUrl'); - - return { - baseUrl, - assetsProxyUrl: `${baseUrl}${ASSETS_ROUTE_PREFIX}`, - apiProxyUrl: `${baseUrl}${API_ROUTE_PREFIX}`, - }; -} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 3517ad2b..ea8405bd 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { workspaces, workspacesByRepo } from '../api'; +import { workspaces, workspacesByRepo } from '../api/queryOptions'; import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; -import { useBackstageEndpoints } from './useBackstageEndpoints'; +import { useUrlSync } from './useUrlSync'; import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; @@ -16,19 +16,19 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useCoderAuth(); - const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); + const identityApi = useApi(identityApiRef); + const { api: urlSyncApi } = useUrlSync(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData ? workspacesByRepo({ coderQuery, - identity, auth, - baseUrl, + identityApi, + urlSyncApi, workspacesConfig, }) - : workspaces({ coderQuery, identity, auth, baseUrl }); + : workspaces({ coderQuery, auth, identityApi, urlSyncApi }); 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 new file mode 100644 index 00000000..acc5b282 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { UrlSync, urlSyncApiRef } from '../api/UrlSync'; +import { type UseUrlSyncResult, useUrlSync } from './useUrlSync'; +import type { DiscoveryApi } from '@backstage/core-plugin-api'; +import { + mockBackstageAssetsEndpoint, + mockBackstageProxyEndpoint, + mockBackstageUrlRoot, + getMockConfigApi, +} from '../testHelpers/mockBackstageData'; + +function renderUseUrlSync() { + let proxyEndpoint: string = mockBackstageProxyEndpoint; + const mockDiscoveryApi: DiscoveryApi = { + getBaseUrl: async () => proxyEndpoint, + }; + + const urlSync = new UrlSync({ + apis: { + discoveryApi: mockDiscoveryApi, + configApi: getMockConfigApi(), + }, + }); + + const renderResult = renderHook(useUrlSync, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + return { + ...renderResult, + updateMockProxyEndpoint: (newEndpoint: string) => { + proxyEndpoint = newEndpoint; + }, + }; +} + +describe(`${useUrlSync.name}`, () => { + const altProxyUrl = 'http://zombo.com/api/proxy/coder'; + + describe('State', () => { + it('Should provide pre-formatted URLs for interacting with Backstage endpoints', () => { + const { result } = renderUseUrlSync(); + + expect(result.current).toEqual( + expect.objectContaining>({ + state: { + baseUrl: mockBackstageUrlRoot, + assetsRoute: mockBackstageAssetsEndpoint, + apiRoute: mockBackstageProxyEndpoint, + }, + }), + ); + }); + + it('Should re-render when URLs change via the UrlSync class', async () => { + const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const initialState = result.current.state; + + updateMockProxyEndpoint(altProxyUrl); + await act(() => result.current.api.getApiEndpoint()); + const newState = result.current.state; + expect(newState).not.toEqual(initialState); + }); + }); + + describe('Render helpers', () => { + it('isEmojiUrl should correctly detect whether a URL is valid', async () => { + const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + + // Test for URL that is valid and matches the URL from UrlSync + const url1 = `${mockBackstageAssetsEndpoint}/emoji`; + expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(true); + + // Test for URL that is obviously not valid under any circumstances + const url2 = "I don't even know how you could get a URL like this"; + expect(result.current.renderHelpers.isEmojiUrl(url2)).toBe(false); + + // 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()); + 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 new file mode 100644 index 00000000..9ec95ff7 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -0,0 +1,52 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useApi } from '@backstage/core-plugin-api'; +import { + type UrlSyncSnapshot, + type UrlSync, + 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. + */ + renderHelpers: { + isEmojiUrl: (url: string) => boolean; + }; +}>; + +export function useUrlSync(): UseUrlSyncResult { + const urlSyncApi = useApi(urlSyncApiRef); + const state = useSyncExternalStore( + urlSyncApi.subscribe, + urlSyncApi.getCachedUrls, + ); + + 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 7de9929e..ec09da33 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -1,14 +1,30 @@ import { createPlugin, createComponentExtension, + createApiFactory, + discoveryApiRef, + configApiRef, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; +import { UrlSync, urlSyncApiRef } from './api/UrlSync'; export const coderPlugin = createPlugin({ id: 'coder', - routes: { - root: rootRouteRef, - }, + routes: { root: rootRouteRef }, + apis: [ + createApiFactory({ + api: urlSyncApiRef, + deps: { + discoveryApi: discoveryApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, configApi }) => { + return new UrlSync({ + apis: { discoveryApi, configApi }, + }); + }, + }), + ], }); /** diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 2e0fa6fe..fffd265c 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -1,5 +1,5 @@ /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */ -import { ConfigReader } from '@backstage/core-app-api'; +import { ConfigReader, FrontendHostDiscovery } from '@backstage/core-app-api'; import { MockConfigApi, MockErrorApi } from '@backstage/test-utils'; import type { ScmIntegrationRegistry } from '@backstage/integration'; /* eslint-enable @backstage/no-undeclared-imports */ @@ -14,10 +14,25 @@ import { CoderWorkspacesConfig, type YamlConfig, } from '../hooks/useCoderWorkspacesConfig'; -import { ScmIntegrationsApi } from '@backstage/integration-react'; - -import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; -import { IdentityApi } from '@backstage/core-plugin-api'; +import { + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; +import { + ApiRef, + DiscoveryApi, + IdentityApi, + configApiRef, + discoveryApiRef, + errorApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { + CODER_PROXY_PREFIX, + UrlSync, + defaultUrlPrefixes, + urlSyncApiRef, +} from '../api/UrlSync'; /** * This is the key that Backstage checks from the entity data to determine the @@ -51,12 +66,22 @@ export const rawRepoUrl = `${cleanedRepoUrl}/tree/main/`; export const mockBackstageUrlRoot = 'http://localhost:7007'; /** - * The actual endpoint to hit when trying to mock out a server request during - * testing. + * The API endpoint to use with the mock server during testing. + * + * The string literal expression is complicated, but hover over it to see what + * the final result is. */ -export const mockBackstageProxyEndpoint = `${mockBackstageUrlRoot}${API_ROUTE_PREFIX}`; +export const mockBackstageProxyEndpoint = + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; -export const mockBackstageAssetsEndpoint = `${mockBackstageUrlRoot}${ASSETS_ROUTE_PREFIX}`; +/** + * The assets endpoint to use during testing. + * + * The string literal expression is complicated, but hover over it to see what + * the final result is. + */ +export const mockBackstageAssetsEndpoint = + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.assetsRoutePrefix}` as const; export const mockBearerToken = 'This-is-an-opaque-value-by-design'; export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X'; @@ -246,3 +271,42 @@ export function getMockIdentityApi(): IdentityApi { export function getMockSourceControl(): ScmIntegrationRegistry { return ScmIntegrationsApi.fromConfig(new ConfigReader({})); } + +export function getMockDiscoveryApi(): DiscoveryApi { + return FrontendHostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: mockBackstageUrlRoot, + }, + }), + ); +} + +type ApiTuple = readonly [ApiRef>, NonNullable]; + +export function getMockApiList(): readonly ApiTuple[] { + const mockErrorApi = getMockErrorApi(); + const mockSourceControl = getMockSourceControl(); + const mockConfigApi = getMockConfigApi(); + const mockIdentityApi = getMockIdentityApi(); + const mockDiscoveryApi = getMockDiscoveryApi(); + + const mockUrlSyncApi = new UrlSync({ + apis: { + discoveryApi: mockDiscoveryApi, + configApi: mockConfigApi, + }, + }); + + return [ + // APIs that Backstage ships with normally + [errorApiRef, mockErrorApi], + [scmIntegrationsApiRef, mockSourceControl], + [configApiRef, mockConfigApi], + [identityApiRef, mockIdentityApi], + [discoveryApiRef, mockDiscoveryApi], + + // Custom APIs specific to the Coder plugin + [urlSyncApiRef, mockUrlSyncApi], + ]; +} diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 99db7c1b..71d21145 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -20,7 +20,7 @@ import { mockBackstageProxyEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; -import { CODER_AUTH_HEADER_KEY } from '../api'; +import { CODER_AUTH_HEADER_KEY } from '../api/api'; type RestResolver = ResponseResolver< RestRequest, @@ -33,6 +33,16 @@ export type RestResolverMiddleware = ( ) => RestResolver; const defaultMiddleware = [ + function validateCoderSessionToken(handler) { + return (req, res, ctx) => { + const token = req.headers.get(CODER_AUTH_HEADER_KEY); + if (token === mockCoderAuthToken) { + return handler(req, res, ctx); + } + + return res(ctx.status(401)); + }; + }, function validateBearerToken(handler) { return (req, res, ctx) => { const tokenRe = /^Bearer (.+)$/; @@ -104,13 +114,8 @@ const mainTestHandlers: readonly RestHandler[] = [ ), // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me`, (req, res, ctx) => { - const token = req.headers.get(CODER_AUTH_HEADER_KEY); - if (token === mockCoderAuthToken) { - return res(ctx.status(200)); - } - - return res(ctx.status(401)); + wrappedGet(`${root}/users/me`, (_, res, ctx) => { + return res(ctx.status(200)); }), ]; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 70afba5b..0cef032f 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -11,12 +11,6 @@ import { import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { - configApiRef, - errorApiRef, - identityApiRef, -} from '@backstage/core-plugin-api'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { type CoderAuth, @@ -27,14 +21,11 @@ import { CoderAppConfigProvider, } from '../components/CoderProvider'; import { - getMockSourceControl, mockAppConfig, mockEntity, - getMockErrorApi, - getMockConfigApi, mockAuthStates, BackstageEntity, - getMockIdentityApi, + getMockApiList, } from './mockBackstageData'; import { CoderErrorBoundary } from '../plugin'; @@ -161,24 +152,13 @@ export const renderHookAsCoderEntity = async < options?: RenderHookAsCoderEntityOptions, ): Promise> => { const { authStatus, ...delegatedOptions } = options ?? {}; - const mockErrorApi = getMockErrorApi(); - const mockSourceControl = getMockSourceControl(); - const mockConfigApi = getMockConfigApi(); - const mockIdentityApi = getMockIdentityApi(); const mockQueryClient = getMockQueryClient(); const renderHookValue = renderHook(hook, { ...delegatedOptions, wrapper: ({ children }) => { const mainMarkup = ( - + + ; +export type SubscriptionCallback = (value: T) => void; +export interface Subscribable { + subscribe: (callback: SubscriptionCallback) => () => void; + unsubscribe: (callback: SubscriptionCallback) => void; +} + +/** + * The prefix to use for all Backstage API refs created for the Coder plugin. + */ +export const CODER_API_REF_ID_PREFIX = 'backstage-plugin-coder'; + export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest'; export const workspaceAgentStatusSchema = union([ diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts index a109909d..1493c907 100644 --- a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts @@ -12,11 +12,11 @@ * into this class. It will then take care of notifying subscriptions, while * reconciling old/new snapshots to minimize needless re-renders. */ -import type { ReadonlyJsonValue } from '../typesConstants'; - -type SubscriptionCallback = ( - snapshot: TSnapshot, -) => void; +import type { + ReadonlyJsonValue, + SubscriptionCallback, + Subscribable, +} from '../typesConstants'; type DidSnapshotsChange = ( oldSnapshot: TSnapshot, @@ -33,12 +33,11 @@ type SnapshotManagerOptions = Readonly<{ didSnapshotsChange?: DidSnapshotsChange; }>; -interface SnapshotManagerApi { - subscribe: (callback: SubscriptionCallback) => () => void; - unsubscribe: (callback: SubscriptionCallback) => void; - getSnapshot: () => TSnapshot; - updateSnapshot: (newSnapshot: TSnapshot) => void; -} +type SnapshotManagerApi = + Subscribable & { + getSnapshot: () => TSnapshot; + updateSnapshot: (newSnapshot: TSnapshot) => void; + }; function areSameByReference(v1: unknown, v2: unknown) { // Comparison looks wonky, but Object.is handles more edge cases than === diff --git a/yarn.lock b/yarn.lock index a60186cb..b060021e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8713,7 +8713,7 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0": +"@types/react-dom@*", "@types/react-dom@^18.0.0": version "18.2.21" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== @@ -8751,7 +8751,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": +"@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0": version "18.2.64" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== @@ -8760,6 +8760,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^16.13.1 || ^17.0.0": + version "17.0.80" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.80.tgz#a5dfc351d6a41257eb592d73d3a85d3b7dbcbb41" + integrity sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "^0.16" + csstype "^3.0.2" + "@types/request@^2.47.1", "@types/request@^2.48.8": version "2.48.12" resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.12.tgz#0f590f615a10f87da18e9790ac94c29ec4c5ef30" @@ -8787,7 +8796,7 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*": +"@types/scheduler@*", "@types/scheduler@^0.16": version "0.16.8" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== @@ -21890,16 +21899,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21973,7 +21973,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -21987,13 +21987,6 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23233,6 +23226,11 @@ use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== +use-sync-external-store@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.1.tgz#8a64ce640415ae9944ec9e8336a8544bb77dcff2" + integrity sha512-6MCBDr76UJmRpbF8pzP27uIoTocf3tITaMJ52mccgAhMJycuh5A/RL6mDZCTwTisj0Qfeq69FtjMCUX27U78oA== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -23797,7 +23795,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23815,15 +23813,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"