From 67cb4b3d1bdb3dbba2368eda13d6a0dfac2b9114 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 13:43:01 +0000 Subject: [PATCH 01/31] wip: commit progress on UrlSync class/hook --- plugins/backstage-plugin-coder/package.json | 1 + .../backstage-plugin-coder/src/api/UrlSync.ts | 129 ++++++++++++++++++ .../CoderProvider/CoderAuthProvider.tsx | 4 +- .../WorkspacesListIcon.tsx | 6 +- .../src/hooks/useBackstageEndpoints.ts | 19 --- .../src/hooks/useCoderWorkspacesQuery.ts | 4 +- ...geEndpoints.test.ts => useUrlSync.test.ts} | 17 +-- .../src/hooks/useUrlSync.ts | 8 ++ plugins/backstage-plugin-coder/src/plugin.ts | 22 ++- .../src/typesConstants.ts | 11 ++ .../src/utils/StateSnapshotManager.ts | 21 ++- yarn.lock | 51 +++---- 12 files changed, 212 insertions(+), 81 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/UrlSync.ts delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts rename plugins/backstage-plugin-coder/src/hooks/{useBackstageEndpoints.test.ts => useUrlSync.test.ts} (50%) 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.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts new file mode 100644 index 00000000..8f62ff7b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -0,0 +1,129 @@ +/** + * @file This is basically a fancier version of Backstage's built-in + * DiscoveryApi that is designed to work much better with React. It helps with: + * + * 1. Making sure URLs are cached so that they can be accessed directly and + * synchronously from the UI + * 2. Making 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) + * 2. DiscoveryApi (offers async methods) + * + * Both of these work fine inside event handlers and effects, but are never safe + * to put directly inside render logic because they're non-deterministic and + * are not state-based. + */ +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 +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<{ + apiRoutePrefix: string; + assetsRoutePrefix: string; +}>; + +export const defaultUrlPrefixes = { + 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 proxyRouteExtractor = /^.+?\/proxy\/\w+$/; + +export class UrlSync implements Subscribable { + // ConfigApi is literally only used because it offers a synchronous way to + // get an initial URL to use from inside the constructor + private readonly configApi: ConfigApi; + private readonly discoveryApi: DiscoveryApi; + private readonly urlCache: StateSnapshotManager; + // private readonly proxyRoot: string; + + private urlPrefixes: UrlPrefixes; + + constructor(setup: ConstructorInputs) { + const { apis, urlPrefixes = {} } = setup; + const { discoveryApi, configApi } = apis; + + this.discoveryApi = discoveryApi; + this.configApi = configApi; + this.urlPrefixes = { ...defaultUrlPrefixes, ...urlPrefixes }; + + const initialUrl = this.configApi.getString(BASE_URL_KEY_FOR_CONFIG_API); + // const [, proxyRoot] = proxyRouteExtractor.exec(initialUrl) ?? []; + // this.proxyRoot = proxyRoot; + + this.urlCache = new StateSnapshotManager({ + initialSnapshot: this.prepareNewSnapshot(initialUrl), + }); + } + + private prepareNewSnapshot(newBaseUrl: string): UrlSyncSnapshot { + const { assetsRoutePrefix, apiRoutePrefix } = this.urlPrefixes; + + return { + baseUrl: newBaseUrl, + assetsRoute: `${newBaseUrl}${assetsRoutePrefix}`, + apiRoute: `${newBaseUrl}${apiRoutePrefix}`, + }; + } + + 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; + }; + + 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}.urlSync`, +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 8dd9a741..018565b8 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -18,7 +18,7 @@ import { authQueryKey, authValidation, } from '../../api'; -import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; +import { useUrlSync } from '../../hooks/useUrlSync'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -100,7 +100,7 @@ type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); + const { baseUrl } = useUrlSync(); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); // Need to split hairs, because the query object can be disabled. Only want to diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx index 23623a72..df712244 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,10 +56,10 @@ export const WorkspacesListIcon = ({ ...delegatedProps }: WorkspaceListIconProps) => { const [hasError, setHasError] = useState(false); - const { assetsProxyUrl } = useBackstageEndpoints(); + const { assetsRoute } = useUrlSync(); const styles = useStyles({ - isEmoji: src.startsWith(`${assetsProxyUrl}/emoji`), + isEmoji: src.startsWith(`${assetsRoute}/emoji`), }); return ( 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..0ce07755 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../api'; 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'; @@ -17,7 +17,7 @@ export function useCoderWorkspacesQuery({ }: QueryInput) { const auth = useCoderAuth(); const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); + const { baseUrl } = useUrlSync(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData diff --git a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.ts similarity index 50% rename from plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts rename to plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.ts index d245e5d3..1dba89b7 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.ts @@ -1,25 +1,22 @@ import { renderHookAsCoderEntity } from '../testHelpers/setup'; - -import { - UseBackstageEndpointResult, - useBackstageEndpoints, -} from './useBackstageEndpoints'; +import { useUrlSync } from './useUrlSync'; import { mockBackstageAssetsEndpoint, mockBackstageProxyEndpoint, mockBackstageUrlRoot, } from '../testHelpers/mockBackstageData'; +import { UrlSyncSnapshot } from '../api/UrlSync'; -describe(`${useBackstageEndpoints.name}`, () => { +describe(`${useUrlSync.name}`, () => { it('Should provide pre-formatted URLs for interacting with Backstage endpoints', async () => { - const { result } = await renderHookAsCoderEntity(useBackstageEndpoints); + const { result } = await renderHookAsCoderEntity(useUrlSync); expect(result.current).toEqual( - expect.objectContaining({ + expect.objectContaining({ baseUrl: mockBackstageUrlRoot, - assetsProxyUrl: mockBackstageAssetsEndpoint, - apiProxyUrl: mockBackstageProxyEndpoint, + assetsRoute: mockBackstageAssetsEndpoint, + apiRoute: mockBackstageProxyEndpoint, }), ); }); 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..2e2c13f2 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -0,0 +1,8 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { type UrlSyncSnapshot, urlSyncApiRef } from '../api/UrlSync'; +import { useApi } from '@backstage/core-plugin-api'; + +export function useUrlSync(): UrlSyncSnapshot { + const urlSyncApi = useApi(urlSyncApiRef); + return useSyncExternalStore(urlSyncApi.subscribe, urlSyncApi.getCachedUrls); +} 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/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index d4b613c7..b92d0cdb 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -17,6 +17,17 @@ export type ReadonlyJsonValue = | readonly ReadonlyJsonValue[] | Readonly<{ [key: string]: ReadonlyJsonValue }>; +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" From e53d12773a9ace14044f65ad4243a34a6281c1f2 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 13:56:55 +0000 Subject: [PATCH 02/31] refactor: consolidate emoji-testing logic --- .../CoderProvider/CoderAuthProvider.tsx | 2 +- .../WorkspacesListIcon.tsx | 7 ++---- .../src/hooks/useCoderWorkspacesQuery.ts | 2 +- .../src/hooks/useUrlSync.ts | 23 +++++++++++++++++-- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 018565b8..2747e2d5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -100,8 +100,8 @@ type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const identity = useApi(identityApiRef); - const { baseUrl } = useUrlSync(); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); + const { baseUrl } = useUrlSync().state; // 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 diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx index df712244..8044fd60 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx @@ -56,11 +56,8 @@ export const WorkspacesListIcon = ({ ...delegatedProps }: WorkspaceListIconProps) => { const [hasError, setHasError] = useState(false); - const { assetsRoute } = useUrlSync(); - - const styles = useStyles({ - isEmoji: src.startsWith(`${assetsRoute}/emoji`), - }); + const { uiHelpers } = useUrlSync(); + const styles = useStyles({ isEmoji: uiHelpers.isEmojiUrl(src) }); return (
boolean; + }; +}>; + +export function useUrlSync(): UseUrlSyncResult { const urlSyncApi = useApi(urlSyncApiRef); - return useSyncExternalStore(urlSyncApi.subscribe, urlSyncApi.getCachedUrls); + const state = useSyncExternalStore( + urlSyncApi.subscribe, + urlSyncApi.getCachedUrls, + ); + + return { + state, + uiHelpers: { + isEmojiUrl: url => { + return url.startsWith(`${state.assetsRoute}/emoji`); + }, + }, + }; } From f26184f1113707704cd2d2ad92a9272334c4fa2a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 14:17:57 +0000 Subject: [PATCH 03/31] docs: update comments for clarity --- plugins/backstage-plugin-coder/src/api/UrlSync.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index 8f62ff7b..194fb01e 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -10,12 +10,14 @@ * * As of April 2024, there are two main built-in ways of getting URLs from * Backstage config values: - * 1. ConfigApi (offers synchronous methods) - * 2. DiscoveryApi (offers async methods) + * 1. ConfigApi (offers synchronous methods, but does not have access to the + * proxy config) + * 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 because they're non-deterministic and - * are not state-based. + * 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, @@ -65,7 +67,8 @@ const proxyRouteExtractor = /^.+?\/proxy\/\w+$/; export class UrlSync implements Subscribable { // ConfigApi is literally only used because it offers a synchronous way to - // get an initial URL to use from inside the constructor + // get an initial URL to use from inside the constructor. Should not be used + // beyond initial constructor call private readonly configApi: ConfigApi; private readonly discoveryApi: DiscoveryApi; private readonly urlCache: StateSnapshotManager; From b54324bca276ad00c84776df8851cdaddc9d2ae0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 14:48:18 +0000 Subject: [PATCH 04/31] refactor: rename helpers to renderHelpers --- .../CoderWorkspacesCard/WorkspacesListIcon.tsx | 4 ++-- plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx index 8044fd60..079189a9 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx @@ -56,8 +56,8 @@ export const WorkspacesListIcon = ({ ...delegatedProps }: WorkspaceListIconProps) => { const [hasError, setHasError] = useState(false); - const { uiHelpers } = useUrlSync(); - const styles = useStyles({ isEmoji: uiHelpers.isEmojiUrl(src) }); + const { renderHelpers } = useUrlSync(); + const styles = useStyles({ isEmoji: renderHelpers.isEmojiUrl(src) }); return (
boolean; }; }>; @@ -18,7 +23,7 @@ export function useUrlSync(): UseUrlSyncResult { return { state, - uiHelpers: { + renderHelpers: { isEmojiUrl: url => { return url.startsWith(`${state.assetsRoute}/emoji`); }, From 7daf597ed03e37b65d100c734281436a54c24dab Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 15:07:10 +0000 Subject: [PATCH 05/31] wip: finish initial implementation of UrlSync --- .../backstage-plugin-coder/src/api/UrlSync.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index 194fb01e..a2581879 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -1,7 +1,9 @@ /** * @file This is basically a fancier version of Backstage's built-in - * DiscoveryApi that is designed to work much better with React. It helps with: + * DiscoveryApi that is designed to work much better with React. Its hook + * counterpart is useUrlSync. * + * The class helps with: * 1. Making sure URLs are cached so that they can be accessed directly and * synchronously from the UI * 2. Making sure that there are mechanisms for binding value changes to React @@ -32,17 +34,19 @@ import { import { StateSnapshotManager } from '../utils/StateSnapshotManager'; // This is the value we tell people to use inside app-config.yaml -const CODER_PROXY_PREFIX = '/coder'; +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; @@ -63,7 +67,7 @@ type ConstructorInputs = Readonly<{ }>; }>; -const proxyRouteExtractor = /^.+?\/proxy\/\w+$/; +const proxyRouteReplacer = /\/api\/proxy.*?$/; export class UrlSync implements Subscribable { // ConfigApi is literally only used because it offers a synchronous way to @@ -72,8 +76,6 @@ export class UrlSync implements Subscribable { private readonly configApi: ConfigApi; private readonly discoveryApi: DiscoveryApi; private readonly urlCache: StateSnapshotManager; - // private readonly proxyRoot: string; - private urlPrefixes: UrlPrefixes; constructor(setup: ConstructorInputs) { @@ -84,22 +86,24 @@ export class UrlSync implements Subscribable { this.configApi = configApi; this.urlPrefixes = { ...defaultUrlPrefixes, ...urlPrefixes }; - const initialUrl = this.configApi.getString(BASE_URL_KEY_FOR_CONFIG_API); - // const [, proxyRoot] = proxyRouteExtractor.exec(initialUrl) ?? []; - // this.proxyRoot = proxyRoot; - + const proxyRoot = this.getProxyRootFromConfigApi(); this.urlCache = new StateSnapshotManager({ - initialSnapshot: this.prepareNewSnapshot(initialUrl), + initialSnapshot: this.prepareNewSnapshot(proxyRoot), }); } - private prepareNewSnapshot(newBaseUrl: string): UrlSyncSnapshot { + private getProxyRootFromConfigApi(): string { + const baseUrl = this.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: newBaseUrl, - assetsRoute: `${newBaseUrl}${assetsRoutePrefix}`, - apiRoute: `${newBaseUrl}${apiRoutePrefix}`, + baseUrl: newProxyUrl.replace(proxyRouteReplacer, ''), + assetsRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${assetsRoutePrefix}`, + apiRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${apiRoutePrefix}`, }; } @@ -128,5 +132,5 @@ export class UrlSync implements Subscribable { } export const urlSyncApiRef = createApiRef({ - id: `${CODER_API_REF_ID_PREFIX}.urlSync`, + id: `${CODER_API_REF_ID_PREFIX}.url-sync`, }); From 86b5acc00bc2282108c77eb63736f15fe96b5235 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 15:51:22 +0000 Subject: [PATCH 06/31] chore: finish tests for UrlSync class --- .../src/api/UrlSync.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 plugins/backstage-plugin-coder/src/api/UrlSync.test.ts 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(); + }); +}); From 5d14d5a3b3e646cf921192eb1ddc696a9b2d0859 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 15:54:38 +0000 Subject: [PATCH 07/31] chore: add mock DiscoveryApi helper --- .../src/testHelpers/mockBackstageData.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 2e0fa6fe..51e675be 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 */ @@ -17,7 +17,7 @@ import { import { ScmIntegrationsApi } from '@backstage/integration-react'; import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; -import { IdentityApi } from '@backstage/core-plugin-api'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; /** * This is the key that Backstage checks from the entity data to determine the @@ -246,3 +246,13 @@ 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, + }, + }), + ); +} From afd0203e83be176bcad78f1a19ea15178596bd53 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 16:39:04 +0000 Subject: [PATCH 08/31] chore: finish tests for useUrlSync --- .../app/src/components/catalog/EntityPage.tsx | 2 +- .../backstage-plugin-coder/src/api/UrlSync.ts | 7 +- .../src/hooks/useUrlSync.test.ts | 23 ----- .../src/hooks/useUrlSync.test.tsx | 88 +++++++++++++++++++ .../src/testHelpers/mockBackstageData.ts | 45 +++++++++- .../src/testHelpers/setup.tsx | 14 +-- 6 files changed, 140 insertions(+), 39 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.ts create mode 100644 plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 6c4f9df1..80fc89f4 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -160,7 +160,7 @@ const overviewContent = ( - + diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index a2581879..9c4402ed 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -69,7 +69,12 @@ type ConstructorInputs = Readonly<{ const proxyRouteReplacer = /\/api\/proxy.*?$/; -export class UrlSync implements Subscribable { +interface UrlSyncApi extends Subscribable { + getApiEndpoint: () => Promise; + getCachedUrls: () => UrlSyncSnapshot; +} + +export class UrlSync implements UrlSyncApi { // 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 diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.ts deleted file mode 100644 index 1dba89b7..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { renderHookAsCoderEntity } from '../testHelpers/setup'; -import { useUrlSync } from './useUrlSync'; - -import { - mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, - mockBackstageUrlRoot, -} from '../testHelpers/mockBackstageData'; -import { UrlSyncSnapshot } from '../api/UrlSync'; - -describe(`${useUrlSync.name}`, () => { - it('Should provide pre-formatted URLs for interacting with Backstage endpoints', async () => { - const { result } = await renderHookAsCoderEntity(useUrlSync); - - expect(result.current).toEqual( - expect.objectContaining({ - baseUrl: mockBackstageUrlRoot, - assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageProxyEndpoint, - }), - ); - }); -}); 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..b0f193eb --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -0,0 +1,88 @@ +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 = 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: async (newEndpoint: string) => { + proxyEndpoint = newEndpoint; + return act(() => urlSync.getApiEndpoint()); + }, + }; +} + +describe(`${useUrlSync.name}`, () => { + 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; + + await updateMockProxyEndpoint('blah'); + 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 + await updateMockProxyEndpoint('http://zombo.com/api/proxy/coder'); + expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(false); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 51e675be..88f877f5 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -14,10 +14,22 @@ import { CoderWorkspacesConfig, type YamlConfig, } from '../hooks/useCoderWorkspacesConfig'; -import { ScmIntegrationsApi } from '@backstage/integration-react'; +import { + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; -import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + ApiRef, + DiscoveryApi, + IdentityApi, + configApiRef, + discoveryApiRef, + errorApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { UrlSync, urlSyncApiRef } from '../api/UrlSync'; /** * This is the key that Backstage checks from the entity data to determine the @@ -256,3 +268,32 @@ export function getMockDiscoveryApi(): DiscoveryApi { }), ); } + +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/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 70afba5b..eeedcd60 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -35,6 +35,7 @@ import { mockAuthStates, BackstageEntity, getMockIdentityApi, + getMockApiList, } from './mockBackstageData'; import { CoderErrorBoundary } from '../plugin'; @@ -161,24 +162,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 = ( - + Date: Fri, 26 Apr 2024 16:40:46 +0000 Subject: [PATCH 09/31] refactor: consolidate mock URL logic for useUrlSync --- .../backstage-plugin-coder/src/hooks/useUrlSync.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index b0f193eb..965297ef 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -42,6 +42,8 @@ function renderUseUrlSync() { } 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(); @@ -61,7 +63,7 @@ describe(`${useUrlSync.name}`, () => { const { result, updateMockProxyEndpoint } = renderUseUrlSync(); const initialState = result.current.state; - await updateMockProxyEndpoint('blah'); + await updateMockProxyEndpoint(altProxyUrl); const newState = result.current.state; expect(newState).not.toEqual(initialState); }); @@ -81,7 +83,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 - await updateMockProxyEndpoint('http://zombo.com/api/proxy/coder'); + await updateMockProxyEndpoint(altProxyUrl); expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(false); }); }); From d786e345d275c2a9f97147d2dca56c1a60b66609 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 16:43:11 +0000 Subject: [PATCH 10/31] fix: update test helper to use API list --- .../src/testHelpers/setup.tsx | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index eeedcd60..ba6baa99 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -202,27 +202,8 @@ export async function renderInCoderEnvironment({ queryClient = getMockQueryClient(), appConfig = mockAppConfig, }: RenderInCoderEnvironmentInputs) { - /** - * Tried really hard to get renderInTestApp to work, but I couldn't figure out - * how to get it set up with custom config values (mainly for testing the - * backend endpoints). - * - * Manually setting up the config API to get around that - */ - const mockErrorApi = getMockErrorApi(); - const mockSourceControl = getMockSourceControl(); - const mockConfigApi = getMockConfigApi(); - const mockIdentityApi = getMockIdentityApi(); - const mainMarkup = ( - + Date: Fri, 26 Apr 2024 16:46:15 +0000 Subject: [PATCH 11/31] fix: remove unneeded imports --- .../backstage-plugin-coder/src/testHelpers/setup.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index ba6baa99..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,10 @@ import { CoderAppConfigProvider, } from '../components/CoderProvider'; import { - getMockSourceControl, mockAppConfig, mockEntity, - getMockErrorApi, - getMockConfigApi, mockAuthStates, BackstageEntity, - getMockIdentityApi, getMockApiList, } from './mockBackstageData'; import { CoderErrorBoundary } from '../plugin'; From 1d1ab3c74f912c3898627b8c918bba83081c4f6e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 16:49:53 +0000 Subject: [PATCH 12/31] fix: get tests for all current code passing --- .../components/CoderProvider/CoderProvider.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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..11ba733a 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( From 58566c8e55784e399582606cc70a3ff261a3a8e2 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 16:50:08 +0000 Subject: [PATCH 13/31] fix: remove typo --- .../components/CoderProvider/CoderProvider.test.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 11ba733a..1b6b87da 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -98,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 }) => ( Date: Fri, 26 Apr 2024 16:53:51 +0000 Subject: [PATCH 14/31] fix: update useUrlSync to expose underlying api --- .../src/hooks/useUrlSync.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts index 555e642e..e122f101 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -1,8 +1,13 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { type UrlSyncSnapshot, urlSyncApiRef } from '../api/UrlSync'; import { useApi } from '@backstage/core-plugin-api'; +import { + type UrlSyncSnapshot, + type UrlSync, + urlSyncApiRef, +} from '../api/UrlSync'; export type UseUrlSyncResult = Readonly<{ + api: UrlSync; state: UrlSyncSnapshot; /** @@ -15,13 +20,11 @@ export type UseUrlSyncResult = Readonly<{ }>; export function useUrlSync(): UseUrlSyncResult { - const urlSyncApi = useApi(urlSyncApiRef); - const state = useSyncExternalStore( - urlSyncApi.subscribe, - urlSyncApi.getCachedUrls, - ); + const api = useApi(urlSyncApiRef); + const state = useSyncExternalStore(api.subscribe, api.getCachedUrls); return { + api, state, renderHelpers: { isEmojiUrl: url => { From abfd94953c3ec0fc4a49445fc781cc6fb4a94d0d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 16:56:12 +0000 Subject: [PATCH 15/31] refactor: increase data hiding for hook --- .../src/hooks/useUrlSync.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts index e122f101..3c7c2a38 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -7,8 +7,10 @@ import { } from '../api/UrlSync'; export type UseUrlSyncResult = Readonly<{ - api: UrlSync; state: UrlSyncSnapshot; + api: Readonly<{ + getApiEndpoint: UrlSync['getApiEndpoint']; + }>; /** * A collection of functions that can safely be called from within a React @@ -20,12 +22,17 @@ export type UseUrlSyncResult = Readonly<{ }>; export function useUrlSync(): UseUrlSyncResult { - const api = useApi(urlSyncApiRef); - const state = useSyncExternalStore(api.subscribe, api.getCachedUrls); + const urlSyncApi = useApi(urlSyncApiRef); + const state = useSyncExternalStore( + urlSyncApi.subscribe, + urlSyncApi.getCachedUrls, + ); return { - api, state, + api: { + getApiEndpoint: urlSyncApi.getApiEndpoint, + }, renderHelpers: { isEmojiUrl: url => { return url.startsWith(`${state.assetsRoute}/emoji`); From 26ae96d5e16b50b42236c73ad8c9614910057ce0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 17:08:35 +0000 Subject: [PATCH 16/31] fix: make useUrlSync tests less dependent on implementation details --- .../backstage-plugin-coder/src/hooks/useUrlSync.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index 965297ef..f0f2c492 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -34,9 +34,8 @@ function renderUseUrlSync() { return { ...renderResult, - updateMockProxyEndpoint: async (newEndpoint: string) => { + updateMockProxyEndpoint: (newEndpoint: string) => { proxyEndpoint = newEndpoint; - return act(() => urlSync.getApiEndpoint()); }, }; } @@ -63,7 +62,8 @@ describe(`${useUrlSync.name}`, () => { const { result, updateMockProxyEndpoint } = renderUseUrlSync(); const initialState = result.current.state; - await updateMockProxyEndpoint(altProxyUrl); + updateMockProxyEndpoint(altProxyUrl); + await act(() => result.current.api.getApiEndpoint()); const newState = result.current.state; expect(newState).not.toEqual(initialState); }); @@ -83,7 +83,8 @@ 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 - await updateMockProxyEndpoint(altProxyUrl); + updateMockProxyEndpoint(altProxyUrl); + await act(() => result.current.api.getApiEndpoint()); expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(false); }); }); From 5aabe86cd160018f450a34a093e299887cf03fd4 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 17:30:05 +0000 Subject: [PATCH 17/31] refactor: remove reliance on baseUrl argument for fetch calls --- plugins/backstage-plugin-coder/src/api.ts | 35 ++++++++++++------- .../backstage-plugin-coder/src/api/UrlSync.ts | 20 ++++++++--- .../src/hooks/useCoderWorkspacesQuery.ts | 10 +++--- .../src/hooks/useUrlSync.ts | 10 ++++++ 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index d11248eb..664dd1e1 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -10,6 +10,7 @@ import { } from './typesConstants'; import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider'; import { IdentityApi } from '@backstage/core-plugin-api'; +import { UrlSync } from './api/UrlSync'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; @@ -73,10 +74,15 @@ export class BackstageHttpError extends Error { } } +type TempPublicUrlSyncApi = Readonly<{ + getApiEndpoint: UrlSync['getApiEndpoint']; + getAssetsEndpoint: UrlSync['getAssetsEndpoint']; +}>; + type FetchInputs = Readonly<{ auth: CoderAuth; - baseUrl: string; - identity: IdentityApi; + identityApi: IdentityApi; + urlSyncApi: TempPublicUrlSyncApi; }>; type WorkspacesFetchInputs = Readonly< @@ -88,7 +94,7 @@ type WorkspacesFetchInputs = Readonly< 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 +102,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 +126,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 +135,7 @@ async function getWorkspaces( return { ...ws, - template_icon: `${baseUrl}${ASSETS_ROUTE_PREFIX}${templateIcon}`, + template_icon: `${assetsUrl}${templateIcon}`, }; }); @@ -141,12 +149,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, ); @@ -255,7 +264,7 @@ export function isWorkspaceOnline(workspace: Workspace): boolean { export function workspaces( inputs: WorkspacesFetchInputs, ): UseQueryOptions { - const enabled = inputs.auth.status === 'authenticated'; + const enabled = inputs.auth.isAuthenticated; return { queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery], @@ -268,8 +277,10 @@ export function workspaces( export function workspacesByRepo( inputs: WorkspacesByRepoFetchInputs, ): UseQueryOptions { - const enabled = - inputs.auth.status === 'authenticated' && inputs.coderQuery !== ''; + // 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 !== ''; return { queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery, 'repo'], diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index 9c4402ed..92e201fc 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -69,10 +69,12 @@ type ConstructorInputs = Readonly<{ const proxyRouteReplacer = /\/api\/proxy.*?$/; -interface UrlSyncApi extends Subscribable { - getApiEndpoint: () => Promise; - getCachedUrls: () => UrlSyncSnapshot; -} +type UrlSyncApi = Subscribable & + Readonly<{ + getApiEndpoint: () => Promise; + getAssetsEndpoint: () => Promise; + getCachedUrls: () => UrlSyncSnapshot; + }>; export class UrlSync implements UrlSyncApi { // ConfigApi is literally only used because it offers a synchronous way to @@ -122,6 +124,16 @@ export class UrlSync implements UrlSyncApi { 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(); }; diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 8ae53454..2b95cffb 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -16,19 +16,19 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useCoderAuth(); - const identity = useApi(identityApiRef); - const { baseUrl } = useUrlSync().state; + 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.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts index 3c7c2a38..9ec95ff7 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -8,8 +8,16 @@ import { 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']; }>; /** @@ -32,7 +40,9 @@ export function useUrlSync(): UseUrlSyncResult { state, api: { getApiEndpoint: urlSyncApi.getApiEndpoint, + getAssetsEndpoint: urlSyncApi.getAssetsEndpoint, }, + renderHelpers: { isEmojiUrl: url => { return url.startsWith(`${state.assetsRoute}/emoji`); From 425d50ff50be4863b50474767a7774910779d329 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 17:40:16 +0000 Subject: [PATCH 18/31] refactor: split Backstage error type into separate file --- plugins/backstage-plugin-coder/src/api.ts | 27 ++----------------- .../backstage-plugin-coder/src/api/errors.ts | 27 +++++++++++++++++++ .../CoderProvider/CoderAuthProvider.tsx | 7 +++-- .../CoderProvider/CoderProvider.tsx | 4 +-- 4 files changed, 34 insertions(+), 31 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/errors.ts diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index 664dd1e1..3b3151bc 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -9,7 +9,8 @@ import { WorkspaceAgentStatus, } from './typesConstants'; import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider'; -import { IdentityApi } from '@backstage/core-plugin-api'; +import type { IdentityApi } from '@backstage/core-plugin-api'; +import { BackstageHttpError } from './api/errors'; import { UrlSync } from './api/UrlSync'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; @@ -50,30 +51,6 @@ 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']; 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/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 2747e2d5..041e6a87 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -11,9 +11,8 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; - +import { BackstageHttpError } from '../../api/errors'; import { - BackstageHttpError, CODER_QUERY_KEY_PREFIX, authQueryKey, authValidation, @@ -158,7 +157,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) { @@ -240,7 +239,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.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index 4c8d0898..1b825404 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { CoderAuthProvider } from './CoderAuthProvider'; import { CoderAppConfigProvider } from './CoderAppConfigProvider'; import { CoderErrorBoundary } from '../CoderErrorBoundary'; -import { BackstageHttpError } from '../../api'; +import { BackstageHttpError } from '../../api/errors'; const MAX_FETCH_FAILURES = 3; @@ -15,7 +15,7 @@ export type CoderProviderProps = ComponentProps & const shouldRetryRequest = (failureCount: number, error: unknown): boolean => { const isBelowThreshold = failureCount < MAX_FETCH_FAILURES; - if (!(error instanceof BackstageHttpError)) { + if (!BackstageHttpError.isInstance(error)) { return isBelowThreshold; } From 04f0f3e4d60f68f6a31f871ef00224d4dd496caa Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 17:42:24 +0000 Subject: [PATCH 19/31] refactor: clean up imports for api file --- plugins/backstage-plugin-coder/src/api.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index 3b3151bc..6377ad75 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -1,17 +1,19 @@ 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 './api/errors'; +import type { UrlSync } from './api/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 type { IdentityApi } from '@backstage/core-plugin-api'; -import { BackstageHttpError } from './api/errors'; -import { UrlSync } from './api/UrlSync'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; From 111df43f5c606d578a63bdd0a3d7d9b41edc80e6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 18:11:41 +0000 Subject: [PATCH 20/31] refactor: split main query options into separate file --- plugins/backstage-plugin-coder/src/api.ts | 50 +---------- .../src/api/queryOptions.ts | 84 +++++++++++++++++++ .../src/hooks/useCoderWorkspacesQuery.ts | 2 +- 3 files changed, 87 insertions(+), 49 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/queryOptions.ts diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index 6377ad75..a3dac4b5 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -58,7 +58,7 @@ type TempPublicUrlSyncApi = Readonly<{ getAssetsEndpoint: UrlSync['getAssetsEndpoint']; }>; -type FetchInputs = Readonly<{ +export type FetchInputs = Readonly<{ auth: CoderAuth; identityApi: IdentityApi; urlSyncApi: TempPublicUrlSyncApi; @@ -70,7 +70,7 @@ type WorkspacesFetchInputs = Readonly< } >; -async function getWorkspaces( +export async function getWorkspaces( fetchInputs: WorkspacesFetchInputs, ): Promise { const { coderQuery, auth, identityApi, urlSyncApi } = fetchInputs; @@ -223,52 +223,6 @@ 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.isAuthenticated; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery], - queryFn: () => getWorkspaces(inputs), - enabled, - keepPreviousData: enabled && inputs.coderQuery !== '', - }; -} - -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 !== ''; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery, 'repo'], - queryFn: () => getWorkspacesByRepo(inputs), - enabled, - keepPreviousData: enabled, - }; -} - type AuthValidationInputs = Readonly<{ baseUrl: string; authToken: string; 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..de2df70d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -0,0 +1,84 @@ +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'; +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 !== ''; + + return { + queryKey: [ + ...getSharedWorkspacesQueryKey(inputs.coderQuery), + inputs.workspacesConfig, + ], + queryFn: () => getWorkspacesByRepo(inputs), + enabled, + keepPreviousData: enabled, + refetchInterval: getCoderWorkspacesRefetchInterval, + }; +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 2b95cffb..ea8405bd 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { workspaces, workspacesByRepo } from '../api'; +import { workspaces, workspacesByRepo } from '../api/queryOptions'; import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; import { useUrlSync } from './useUrlSync'; import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; From 1575f8e2c01980e89d390fae61ea1b534a1ca552 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 18:21:32 +0000 Subject: [PATCH 21/31] consolidate how mock endpoints are defined --- plugins/backstage-plugin-coder/src/api.ts | 1 - .../src/testHelpers/mockBackstageData.ts | 27 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index a3dac4b5..99c53a79 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -19,7 +19,6 @@ 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; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; export const REQUEST_TIMEOUT_MS = 20_000; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 88f877f5..fffd265c 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -18,8 +18,6 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, } from '@backstage/integration-react'; - -import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; import { ApiRef, DiscoveryApi, @@ -29,7 +27,12 @@ import { errorApiRef, identityApiRef, } from '@backstage/core-plugin-api'; -import { UrlSync, urlSyncApiRef } from '../api/UrlSync'; +import { + CODER_PROXY_PREFIX, + UrlSync, + defaultUrlPrefixes, + urlSyncApiRef, +} from '../api/UrlSync'; /** * This is the key that Backstage checks from the entity data to determine the @@ -63,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'; From dc6e75c2f8332768ebfb822f26afdfeb19493618 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 18:24:41 +0000 Subject: [PATCH 22/31] fix: remove base URL from auth calls --- plugins/backstage-plugin-coder/src/api.ts | 10 ++++------ .../src/components/CoderProvider/CoderAuthProvider.tsx | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index 99c53a79..3dfc2fab 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -223,21 +223,19 @@ export function getWorkspaceAgentStatuses( } type AuthValidationInputs = Readonly<{ - baseUrl: string; + urlSyncApi: TempPublicUrlSyncApi; authToken: string; identity: IdentityApi; }>; async function isAuthValid(inputs: AuthValidationInputs): Promise { - const { baseUrl, authToken, identity } = inputs; + const { urlSyncApi, 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, - ); + 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); diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 041e6a87..6ea98b00 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -100,7 +100,7 @@ type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const identity = useApi(identityApiRef); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - const { baseUrl } = useUrlSync().state; + 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,7 +109,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const [readonlyInitialAuthToken] = useState(authToken); const authValidityQuery = useQuery({ - ...authValidation({ baseUrl, authToken, identity }), + ...authValidation({ urlSyncApi, authToken, identity }), refetchOnWindowFocus: query => query.state.data !== false, }); From fd8b3cb4d7cf638e4ac98c467f191b442b988214 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 18:45:01 +0000 Subject: [PATCH 23/31] refactor: consolidate almost all auth logic into CoderAuthProvider --- plugins/backstage-plugin-coder/src/api.ts | 47 ++----------------- .../CoderProvider/CoderAuthProvider.tsx | 28 ++++++++--- 2 files changed, 27 insertions(+), 48 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index 3dfc2fab..f164f584 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -1,5 +1,4 @@ import { parse } from 'valibot'; -import { type UseQueryOptions } from '@tanstack/react-query'; import type { IdentityApi } from '@backstage/core-plugin-api'; import { BackstageHttpError } from './api/errors'; import type { UrlSync } from './api/UrlSync'; @@ -16,14 +15,14 @@ import { } from './typesConstants'; 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 CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; export const REQUEST_TIMEOUT_MS = 20_000; -async function getCoderApiRequestInit( +// Defined here and not in CoderAuthProvider.ts to avoid circular dependency +// issues +export const sharedAuthQueryKey = [CODER_QUERY_KEY_PREFIX, 'auth'] as const; + +export async function getCoderApiRequestInit( authToken: string, identity: IdentityApi, ): Promise { @@ -221,39 +220,3 @@ export function getWorkspaceAgentStatuses( return uniqueStatuses; } - -type AuthValidationInputs = Readonly<{ - urlSyncApi: TempPublicUrlSyncApi; - authToken: string; - identity: IdentityApi; -}>; - -async function isAuthValid(inputs: AuthValidationInputs): Promise { - const { urlSyncApi, 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 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; -} - -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/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 6ea98b00..ae16c11f 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -14,8 +14,8 @@ import { import { BackstageHttpError } from '../../api/errors'; import { CODER_QUERY_KEY_PREFIX, - authQueryKey, - authValidation, + getCoderApiRequestInit, + sharedAuthQueryKey, } from '../../api'; import { useUrlSync } from '../../hooks/useUrlSync'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; @@ -98,7 +98,7 @@ export function useCoderAuth(): CoderAuth { type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - const identity = useApi(identityApiRef); + const identityApi = useApi(identityApiRef); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); const { api: urlSyncApi } = useUrlSync(); @@ -108,9 +108,25 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const [authToken, setAuthToken] = useState(readAuthToken); const [readonlyInitialAuthToken] = useState(authToken); - const authValidityQuery = useQuery({ - ...authValidation({ urlSyncApi, 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({ @@ -165,7 +181,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { } isRefetchingTokenQuery = true; - await queryClient.refetchQueries({ queryKey: authQueryKey }); + await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); isRefetchingTokenQuery = false; }); From 5af006c7ec50818f411edac14df5da24060167f5 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 18:46:42 +0000 Subject: [PATCH 24/31] move api file into api directory --- plugins/backstage-plugin-coder/src/{ => api}/api.ts | 10 +++++----- plugins/backstage-plugin-coder/src/api/queryOptions.ts | 2 +- .../src/components/CoderProvider/CoderAuthProvider.tsx | 2 +- .../CoderWorkspacesCard/WorkspacesListItem.tsx | 2 +- .../backstage-plugin-coder/src/testHelpers/server.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename plugins/backstage-plugin-coder/src/{ => api}/api.ts (95%) diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api/api.ts similarity index 95% rename from plugins/backstage-plugin-coder/src/api.ts rename to plugins/backstage-plugin-coder/src/api/api.ts index f164f584..0a5a4039 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api/api.ts @@ -1,18 +1,18 @@ import { parse } from 'valibot'; import type { IdentityApi } from '@backstage/core-plugin-api'; -import { BackstageHttpError } from './api/errors'; -import type { UrlSync } from './api/UrlSync'; -import type { CoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; +import { BackstageHttpError } from './errors'; +import type { UrlSync } from './UrlSync'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import { type CoderAuth, assertValidCoderAuth, -} from './components/CoderProvider'; +} from '../components/CoderProvider'; import { type Workspace, type WorkspaceAgentStatus, workspaceBuildParametersSchema, workspacesResponseSchema, -} from './typesConstants'; +} from '../typesConstants'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index de2df70d..e2b6530b 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,7 +1,7 @@ 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'; +import { type FetchInputs, getWorkspaces, getWorkspacesByRepo } from './api'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; const PENDING_REFETCH_INTERVAL_MS = 5_000; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index ae16c11f..8e75890b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -16,7 +16,7 @@ import { CODER_QUERY_KEY_PREFIX, getCoderApiRequestInit, sharedAuthQueryKey, -} from '../../api'; +} from '../../api/api'; import { useUrlSync } from '../../hooks/useUrlSync'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index 86904329..26b68daf 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'; +import { getWorkspaceAgentStatuses } from '../../api/api'; import type { Workspace, WorkspaceStatus } from '../../typesConstants'; import { WorkspacesListIcon } from './WorkspacesListIcon'; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 99db7c1b..6f61b5de 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, From 644e632620aa090d28fc171a712ba3ef8e7a607b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 18:47:48 +0000 Subject: [PATCH 25/31] fix: revert prop that was changed for debugging --- packages/app/src/components/catalog/EntityPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 80fc89f4..98914288 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -160,7 +160,7 @@ const overviewContent = ( - + From a667b485f6c1078f30b1bad550d66a4b6fbad8fd Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 18:56:00 +0000 Subject: [PATCH 26/31] fix: revert prop definition --- packages/app/src/components/catalog/EntityPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 98914288..6c4f9df1 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -160,7 +160,7 @@ const overviewContent = ( - + From cca343ddab39a2f2fb2037e79fa1ab5f8dfaba8e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 19:06:22 +0000 Subject: [PATCH 27/31] refactor: extract token-checking logic into middleware for server --- .../src/hooks/useUrlSync.test.tsx | 2 +- .../src/testHelpers/server.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index f0f2c492..acc5b282 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -12,7 +12,7 @@ import { } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint = mockBackstageProxyEndpoint; + let proxyEndpoint: string = mockBackstageProxyEndpoint; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 6f61b5de..71d21145 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -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)); }), ]; From aac5a0caf1363a1ed0b6e52ea67e01bf8cec91ca Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 19:31:12 +0000 Subject: [PATCH 28/31] refactor: move shared auth key to queryOptions file --- plugins/backstage-plugin-coder/src/api/api.ts | 5 ----- plugins/backstage-plugin-coder/src/api/queryOptions.ts | 5 +++++ .../src/components/CoderProvider/CoderAuthProvider.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/api.ts b/plugins/backstage-plugin-coder/src/api/api.ts index 0a5a4039..ac083724 100644 --- a/plugins/backstage-plugin-coder/src/api/api.ts +++ b/plugins/backstage-plugin-coder/src/api/api.ts @@ -14,14 +14,9 @@ import { workspacesResponseSchema, } from '../typesConstants'; -export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; export const REQUEST_TIMEOUT_MS = 20_000; -// Defined here and not in CoderAuthProvider.ts to avoid circular dependency -// issues -export const sharedAuthQueryKey = [CODER_QUERY_KEY_PREFIX, 'auth'] as const; - export async function getCoderApiRequestInit( authToken: string, identity: IdentityApi, diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index e2b6530b..82ba9ee5 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -4,6 +4,11 @@ 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; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 8e75890b..745e6dc2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -12,11 +12,11 @@ import { useQueryClient, } from '@tanstack/react-query'; import { BackstageHttpError } from '../../api/errors'; +import { getCoderApiRequestInit } from '../../api/api'; import { CODER_QUERY_KEY_PREFIX, - getCoderApiRequestInit, sharedAuthQueryKey, -} from '../../api/api'; +} from '../../api/queryOptions'; import { useUrlSync } from '../../hooks/useUrlSync'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; From 32fb344ffcda6a3fe1a79dc392c6eac65bd79fe1 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 19:59:24 +0000 Subject: [PATCH 29/31] docs: add reminder about arrow functions --- plugins/backstage-plugin-coder/src/api/UrlSync.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index 92e201fc..f6d5acec 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -114,6 +114,11 @@ export class UrlSync implements UrlSyncApi { }; } + /* *************************************************************************** + * 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, From e370197b80bb310827c8a92bd6006bae553d980a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 30 Apr 2024 14:49:16 +0000 Subject: [PATCH 30/31] fix: remove configApi from embedded class properties --- .../backstage-plugin-coder/src/api/UrlSync.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index f6d5acec..ae05294b 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -3,17 +3,18 @@ * DiscoveryApi that is designed to work much better with React. Its hook * counterpart is useUrlSync. * - * The class helps with: - * 1. Making sure URLs are cached so that they can be accessed directly and + * 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. Making sure that there are mechanisms for binding value changes to React + * 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 access to the - * proxy config) + * 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 @@ -77,10 +78,6 @@ type UrlSyncApi = Subscribable & }>; export class UrlSync implements UrlSyncApi { - // 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 - private readonly configApi: ConfigApi; private readonly discoveryApi: DiscoveryApi; private readonly urlCache: StateSnapshotManager; private urlPrefixes: UrlPrefixes; @@ -90,17 +87,19 @@ export class UrlSync implements UrlSyncApi { const { discoveryApi, configApi } = apis; this.discoveryApi = discoveryApi; - this.configApi = configApi; this.urlPrefixes = { ...defaultUrlPrefixes, ...urlPrefixes }; - const proxyRoot = this.getProxyRootFromConfigApi(); + const proxyRoot = this.getProxyRootFromConfigApi(configApi); this.urlCache = new StateSnapshotManager({ initialSnapshot: this.prepareNewSnapshot(proxyRoot), }); } - private getProxyRootFromConfigApi(): string { - const baseUrl = this.configApi.getString(BASE_URL_KEY_FOR_CONFIG_API); + // 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}`; } From 9a359dcc5c535850254dc76862368a65352a18d7 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 30 Apr 2024 16:04:16 +0000 Subject: [PATCH 31/31] fix: update query logic to remove any whitespace --- plugins/backstage-plugin-coder/src/api/queryOptions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index 82ba9ee5..a6507790 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -74,7 +74,8 @@ export function workspacesByRepo( // 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 !== ''; + const enabled = + inputs.auth.isAuthenticated && inputs.coderQuery.trim() !== ''; return { queryKey: [