From 67cb4b3d1bdb3dbba2368eda13d6a0dfac2b9114 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 26 Apr 2024 13:43:01 +0000 Subject: [PATCH 01/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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 170d4517bda3ec4658e9f4e9d4b71ed703ea69e7 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 19:50:08 +0000 Subject: [PATCH 30/64] wip: add initial versions of CoderClient code --- plugins/backstage-plugin-coder/package.json | 1 + .../src/api/CoderClient.ts | 323 ++++++++++++++++++ .../src/hooks/useCoderSdk.ts | 7 + plugins/backstage-plugin-coder/src/plugin.ts | 14 + yarn.lock | 14 + 5 files changed, 359 insertions(+) create mode 100644 plugins/backstage-plugin-coder/src/api/CoderClient.ts create mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 548df083..6dcc24a8 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -41,6 +41,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", + "axios": "^1.6.8", "use-sync-external-store": "^1.2.1", "valibot": "^0.28.1" }, diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts new file mode 100644 index 00000000..2f21dd07 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -0,0 +1,323 @@ +import globalAxios, { + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios'; +import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; +import { + type Workspace, + type WorkspaceBuildParameter, + type WorkspacesResponse, + CODER_API_REF_ID_PREFIX, +} from '../typesConstants'; +import type { UrlSync } from './UrlSync'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; + +const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; +const REQUEST_TIMEOUT_MS = 20_000; + +/** + * This is a temporary (and significantly limited) implementation of the "Coder + * SDK" type that will eventually be imported from Coder core + * + * @todo Replace this with a full, proper implementation, and then expose it to + * plugin users. + */ +type CoderSdk = Readonly<{ + getUserLoginType: () => Promise; + getWorkspaces: (options: WorkspacesRequest) => Promise; + getWorkspaceBuildParameters: ( + workspaceBuildId: string, + ) => Promise; +}>; + +type WorkspacesRequest = Readonly<{ + after_id?: string; + limit?: number; + offset?: number; + q?: string; +}>; + +// Return value used for the dummy requests used to verify a user's auth status +// for the Coder token auth logic +type UserLoginType = Readonly<{ + login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +}>; + +/** + * A version of the main Coder SDK API, with additional Backstage-specific + * methods and properties. + */ +export type BackstageCoderSdk = Readonly< + CoderSdk & { + getWorkspacesByRepo: ( + coderQuery: string, + config: CoderWorkspacesConfig, + ) => Promise; + } +>; + +type SetupAxiosResult = Readonly<{ + axios: AxiosInstance; + ejectId: number; +}>; + +type CoderClientApi = Readonly<{ + sdk: BackstageCoderSdk; + + /** + * Validates a new token, and loads it only if it is valid. + * Return value indicates whether the token is valid. + */ + syncToken: (newToken: string) => Promise; + + /** + * Cleans up a client instance, removing its links to all external systems. + */ + cleanupClient: () => void; +}>; + +type ConstructorInputs = Readonly<{ + apis: Readonly<{ + urlSync: UrlSync; + identityApi: IdentityApi; + }>; +}>; + +export class CoderClient implements CoderClientApi { + private readonly urlSync: UrlSync; + private readonly identityApi: IdentityApi; + + private readonly axios: AxiosInstance; + private readonly axiosCleanupId: number; + private readonly cleanupController: AbortController; + + private disabled: boolean; + private loadedSessionToken: string | undefined; + readonly sdk: BackstageCoderSdk; + + constructor(inputs: ConstructorInputs) { + const { apis } = inputs; + const { urlSync, identityApi } = apis; + + this.urlSync = urlSync; + this.identityApi = identityApi; + this.loadedSessionToken = undefined; + this.disabled = false; + + const { axios, ejectId } = this.setupAxiosInstance(); + this.axios = axios; + this.axiosCleanupId = ejectId; + + this.sdk = this.getBackstageCoderSdk(axios); + this.cleanupController = new AbortController(); + } + + private setupAxiosInstance(): SetupAxiosResult { + const axios = globalAxios.create(); + + // Configs exist on a per-request basis; mutating the config for a new + // request won't mutate any configs for requests that are currently pending + const interceptAxiosRequest = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + if (this.disabled) { + throw new Error( + 'Received new request for client that has already been cleaned up', + ); + } + + // Front-load the setup steps that rely on external APIs, so that if any + // fail, the request bails out early + const proxyApiEndpoint = await this.urlSync.getApiEndpoint(); + const bearerToken = (await this.identityApi.getCredentials()).token; + + config.baseURL = proxyApiEndpoint; + config.signal = this.getTimeoutAbortSignal(); + config.headers[CODER_AUTH_HEADER_KEY] = this.loadedSessionToken; + + if (bearerToken) { + config.headers.Authorization = `Bearer ${bearerToken}`; + } + + return config; + }; + + const interceptAxiosError = (error: unknown): unknown => { + const errorIsFromCleanup = error instanceof DOMException; + if (errorIsFromCleanup) { + return undefined; + } + + return error; + }; + + const ejectId = axios.interceptors.request.use( + interceptAxiosRequest, + interceptAxiosError, + ); + + return { axios, ejectId }; + } + + private getBackstageCoderSdk( + axiosInstance: AxiosInstance, + ): BackstageCoderSdk { + // Defining all the SDK functions here instead of in the class as private + // methods to limit the amount of noise you get from intellisense + const getWorkspaces = async ( + request: WorkspacesRequest, + ): Promise => { + const urlParams = new URLSearchParams({ + q: request.q ?? '', + limit: String(request.limit || 0), + }); + + const { data } = await axiosInstance.get( + `/workspaces?${urlParams.toString()}`, + ); + + const remapped = await this.remapWorkspaceIconUrls(data.workspaces); + return { + count: data.count, + workspaces: remapped as Workspace[], + }; + }; + + const getWorkspaceBuildParameters = async ( + workspaceBuildId: string, + ): Promise => { + const response = await axiosInstance.get< + readonly WorkspaceBuildParameter[] + >(`/workspacebuilds/${workspaceBuildId}/parameters`); + + return response.data; + }; + + const getUserLoginType = async (): Promise => { + const response = await axiosInstance.get( + '/users/me/login-type', + ); + + return response.data; + }; + + const getWorkspacesByRepo = async ( + coderQuery: string, + config: CoderWorkspacesConfig, + ): Promise => { + const { workspaces } = await getWorkspaces({ + q: coderQuery, + limit: 0, + }); + + const paramResults = await Promise.allSettled( + workspaces.map(ws => + this.sdk.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); + + const matchedWorkspaces: Workspace[] = []; + for (const [index, res] of paramResults.entries()) { + if (res.status === 'rejected') { + continue; + } + + for (const param of res.value) { + const include = + config.repoUrlParamKeys.includes(param.name) && + param.value === config.repoUrl; + + if (include) { + // Doing type assertion just in case noUncheckedIndexedAccess + // compiler setting ever gets turned on; this shouldn't ever break, + // but it's technically not type-safe + matchedWorkspaces.push(workspaces[index] as Workspace); + break; + } + } + } + + return matchedWorkspaces; + }; + + return { + getWorkspaces, + getWorkspaceBuildParameters, + getUserLoginType, + getWorkspacesByRepo, + }; + } + + /** + * Creates a combined abort signal that will abort when the client is cleaned + * up, but also enforces request timeouts + */ + private getTimeoutAbortSignal(): AbortSignal { + // AbortSignal.any would do exactly what we need to, but it's too new for + // certain browsers to be reliable. Have to wire everything up manually + const timeoutController = new AbortController(); + window.setTimeout(() => { + const reason = new DOMException('Signal timed out', 'TimeoutException'); + timeoutController.abort(reason); + }, REQUEST_TIMEOUT_MS); + + const cleanupSignal = this.cleanupController.signal; + cleanupSignal.addEventListener('abort', () => { + timeoutController.abort(cleanupSignal.reason); + }); + + return timeoutController.signal; + } + + private async remapWorkspaceIconUrls( + workspaces: readonly Workspace[], + ): Promise { + const assetsRoute = await this.urlSync.getAssetsEndpoint(); + + return workspaces.map(ws => { + const templateIconUrl = ws.template_icon; + if (!templateIconUrl.startsWith('/')) { + return ws; + } + + return { + ...ws, + template_icon: `${assetsRoute}${templateIconUrl}`, + }; + }); + } + + syncToken = async (newToken: string): Promise => { + // This is very silly, but just to ensure that the config options for the + // token validation request can't conflict with the Axios instance made + // during instantiation, we're making a brand-new SDK + Axios instance just + // for the lifecycle of this method + const { axios: tempAxiosInstance } = this.setupAxiosInstance(); + tempAxiosInstance.interceptors.request.use(config => { + config.headers[CODER_AUTH_HEADER_KEY] = newToken; + return config; + }); + + try { + // Actual request type doesn't matter; just need to make some kind of + // dummy request. Should favor requests that all users have access to and + // that don't require request bodies + const sdkForToken = this.getBackstageCoderSdk(tempAxiosInstance); + await sdkForToken.getUserLoginType(); + this.loadedSessionToken = newToken; + return true; + } catch { + return false; + } + }; + + cleanupClient = (): void => { + this.disabled = true; + this.axios.interceptors.request.eject(this.axiosCleanupId); + this.cleanupController.abort(); + }; +} + +export const coderClientApiRef = createApiRef({ + id: `${CODER_API_REF_ID_PREFIX}.coder-client`, +}); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts new file mode 100644 index 00000000..8fbec12c --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts @@ -0,0 +1,7 @@ +import { useApi } from '@backstage/core-plugin-api'; +import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; + +export function useCoderSdk(): BackstageCoderSdk { + const coderClient = useApi(coderClientApiRef); + return coderClient.sdk; +} diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index ec09da33..5dad65dc 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -4,9 +4,11 @@ import { createApiFactory, discoveryApiRef, configApiRef, + identityApiRef, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; import { UrlSync, urlSyncApiRef } from './api/UrlSync'; +import { CoderClient, coderClientApiRef } from './api/CoderClient'; export const coderPlugin = createPlugin({ id: 'coder', @@ -24,6 +26,18 @@ export const coderPlugin = createPlugin({ }); }, }), + createApiFactory({ + api: coderClientApiRef, + deps: { + urlSync: urlSyncApiRef, + identityApi: identityApiRef, + }, + factory: ({ urlSync, identityApi }) => { + return new CoderClient({ + apis: { urlSync, identityApi }, + }); + }, + }), ], }); diff --git a/yarn.lock b/yarn.lock index b060021e..d1df1176 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9960,6 +9960,15 @@ axios@^1.0.0, axios@^1.4.0, axios@^1.6.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" + integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -13528,6 +13537,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" From 9f548a30e2df231d9e3891ba5a643fce765c8de5 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 20:24:16 +0000 Subject: [PATCH 31/64] wip: delete entire api.ts file --- .../src/api/CoderClient.ts | 10 +- plugins/backstage-plugin-coder/src/api/api.ts | 217 ------------------ .../src/api/queryOptions.ts | 59 +++-- .../CoderProvider/CoderAuthProvider.tsx | 26 +-- .../WorkspacesListItem.tsx | 2 +- .../src/hooks/useCoderWorkspacesQuery.ts | 21 +- .../src/testHelpers/server.ts | 2 +- .../src/utils/workspaces.ts | 22 ++ 8 files changed, 76 insertions(+), 283 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/src/api/api.ts create mode 100644 plugins/backstage-plugin-coder/src/utils/workspaces.ts diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 2f21dd07..599c2665 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -12,7 +12,7 @@ import { import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; +export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; const REQUEST_TIMEOUT_MS = 20_000; /** @@ -88,7 +88,7 @@ export class CoderClient implements CoderClientApi { private readonly identityApi: IdentityApi; private readonly axios: AxiosInstance; - private readonly axiosCleanupId: number; + private readonly axiosEjectId: number; private readonly cleanupController: AbortController; private disabled: boolean; @@ -106,7 +106,7 @@ export class CoderClient implements CoderClientApi { const { axios, ejectId } = this.setupAxiosInstance(); this.axios = axios; - this.axiosCleanupId = ejectId; + this.axiosEjectId = ejectId; this.sdk = this.getBackstageCoderSdk(axios); this.cleanupController = new AbortController(); @@ -170,6 +170,8 @@ export class CoderClient implements CoderClientApi { const urlParams = new URLSearchParams({ q: request.q ?? '', limit: String(request.limit || 0), + after_id: request.after_id ?? '', + offset: String(request.offset || 0), }); const { data } = await axiosInstance.get( @@ -313,7 +315,7 @@ export class CoderClient implements CoderClientApi { cleanupClient = (): void => { this.disabled = true; - this.axios.interceptors.request.eject(this.axiosCleanupId); + this.axios.interceptors.request.eject(this.axiosEjectId); this.cleanupController.abort(); }; } diff --git a/plugins/backstage-plugin-coder/src/api/api.ts b/plugins/backstage-plugin-coder/src/api/api.ts deleted file mode 100644 index ac083724..00000000 --- a/plugins/backstage-plugin-coder/src/api/api.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { parse } from 'valibot'; -import type { IdentityApi } from '@backstage/core-plugin-api'; -import { BackstageHttpError } from './errors'; -import type { UrlSync } from './UrlSync'; -import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { - type CoderAuth, - assertValidCoderAuth, -} from '../components/CoderProvider'; -import { - type Workspace, - type WorkspaceAgentStatus, - workspaceBuildParametersSchema, - workspacesResponseSchema, -} from '../typesConstants'; - -export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; -export const REQUEST_TIMEOUT_MS = 20_000; - -export async function getCoderApiRequestInit( - authToken: string, - identity: IdentityApi, -): Promise { - const headers: HeadersInit = { - [CODER_AUTH_HEADER_KEY]: authToken, - }; - - try { - const credentials = await identity.getCredentials(); - if (credentials.token) { - headers.Authorization = `Bearer ${credentials.token}`; - } - } catch (err) { - if (err instanceof Error) { - throw err; - } - - throw new Error( - "Unable to parse user information for Coder requests. Please ensure that your Backstage deployment is integrated to use Backstage's Identity API", - ); - } - - return { - headers, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }; -} - -type TempPublicUrlSyncApi = Readonly<{ - getApiEndpoint: UrlSync['getApiEndpoint']; - getAssetsEndpoint: UrlSync['getAssetsEndpoint']; -}>; - -export type FetchInputs = Readonly<{ - auth: CoderAuth; - identityApi: IdentityApi; - urlSyncApi: TempPublicUrlSyncApi; -}>; - -type WorkspacesFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; - } ->; - -export async function getWorkspaces( - fetchInputs: WorkspacesFetchInputs, -): Promise { - const { coderQuery, auth, identityApi, urlSyncApi } = fetchInputs; - assertValidCoderAuth(auth); - - const urlParams = new URLSearchParams({ - q: coderQuery, - limit: '0', - }); - - const requestInit = await getCoderApiRequestInit(auth.token, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const response = await fetch( - `${apiEndpoint}/workspaces?${urlParams.toString()}`, - requestInit, - ); - - if (!response.ok) { - throw new BackstageHttpError( - `Unable to retrieve workspaces for query (${coderQuery})`, - response, - ); - } - - if (!response.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - response, - ); - } - - const json = await response.json(); - const { workspaces } = parse(workspacesResponseSchema, json); - - const assetsUrl = await urlSyncApi.getAssetsEndpoint(); - const withRemappedImgUrls = workspaces.map(ws => { - const templateIcon = ws.template_icon; - if (!templateIcon.startsWith('/')) { - return ws; - } - - return { - ...ws, - template_icon: `${assetsUrl}${templateIcon}`, - }; - }); - - return withRemappedImgUrls; -} - -type BuildParamsFetchInputs = Readonly< - FetchInputs & { - workspaceBuildId: string; - } ->; - -async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { - const { urlSyncApi, auth, workspaceBuildId, identityApi } = inputs; - assertValidCoderAuth(auth); - - const requestInit = await getCoderApiRequestInit(auth.token, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const res = await fetch( - `${apiEndpoint}/workspacebuilds/${workspaceBuildId}/parameters`, - requestInit, - ); - - if (!res.ok) { - throw new BackstageHttpError( - `Failed to retreive build params for workspace ID ${workspaceBuildId}`, - res, - ); - } - - if (!res.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - res, - ); - } - - const json = await res.json(); - return parse(workspaceBuildParametersSchema, json); -} - -type WorkspacesByRepoFetchInputs = Readonly< - WorkspacesFetchInputs & { - workspacesConfig: CoderWorkspacesConfig; - } ->; - -export async function getWorkspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): Promise { - const workspaces = await getWorkspaces(inputs); - - const paramResults = await Promise.allSettled( - workspaces.map(ws => - getWorkspaceBuildParameters({ - ...inputs, - workspaceBuildId: ws.latest_build.id, - }), - ), - ); - - const { workspacesConfig } = inputs; - const matchedWorkspaces: Workspace[] = []; - - for (const [index, res] of paramResults.entries()) { - if (res.status === 'rejected') { - continue; - } - - for (const param of res.value) { - const include = - workspacesConfig.repoUrlParamKeys.includes(param.name) && - param.value === workspacesConfig.repoUrl; - - if (include) { - // Doing type assertion just in case noUncheckedIndexedAccess compiler - // setting ever gets turned on; this shouldn't ever break, but it's - // technically not type-safe - matchedWorkspaces.push(workspaces[index] as Workspace); - break; - } - } - } - - return matchedWorkspaces; -} - -export function getWorkspaceAgentStatuses( - workspace: Workspace, -): readonly WorkspaceAgentStatus[] { - const uniqueStatuses: WorkspaceAgentStatus[] = []; - - for (const resource of workspace.latest_build.resources) { - if (resource.agents === undefined) { - continue; - } - - for (const agent of resource.agents) { - const status = agent.status; - if (!uniqueStatuses.includes(status)) { - uniqueStatuses.push(status); - } - } - } - - return uniqueStatuses; -} diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index 82ba9ee5..1459e8db 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,7 +1,8 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import type { Workspace } from '../typesConstants'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { type FetchInputs, getWorkspaces, getWorkspacesByRepo } from './api'; +import type { BackstageCoderSdk } from './CoderClient'; +import type { CoderAuth } from '../components/CoderProvider'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; @@ -41,47 +42,55 @@ function getSharedWorkspacesQueryKey(coderQuery: string) { return [CODER_QUERY_KEY_PREFIX, 'workspaces', coderQuery] as const; } -type WorkspacesFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; - } ->; +type WorkspacesFetchInputs = Readonly<{ + auth: CoderAuth; + coderSdk: BackstageCoderSdk; + coderQuery: string; +}>; -export function workspaces( - inputs: WorkspacesFetchInputs, -): UseQueryOptions { - const enabled = inputs.auth.isAuthenticated; +export function workspaces({ + auth, + coderSdk, + coderQuery, +}: WorkspacesFetchInputs): UseQueryOptions { + const enabled = auth.isAuthenticated; return { - queryKey: getSharedWorkspacesQueryKey(inputs.coderQuery), - queryFn: () => getWorkspaces(inputs), + queryKey: getSharedWorkspacesQueryKey(coderQuery), enabled, - keepPreviousData: enabled && inputs.coderQuery !== '', + keepPreviousData: enabled && coderQuery !== '', refetchInterval: getCoderWorkspacesRefetchInterval, + queryFn: async () => { + const res = await coderSdk.getWorkspaces({ + q: coderQuery, + limit: 0, + }); + + return res.workspaces; + }, }; } type WorkspacesByRepoFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; + WorkspacesFetchInputs & { workspacesConfig: CoderWorkspacesConfig; } >; -export function workspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): UseQueryOptions { - // Disabling query object when there is no query text for performance reasons; +export function workspacesByRepo({ + coderQuery, + coderSdk, + auth, + workspacesConfig, +}: WorkspacesByRepoFetchInputs): UseQueryOptions { + // Disabling query when there is no query text for performance reasons; // searching through every workspace with an empty string can be incredibly // slow. - const enabled = inputs.auth.isAuthenticated && inputs.coderQuery !== ''; + const enabled = auth.isAuthenticated && coderQuery !== ''; return { - queryKey: [ - ...getSharedWorkspacesQueryKey(inputs.coderQuery), - inputs.workspacesConfig, - ], - queryFn: () => getWorkspacesByRepo(inputs), + queryKey: [...getSharedWorkspacesQueryKey(coderQuery), workspacesConfig], + queryFn: () => coderSdk.getWorkspacesByRepo(coderQuery, workspacesConfig), enabled, keepPreviousData: enabled, refetchInterval: getCoderWorkspacesRefetchInterval, diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 745e6dc2..852abce1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -12,13 +12,12 @@ import { useQueryClient, } from '@tanstack/react-query'; import { BackstageHttpError } from '../../api/errors'; -import { getCoderApiRequestInit } from '../../api/api'; import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; -import { useUrlSync } from '../../hooks/useUrlSync'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { coderClientApiRef } from '../../api/CoderClient'; +import { useApi } from '@backstage/core-plugin-api'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -98,35 +97,22 @@ export function useCoderAuth(): CoderAuth { type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - const identityApi = useApi(identityApiRef); - const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - const { api: urlSyncApi } = useUrlSync(); - // Need to split hairs, because the query object can be disabled. Only want to // expose the initializing state if the app mounts with a token already in // localStorage const [authToken, setAuthToken] = useState(readAuthToken); const [readonlyInitialAuthToken] = useState(authToken); + const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); + const coderClient = useApi(coderClientApiRef); const queryIsEnabled = authToken !== ''; + const authValidityQuery = useQuery({ queryKey: [...sharedAuthQueryKey, authToken], + queryFn: () => coderClient.syncToken(authToken), enabled: queryIsEnabled, keepPreviousData: queryIsEnabled, refetchOnWindowFocus: query => query.state.data !== false, - queryFn: async () => { - // In this case, the request doesn't actually matter. Just need to make any - // kind of dummy request to validate the auth - const requestInit = await getCoderApiRequestInit(authToken, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const response = await fetch(`${apiEndpoint}/users/me`, requestInit); - - if (response.status >= 400 && response.status !== 401) { - throw new BackstageHttpError('Failed to complete request', response); - } - - return response.status !== 401; - }, }); const authState = generateAuthState({ diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index 26b68daf..f7292e51 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -9,7 +9,7 @@ import { type Theme, makeStyles } from '@material-ui/core'; import { useId } from '../../hooks/hookPolyfills'; import { useCoderAppConfig } from '../CoderProvider'; -import { getWorkspaceAgentStatuses } from '../../api/api'; +import { getWorkspaceAgentStatuses } from '../../utils/workspaces'; import type { Workspace, WorkspaceStatus } from '../../typesConstants'; import { WorkspacesListIcon } from './WorkspacesListIcon'; diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index ea8405bd..a3b22d3d 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -1,10 +1,8 @@ import { useQuery } from '@tanstack/react-query'; - import { workspaces, workspacesByRepo } from '../api/queryOptions'; -import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; -import { useUrlSync } from './useUrlSync'; -import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; +import { useCoderSdk } from './useCoderSdk'; +import { useCoderAuth } from '../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; @@ -16,19 +14,12 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useCoderAuth(); - const identityApi = useApi(identityApiRef); - const { api: urlSyncApi } = useUrlSync(); + const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ - coderQuery, - auth, - identityApi, - urlSyncApi, - workspacesConfig, - }) - : workspaces({ coderQuery, auth, identityApi, urlSyncApi }); + ? workspacesByRepo({ auth, coderSdk, coderQuery, workspacesConfig }) + : workspaces({ auth, coderSdk, coderQuery }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 71d21145..9e5bf855 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/api'; +import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; type RestResolver = ResponseResolver< RestRequest, diff --git a/plugins/backstage-plugin-coder/src/utils/workspaces.ts b/plugins/backstage-plugin-coder/src/utils/workspaces.ts new file mode 100644 index 00000000..c36b6d4b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/workspaces.ts @@ -0,0 +1,22 @@ +import type { Workspace, WorkspaceAgentStatus } from '../typesConstants'; + +export function getWorkspaceAgentStatuses( + workspace: Workspace, +): readonly WorkspaceAgentStatus[] { + const uniqueStatuses: WorkspaceAgentStatus[] = []; + + for (const resource of workspace.latest_build.resources) { + if (resource.agents === undefined) { + continue; + } + + for (const agent of resource.agents) { + const status = agent.status; + if (!uniqueStatuses.includes(status)) { + uniqueStatuses.push(status); + } + } + } + + return uniqueStatuses; +} From 340399eba378175c966a0ccb5444ae5a6435ec89 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 20:29:20 +0000 Subject: [PATCH 32/64] fix: remove temp api escape hatch for useUrlSync --- .../src/hooks/useUrlSync.test.tsx | 9 ++++---- .../src/hooks/useUrlSync.ts | 22 +------------------ 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index acc5b282..2769e2d8 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -34,6 +34,7 @@ function renderUseUrlSync() { return { ...renderResult, + urlSync, updateMockProxyEndpoint: (newEndpoint: string) => { proxyEndpoint = newEndpoint; }, @@ -59,11 +60,11 @@ describe(`${useUrlSync.name}`, () => { }); it('Should re-render when URLs change via the UrlSync class', async () => { - const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const { result, urlSync, updateMockProxyEndpoint } = renderUseUrlSync(); const initialState = result.current.state; updateMockProxyEndpoint(altProxyUrl); - await act(() => result.current.api.getApiEndpoint()); + await act(() => urlSync.getApiEndpoint()); const newState = result.current.state; expect(newState).not.toEqual(initialState); }); @@ -71,7 +72,7 @@ describe(`${useUrlSync.name}`, () => { describe('Render helpers', () => { it('isEmojiUrl should correctly detect whether a URL is valid', async () => { - const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const { result, urlSync, updateMockProxyEndpoint } = renderUseUrlSync(); // Test for URL that is valid and matches the URL from UrlSync const url1 = `${mockBackstageAssetsEndpoint}/emoji`; @@ -84,7 +85,7 @@ describe(`${useUrlSync.name}`, () => { // Test for URL that was valid when the React app started up, but then // UrlSync started giving out a completely different URL updateMockProxyEndpoint(altProxyUrl); - await act(() => result.current.api.getApiEndpoint()); + await act(() => urlSync.getApiEndpoint()); expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(false); }); }); diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts index 9ec95ff7..d51fb097 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -1,25 +1,10 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useApi } from '@backstage/core-plugin-api'; -import { - type UrlSyncSnapshot, - type UrlSync, - urlSyncApiRef, -} from '../api/UrlSync'; +import { type UrlSyncSnapshot, urlSyncApiRef } from '../api/UrlSync'; export type UseUrlSyncResult = Readonly<{ state: UrlSyncSnapshot; - /** - * @todo This is a temporary property that is being used until the - * CoderClientApi is created, and can consume the UrlSync class directly. - * - * Delete this entire property once the new class is ready. - */ - api: Readonly<{ - getApiEndpoint: UrlSync['getApiEndpoint']; - getAssetsEndpoint: UrlSync['getAssetsEndpoint']; - }>; - /** * A collection of functions that can safely be called from within a React * component's render logic to get derived values. @@ -38,11 +23,6 @@ export function useUrlSync(): UseUrlSyncResult { return { state, - api: { - getApiEndpoint: urlSyncApi.getApiEndpoint, - getAssetsEndpoint: urlSyncApi.getAssetsEndpoint, - }, - renderHelpers: { isEmojiUrl: url => { return url.startsWith(`${state.assetsRoute}/emoji`); From 5e6d81225037ab207848e7f2265f4191b951f666 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 22:45:23 +0000 Subject: [PATCH 33/64] chore: update syncToken logic to use temporary interceptors --- .../src/api/CoderClient.ts | 68 +++++++++++-------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 599c2665..f0b48541 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -56,11 +56,6 @@ export type BackstageCoderSdk = Readonly< } >; -type SetupAxiosResult = Readonly<{ - axios: AxiosInstance; - ejectId: number; -}>; - type CoderClientApi = Readonly<{ sdk: BackstageCoderSdk; @@ -86,12 +81,10 @@ type ConstructorInputs = Readonly<{ export class CoderClient implements CoderClientApi { private readonly urlSync: UrlSync; private readonly identityApi: IdentityApi; - private readonly axios: AxiosInstance; - private readonly axiosEjectId: number; private readonly cleanupController: AbortController; - private disabled: boolean; + private axiosEjectId: number; private loadedSessionToken: string | undefined; readonly sdk: BackstageCoderSdk; @@ -102,9 +95,9 @@ export class CoderClient implements CoderClientApi { this.urlSync = urlSync; this.identityApi = identityApi; this.loadedSessionToken = undefined; - this.disabled = false; - const { axios, ejectId } = this.setupAxiosInstance(); + const axios = globalAxios.create(); + const ejectId = this.addInterceptors(axios); this.axios = axios; this.axiosEjectId = ejectId; @@ -112,20 +105,12 @@ export class CoderClient implements CoderClientApi { this.cleanupController = new AbortController(); } - private setupAxiosInstance(): SetupAxiosResult { - const axios = globalAxios.create(); - + private addInterceptors(axios: AxiosInstance): number { // Configs exist on a per-request basis; mutating the config for a new // request won't mutate any configs for requests that are currently pending const interceptAxiosRequest = async ( config: InternalAxiosRequestConfig, ): Promise => { - if (this.disabled) { - throw new Error( - 'Received new request for client that has already been cleaned up', - ); - } - // Front-load the setup steps that rely on external APIs, so that if any // fail, the request bails out early const proxyApiEndpoint = await this.urlSync.getApiEndpoint(); @@ -156,7 +141,7 @@ export class CoderClient implements CoderClientApi { interceptAxiosError, ); - return { axios, ejectId }; + return ejectId; } private getBackstageCoderSdk( @@ -290,12 +275,36 @@ export class CoderClient implements CoderClientApi { } syncToken = async (newToken: string): Promise => { - // This is very silly, but just to ensure that the config options for the - // token validation request can't conflict with the Axios instance made - // during instantiation, we're making a brand-new SDK + Axios instance just - // for the lifecycle of this method - const { axios: tempAxiosInstance } = this.setupAxiosInstance(); - tempAxiosInstance.interceptors.request.use(config => { + /** + * This logic requires a long explanation if you aren't familiar with + * the intricacies of JavaScript. Tried other options, but this seemed like + * the best approach. + * + * 1. interceptors.request.use will synchronously add a new interceptor + * function to the axios instance. Axios interceptors are always applied + * in the order they're added; there is no easy way to add a new + * interceptor that will run before what's already been added + * 2. When we make the request in syncToken, we will pause the thread of + * execution when we hit the await keyword. This means that while this + * function call is paused, the interceptor will apply to every single + * request until the syncToken request comes back + * 3. Because of how React Query background re-fetches work, there might be + * other requests that were already queued before syncToken got called, + * and that will go through the new interceptor in the meantime + * 4. As long as the new token is valid, those requests shouldn't notice any + * difference, but if the new token is invalid, they will start failing + * 5. The interceptor doesn't get removed until the syncToken request + * finishes (whether it succeeds or not) + * 6. Thanks to closure, the value of newToken is made available to all + * requests indirectly, so there also isn't a good way to uniquely + * identify the syncToken request. + * + * Tried to figure out a way to make it so that all requests other than the + * syncToken request would be disabled. But the only surefire way to ensure + * no collisions was making a new Axios instance + Coder SDK instance just + * for the lifetime of the syncToken request, which seemed excessive + */ + const ejectValidationId = this.axios.interceptors.request.use(config => { config.headers[CODER_AUTH_HEADER_KEY] = newToken; return config; }); @@ -304,17 +313,18 @@ export class CoderClient implements CoderClientApi { // Actual request type doesn't matter; just need to make some kind of // dummy request. Should favor requests that all users have access to and // that don't require request bodies - const sdkForToken = this.getBackstageCoderSdk(tempAxiosInstance); - await sdkForToken.getUserLoginType(); + await this.sdk.getUserLoginType(); this.loadedSessionToken = newToken; return true; } catch { return false; + } finally { + // Finally blocks always execute even after a value is returned + this.axios.interceptors.request.eject(ejectValidationId); } }; cleanupClient = (): void => { - this.disabled = true; this.axios.interceptors.request.eject(this.axiosEjectId); this.cleanupController.abort(); }; From b8affbd78a65152dd903e99fcef0a02d2e4dd3dc Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 22:46:47 +0000 Subject: [PATCH 34/64] refactor: update variable name for clarity --- plugins/backstage-plugin-coder/src/api/CoderClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index f0b48541..e7fd2247 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -304,7 +304,7 @@ export class CoderClient implements CoderClientApi { * no collisions was making a new Axios instance + Coder SDK instance just * for the lifetime of the syncToken request, which seemed excessive */ - const ejectValidationId = this.axios.interceptors.request.use(config => { + const validationId = this.axios.interceptors.request.use(config => { config.headers[CODER_AUTH_HEADER_KEY] = newToken; return config; }); @@ -320,7 +320,7 @@ export class CoderClient implements CoderClientApi { return false; } finally { // Finally blocks always execute even after a value is returned - this.axios.interceptors.request.eject(ejectValidationId); + this.axios.interceptors.request.eject(validationId); } }; From 0a306f5c21f24f4a94f4788567e386cd06d125bd Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 22:56:36 +0000 Subject: [PATCH 35/64] fix: prevent double-cancellation of timeout signals --- .../backstage-plugin-coder/src/api/CoderClient.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index e7fd2247..e88c4805 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -243,13 +243,25 @@ export class CoderClient implements CoderClientApi { // AbortSignal.any would do exactly what we need to, but it's too new for // certain browsers to be reliable. Have to wire everything up manually const timeoutController = new AbortController(); + let aborted = false; + window.setTimeout(() => { + if (aborted) { + return; + } + + aborted = true; const reason = new DOMException('Signal timed out', 'TimeoutException'); timeoutController.abort(reason); }, REQUEST_TIMEOUT_MS); const cleanupSignal = this.cleanupController.signal; cleanupSignal.addEventListener('abort', () => { + if (aborted) { + return; + } + + aborted = true; timeoutController.abort(cleanupSignal.reason); }); From 24f1c29729d797135f56ce370951fa368efa6201 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 23:00:38 +0000 Subject: [PATCH 36/64] fix: cleanup timeout logic --- .../src/api/CoderClient.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index e88c4805..fed31fe1 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -243,27 +243,24 @@ export class CoderClient implements CoderClientApi { // AbortSignal.any would do exactly what we need to, but it's too new for // certain browsers to be reliable. Have to wire everything up manually const timeoutController = new AbortController(); - let aborted = false; - window.setTimeout(() => { - if (aborted) { - return; - } - - aborted = true; + const timeoutId = window.setTimeout(() => { const reason = new DOMException('Signal timed out', 'TimeoutException'); timeoutController.abort(reason); }, REQUEST_TIMEOUT_MS); const cleanupSignal = this.cleanupController.signal; - cleanupSignal.addEventListener('abort', () => { - if (aborted) { - return; - } - - aborted = true; - timeoutController.abort(cleanupSignal.reason); - }); + cleanupSignal.addEventListener( + 'abort', + () => { + window.clearTimeout(timeoutId); + timeoutController.abort(cleanupSignal.reason); + }, + + // Attaching the timeoutController signal here makes it so that if the + // timeout resolves, this event listener will automatically be removed + { signal: timeoutController.signal }, + ); return timeoutController.signal; } From e2e20345687407e080478f049762477ae08316aa Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 28 Apr 2024 23:46:19 +0000 Subject: [PATCH 37/64] refactor: split pseudo-SDK into separate file --- .../src/api/CoderClient.ts | 105 ++++-------------- .../src/api/MockCoderSdk.ts | 76 +++++++++++++ 2 files changed, 97 insertions(+), 84 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index fed31fe1..d28ae63c 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -3,46 +3,14 @@ import globalAxios, { type InternalAxiosRequestConfig, } from 'axios'; import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; -import { - type Workspace, - type WorkspaceBuildParameter, - type WorkspacesResponse, - CODER_API_REF_ID_PREFIX, -} from '../typesConstants'; +import { type Workspace, CODER_API_REF_ID_PREFIX } from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; +import { CoderSdk } from './MockCoderSdk'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; const REQUEST_TIMEOUT_MS = 20_000; -/** - * This is a temporary (and significantly limited) implementation of the "Coder - * SDK" type that will eventually be imported from Coder core - * - * @todo Replace this with a full, proper implementation, and then expose it to - * plugin users. - */ -type CoderSdk = Readonly<{ - getUserLoginType: () => Promise; - getWorkspaces: (options: WorkspacesRequest) => Promise; - getWorkspaceBuildParameters: ( - workspaceBuildId: string, - ) => Promise; -}>; - -type WorkspacesRequest = Readonly<{ - after_id?: string; - limit?: number; - offset?: number; - q?: string; -}>; - -// Return value used for the dummy requests used to verify a user's auth status -// for the Coder token auth logic -type UserLoginType = Readonly<{ - login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; -}>; - /** * A version of the main Coder SDK API, with additional Backstage-specific * methods and properties. @@ -95,14 +63,11 @@ export class CoderClient implements CoderClientApi { this.urlSync = urlSync; this.identityApi = identityApi; this.loadedSessionToken = undefined; - - const axios = globalAxios.create(); - const ejectId = this.addInterceptors(axios); - this.axios = axios; - this.axiosEjectId = ejectId; - - this.sdk = this.getBackstageCoderSdk(axios); this.cleanupController = new AbortController(); + + this.axios = globalAxios.create(); + this.axiosEjectId = this.addInterceptors(this.axios); + this.sdk = this.getBackstageCoderSdk(this.axios); } private addInterceptors(axios: AxiosInstance): number { @@ -147,52 +112,26 @@ export class CoderClient implements CoderClientApi { private getBackstageCoderSdk( axiosInstance: AxiosInstance, ): BackstageCoderSdk { - // Defining all the SDK functions here instead of in the class as private - // methods to limit the amount of noise you get from intellisense - const getWorkspaces = async ( - request: WorkspacesRequest, - ): Promise => { - const urlParams = new URLSearchParams({ - q: request.q ?? '', - limit: String(request.limit || 0), - after_id: request.after_id ?? '', - offset: String(request.offset || 0), - }); + const baseSdk = new CoderSdk(axiosInstance); - const { data } = await axiosInstance.get( - `/workspaces?${urlParams.toString()}`, + const originalGetWorkspaces = baseSdk.getWorkspaces; + baseSdk.getWorkspaces = async request => { + const workspacesRes = await originalGetWorkspaces(request); + const remapped = await this.remapWorkspaceIconUrls( + workspacesRes.workspaces, ); - const remapped = await this.remapWorkspaceIconUrls(data.workspaces); return { - count: data.count, - workspaces: remapped as Workspace[], + count: remapped.length, + workspaces: remapped, }; }; - const getWorkspaceBuildParameters = async ( - workspaceBuildId: string, - ): Promise => { - const response = await axiosInstance.get< - readonly WorkspaceBuildParameter[] - >(`/workspacebuilds/${workspaceBuildId}/parameters`); - - return response.data; - }; - - const getUserLoginType = async (): Promise => { - const response = await axiosInstance.get( - '/users/me/login-type', - ); - - return response.data; - }; - const getWorkspacesByRepo = async ( coderQuery: string, config: CoderWorkspacesConfig, ): Promise => { - const { workspaces } = await getWorkspaces({ + const { workspaces } = await baseSdk.getWorkspaces({ q: coderQuery, limit: 0, }); @@ -228,16 +167,14 @@ export class CoderClient implements CoderClientApi { }; return { - getWorkspaces, - getWorkspaceBuildParameters, - getUserLoginType, + ...baseSdk, getWorkspacesByRepo, }; } /** * Creates a combined abort signal that will abort when the client is cleaned - * up, but also enforces request timeouts + * up, but will also enforce request timeouts */ private getTimeoutAbortSignal(): AbortSignal { // AbortSignal.any would do exactly what we need to, but it's too new for @@ -267,7 +204,7 @@ export class CoderClient implements CoderClientApi { private async remapWorkspaceIconUrls( workspaces: readonly Workspace[], - ): Promise { + ): Promise { const assetsRoute = await this.urlSync.getAssetsEndpoint(); return workspaces.map(ws => { @@ -286,8 +223,8 @@ export class CoderClient implements CoderClientApi { syncToken = async (newToken: string): Promise => { /** * This logic requires a long explanation if you aren't familiar with - * the intricacies of JavaScript. Tried other options, but this seemed like - * the best approach. + * the intricacies of JavaScript. Tried other options, but they were all + * somehow even worse/more convoluted * * 1. interceptors.request.use will synchronously add a new interceptor * function to the axios instance. Axios interceptors are always applied @@ -328,7 +265,7 @@ export class CoderClient implements CoderClientApi { } catch { return false; } finally { - // Finally blocks always execute even after a value is returned + // Logic in a finally block always executes even after a value is returned this.axios.interceptors.request.eject(validationId); } }; diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts new file mode 100644 index 00000000..d7751b8c --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts @@ -0,0 +1,76 @@ +/** + * @file This is a temporary (and significantly limited) implementation of the + * "Coder SDK" that will eventually be imported from Coder core + * + * @todo Replace this with a full, proper implementation, and then expose it to + * plugin users. + */ +import globalAxios, { type AxiosInstance } from 'axios'; +import { + type WorkspaceBuildParameter, + type WorkspacesResponse, +} from '../typesConstants'; + +type WorkspacesRequest = Readonly<{ + after_id?: string; + limit?: number; + offset?: number; + q?: string; +}>; + +// Return value used for the dummy requests used to verify a user's auth status +// for the Coder token auth logic +type UserLoginType = Readonly<{ + login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +}>; + +type CoderSdkApi = { + getUserLoginType: () => Promise; + getWorkspaces: (options: WorkspacesRequest) => Promise; + getWorkspaceBuildParameters: ( + workspaceBuildId: string, + ) => Promise; +}; + +export class CoderSdk implements CoderSdkApi { + private readonly axios: AxiosInstance; + + constructor(axiosInstance?: AxiosInstance) { + this.axios = axiosInstance ?? globalAxios.create(); + } + + getWorkspaces = async ( + request: WorkspacesRequest, + ): Promise => { + const urlParams = new URLSearchParams({ + q: request.q ?? '', + limit: String(request.limit || 0), + after_id: request.after_id ?? '', + offset: String(request.offset || 0), + }); + + const response = await this.axios.get( + `/workspaces?${urlParams.toString()}`, + ); + + return response.data; + }; + + getWorkspaceBuildParameters = async ( + workspaceBuildId: string, + ): Promise => { + const response = await this.axios.get( + `/workspacebuilds/${workspaceBuildId}/parameters`, + ); + + return response.data; + }; + + getUserLoginType = async (): Promise => { + const response = await this.axios.get( + '/users/me/login-type', + ); + + return response.data; + }; +} From 09b0c48c538bd4e0d31710f3a5524aaccd01c581 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 13:25:39 +0000 Subject: [PATCH 38/64] fix: resolve issue with conflicting interceptors --- .../src/api/CoderClient.ts | 108 ++++++++++-------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index d28ae63c..5bb895bb 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -1,6 +1,6 @@ import globalAxios, { type AxiosInstance, - type InternalAxiosRequestConfig, + type InternalAxiosRequestConfig as RequestConfig, } from 'axios'; import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; import { type Workspace, CODER_API_REF_ID_PREFIX } from '../typesConstants'; @@ -52,7 +52,7 @@ export class CoderClient implements CoderClientApi { private readonly axios: AxiosInstance; private readonly cleanupController: AbortController; - private axiosEjectId: number; + private trackedEjectionIds: Set; private loadedSessionToken: string | undefined; readonly sdk: BackstageCoderSdk; @@ -64,26 +64,62 @@ export class CoderClient implements CoderClientApi { this.identityApi = identityApi; this.loadedSessionToken = undefined; this.cleanupController = new AbortController(); + this.trackedEjectionIds = new Set(); this.axios = globalAxios.create(); - this.axiosEjectId = this.addInterceptors(this.axios); this.sdk = this.getBackstageCoderSdk(this.axios); + this.addBaseRequestInterceptors(); } - private addInterceptors(axios: AxiosInstance): number { + private addRequestInterceptor( + requestInterceptor: ( + config: RequestConfig, + ) => RequestConfig | Promise, + errorInterceptor?: (error: unknown) => unknown, + ): number { + const ejectionId = this.axios.interceptors.request.use( + requestInterceptor, + errorInterceptor, + ); + + this.trackedEjectionIds.add(ejectionId); + return ejectionId; + } + + private removeRequestInterceptorById(ejectionId: number): boolean { + const sizeBeforeRemoval = this.trackedEjectionIds.size; + + this.axios.interceptors.request.eject(ejectionId); + if (this.trackedEjectionIds.has(ejectionId)) { + this.trackedEjectionIds.delete(ejectionId); + } + + return sizeBeforeRemoval !== this.trackedEjectionIds.size; + } + + private addBaseRequestInterceptors(): void { // Configs exist on a per-request basis; mutating the config for a new // request won't mutate any configs for requests that are currently pending - const interceptAxiosRequest = async ( - config: InternalAxiosRequestConfig, - ): Promise => { + const baseRequestInterceptor = async ( + config: RequestConfig, + ): Promise => { // Front-load the setup steps that rely on external APIs, so that if any - // fail, the request bails out early + // fail, the request bails out early before modifying the config const proxyApiEndpoint = await this.urlSync.getApiEndpoint(); const bearerToken = (await this.identityApi.getCredentials()).token; config.baseURL = proxyApiEndpoint; config.signal = this.getTimeoutAbortSignal(); - config.headers[CODER_AUTH_HEADER_KEY] = this.loadedSessionToken; + + // The Axios docs have incredibly confusing wording about how multiple + // interceptors work. They say the interceptors are "run in the order + // added", implying that the first interceptor you add will always run + // first. That is not true - they're run in reverse order, so the newer + // interceptors will always run before anything else. Only add token from + // this base interceptor if a newer interceptor hasn't already added one + if (config.headers[CODER_AUTH_HEADER_KEY] === undefined) { + config.headers[CODER_AUTH_HEADER_KEY] = this.loadedSessionToken; + } if (bearerToken) { config.headers.Authorization = `Bearer ${bearerToken}`; @@ -92,7 +128,7 @@ export class CoderClient implements CoderClientApi { return config; }; - const interceptAxiosError = (error: unknown): unknown => { + const baseErrorInterceptor = (error: unknown): unknown => { const errorIsFromCleanup = error instanceof DOMException; if (errorIsFromCleanup) { return undefined; @@ -101,12 +137,7 @@ export class CoderClient implements CoderClientApi { return error; }; - const ejectId = axios.interceptors.request.use( - interceptAxiosRequest, - interceptAxiosError, - ); - - return ejectId; + this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); } private getBackstageCoderSdk( @@ -221,36 +252,13 @@ export class CoderClient implements CoderClientApi { } syncToken = async (newToken: string): Promise => { - /** - * This logic requires a long explanation if you aren't familiar with - * the intricacies of JavaScript. Tried other options, but they were all - * somehow even worse/more convoluted - * - * 1. interceptors.request.use will synchronously add a new interceptor - * function to the axios instance. Axios interceptors are always applied - * in the order they're added; there is no easy way to add a new - * interceptor that will run before what's already been added - * 2. When we make the request in syncToken, we will pause the thread of - * execution when we hit the await keyword. This means that while this - * function call is paused, the interceptor will apply to every single - * request until the syncToken request comes back - * 3. Because of how React Query background re-fetches work, there might be - * other requests that were already queued before syncToken got called, - * and that will go through the new interceptor in the meantime - * 4. As long as the new token is valid, those requests shouldn't notice any - * difference, but if the new token is invalid, they will start failing - * 5. The interceptor doesn't get removed until the syncToken request - * finishes (whether it succeeds or not) - * 6. Thanks to closure, the value of newToken is made available to all - * requests indirectly, so there also isn't a good way to uniquely - * identify the syncToken request. - * - * Tried to figure out a way to make it so that all requests other than the - * syncToken request would be disabled. But the only surefire way to ensure - * no collisions was making a new Axios instance + Coder SDK instance just - * for the lifetime of the syncToken request, which seemed excessive - */ - const validationId = this.axios.interceptors.request.use(config => { + // Because this newly-added interceptor will run before any other + // interceptors, you could make it so that the syncToken request will + // disable all other requests while validating. Chose not to do that because + // of React Query background re-fetches. As long as the new token is valid, + // they won't notice any difference at all, even though the token will have + // suddenly changed out from under them + const validationId = this.addRequestInterceptor(config => { config.headers[CODER_AUTH_HEADER_KEY] = newToken; return config; }); @@ -266,12 +274,16 @@ export class CoderClient implements CoderClientApi { return false; } finally { // Logic in a finally block always executes even after a value is returned - this.axios.interceptors.request.eject(validationId); + this.removeRequestInterceptorById(validationId); } }; cleanupClient = (): void => { - this.axios.interceptors.request.eject(this.axiosEjectId); + this.trackedEjectionIds.forEach(id => { + this.axios.interceptors.request.eject(id); + }); + + this.trackedEjectionIds.clear(); this.cleanupController.abort(); }; } From 7b534ccd4980b9062ef6076158373fd612a94bc9 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 13:33:44 +0000 Subject: [PATCH 39/64] chore: improve cleanup logic --- .../src/api/CoderClient.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 5bb895bb..bcf87acb 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -39,6 +39,11 @@ type CoderClientApi = Readonly<{ cleanupClient: () => void; }>; +const sharedCleanupAbortReason = new DOMException( + 'Coder Client instance has been manually cleaned up', + 'AbortError', +); + type ConstructorInputs = Readonly<{ apis: Readonly<{ urlSync: UrlSync; @@ -129,7 +134,13 @@ export class CoderClient implements CoderClientApi { }; const baseErrorInterceptor = (error: unknown): unknown => { - const errorIsFromCleanup = error instanceof DOMException; + const errorIsFromCleanup = + error instanceof DOMException && + error.name === sharedCleanupAbortReason.name && + error.message === sharedCleanupAbortReason.message; + + // Manually aborting a request is always treated as an error, even if we + // 100% expect it. Just scrub the error if it's from the cleanup if (errorIsFromCleanup) { return undefined; } @@ -284,7 +295,8 @@ export class CoderClient implements CoderClientApi { }); this.trackedEjectionIds.clear(); - this.cleanupController.abort(); + this.cleanupController.abort(sharedCleanupAbortReason); + this.loadedSessionToken = undefined; }; } From 7dc8b247ac05f2262c639a3a07151809ce633170 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 13:46:41 +0000 Subject: [PATCH 40/64] fix: update majority of breaking tests --- .../src/api/CoderClient.ts | 8 +++++--- .../src/testHelpers/mockBackstageData.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index bcf87acb..f74ffd5c 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -45,6 +45,7 @@ const sharedCleanupAbortReason = new DOMException( ); type ConstructorInputs = Readonly<{ + initialToken?: string; apis: Readonly<{ urlSync: UrlSync; identityApi: IdentityApi; @@ -56,18 +57,19 @@ export class CoderClient implements CoderClientApi { private readonly identityApi: IdentityApi; private readonly axios: AxiosInstance; private readonly cleanupController: AbortController; + private readonly trackedEjectionIds: Set; - private trackedEjectionIds: Set; private loadedSessionToken: string | undefined; readonly sdk: BackstageCoderSdk; constructor(inputs: ConstructorInputs) { - const { apis } = inputs; + const { apis, initialToken } = inputs; const { urlSync, identityApi } = apis; this.urlSync = urlSync; this.identityApi = identityApi; - this.loadedSessionToken = undefined; + this.loadedSessionToken = initialToken; + this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index fffd265c..b36bc2ec 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -33,6 +33,7 @@ import { defaultUrlPrefixes, urlSyncApiRef, } from '../api/UrlSync'; +import { CoderClient, coderClientApiRef } from '../api/CoderClient'; /** * This is the key that Backstage checks from the entity data to determine the @@ -282,6 +283,15 @@ export function getMockDiscoveryApi(): DiscoveryApi { ); } +export function getMockCoderClient( + urlSync: UrlSync, + identityApi: IdentityApi, +): CoderClient { + return new CoderClient({ + apis: { urlSync, identityApi }, + }); +} + type ApiTuple = readonly [ApiRef>, NonNullable]; export function getMockApiList(): readonly ApiTuple[] { @@ -298,6 +308,14 @@ export function getMockApiList(): readonly ApiTuple[] { }, }); + const mockCoderClient = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: { + urlSync: mockUrlSyncApi, + identityApi: mockIdentityApi, + }, + }); + return [ // APIs that Backstage ships with normally [errorApiRef, mockErrorApi], @@ -308,5 +326,6 @@ export function getMockApiList(): readonly ApiTuple[] { // Custom APIs specific to the Coder plugin [urlSyncApiRef, mockUrlSyncApi], + [coderClientApiRef, mockCoderClient], ]; } From 72edd922e1e40d9647c6c197a3eaa94068962397 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 13:54:59 +0000 Subject: [PATCH 41/64] fix: resolve all breaking tests --- .../CoderProvider/CoderProvider.test.tsx | 58 +++++-------------- .../src/testHelpers/server.ts | 10 +++- 2 files changed, 21 insertions(+), 47 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 1b6b87da..955aae28 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -1,8 +1,8 @@ -import React, { PropsWithChildren } from 'react'; +import React from 'react'; import { renderHook } from '@testing-library/react'; import { act, waitFor } from '@testing-library/react'; -import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; +import { TestApiProvider } from '@backstage/test-utils'; import { configApiRef, discoveryApiRef, @@ -27,6 +27,7 @@ import { renderHookAsCoderEntity, } from '../../testHelpers/setup'; import { UrlSync, urlSyncApiRef } from '../../api/UrlSync'; +import { CoderClient, coderClientApiRef } from '../../api/CoderClient'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -50,47 +51,6 @@ describe(`${CoderProvider.name}`, () => { expect(result.current).toBe(mockAppConfig); } }); - - // Our documentation pushes people to define the config outside a component, - // just to stabilize the memory reference for the value, and make sure that - // memoization caches don't get invalidated too often. This test is just a - // safety net to catch what happens if someone forgets - test('Context value will change by reference on re-render if defined inline inside a parent', () => { - const ParentComponent = ({ children }: PropsWithChildren) => { - const configThatChangesEachRender = { ...mockAppConfig }; - - const discoveryApi = getMockDiscoveryApi(); - const configApi = getMockConfigApi(); - const urlSyncApi = new UrlSync({ - apis: { discoveryApi, configApi }, - }); - - return wrapInTestApp( - - - {children} - - , - ); - }; - - const { result, rerender } = renderHook(useCoderAppConfig, { - wrapper: ParentComponent, - }); - - const firstResult = result.current; - rerender(); - - expect(result.current).not.toBe(firstResult); - expect(result.current).toEqual(firstResult); - }); }); describe('Auth', () => { @@ -100,10 +60,16 @@ describe(`${CoderProvider.name}`, () => { const renderUseCoderAuth = () => { const discoveryApi = getMockDiscoveryApi(); const configApi = getMockConfigApi(); - const urlSyncApi = new UrlSync({ + const identityApi = getMockIdentityApi(); + + const urlSync = new UrlSync({ apis: { discoveryApi, configApi }, }); + const coderClientApi = new CoderClient({ + apis: { urlSync, identityApi }, + }); + return renderHook(useCoderAuth, { wrapper: ({ children }) => ( { [identityApiRef, getMockIdentityApi()], [configApiRef, configApi], [discoveryApiRef, discoveryApi], - [urlSyncApiRef, urlSyncApi], + + [urlSyncApiRef, urlSync], + [coderClientApiRef, coderClientApi], ]} > = ResponseResolver< RestRequest, @@ -114,8 +115,13 @@ const mainTestHandlers: readonly RestHandler[] = [ ), // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me`, (_, res, ctx) => { - return res(ctx.status(200)); + wrappedGet(`${root}/users/me/login-type`, (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + login_type: 'token', + }), + ); }), ]; From 8d3ad6043d36d830888e697de1a8093c35ae0525 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 14:31:34 +0000 Subject: [PATCH 42/64] fix: beef up CoderClient validation logic --- .../src/api/CoderClient.ts | 37 ++++++++++++++++--- .../src/api/MockCoderSdk.ts | 2 +- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index f74ffd5c..c893238c 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -1,4 +1,5 @@ import globalAxios, { + AxiosError, type AxiosInstance, type InternalAxiosRequestConfig as RequestConfig, } from 'axios'; @@ -9,7 +10,7 @@ import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import { CoderSdk } from './MockCoderSdk'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; -const REQUEST_TIMEOUT_MS = 20_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; /** * A version of the main Coder SDK API, with additional Backstage-specific @@ -46,6 +47,8 @@ const sharedCleanupAbortReason = new DOMException( type ConstructorInputs = Readonly<{ initialToken?: string; + requestTimeoutMs?: number; + apis: Readonly<{ urlSync: UrlSync; identityApi: IdentityApi; @@ -56,6 +59,8 @@ export class CoderClient implements CoderClientApi { private readonly urlSync: UrlSync; private readonly identityApi: IdentityApi; private readonly axios: AxiosInstance; + + private readonly requestTimeoutMs: number; private readonly cleanupController: AbortController; private readonly trackedEjectionIds: Set; @@ -63,12 +68,18 @@ export class CoderClient implements CoderClientApi { readonly sdk: BackstageCoderSdk; constructor(inputs: ConstructorInputs) { - const { apis, initialToken } = inputs; + const { + apis, + initialToken, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + } = inputs; const { urlSync, identityApi } = apis; this.urlSync = urlSync; this.identityApi = identityApi; + this.loadedSessionToken = initialToken; + this.requestTimeoutMs = requestTimeoutMs; this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); @@ -228,7 +239,7 @@ export class CoderClient implements CoderClientApi { const timeoutId = window.setTimeout(() => { const reason = new DOMException('Signal timed out', 'TimeoutException'); timeoutController.abort(reason); - }, REQUEST_TIMEOUT_MS); + }, this.requestTimeoutMs); const cleanupSignal = this.cleanupController.signal; cleanupSignal.addEventListener( @@ -282,11 +293,19 @@ export class CoderClient implements CoderClientApi { // that don't require request bodies await this.sdk.getUserLoginType(); this.loadedSessionToken = newToken; + return true; - } catch { - return false; + } catch (err) { + const tokenIsInvalid = + err instanceof AxiosError && err.response?.status === 401; + if (tokenIsInvalid) { + return false; + } + + throw err; } finally { - // Logic in a finally block always executes even after a value is returned + // Logic in finally blocks always run, even after the function has + // returned a value or thrown an error this.removeRequestInterceptorById(validationId); } }; @@ -299,6 +318,12 @@ export class CoderClient implements CoderClientApi { this.trackedEjectionIds.clear(); this.cleanupController.abort(sharedCleanupAbortReason); this.loadedSessionToken = undefined; + + this.axios.interceptors.request.use(() => { + throw new Error( + 'Requests have been disabled for this client. Please create a new client', + ); + }); }; } diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts index d7751b8c..6b12dc1a 100644 --- a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts @@ -20,7 +20,7 @@ type WorkspacesRequest = Readonly<{ // Return value used for the dummy requests used to verify a user's auth status // for the Coder token auth logic -type UserLoginType = Readonly<{ +export type UserLoginType = Readonly<{ login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; }>; From af0cae8cd90c8d399138b5bcdf8e9b306c78dbb5 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 14:56:57 +0000 Subject: [PATCH 43/64] chore: commit first passing test for CoderClient --- .../src/api/CoderClient.test.ts | 49 +++++++++++++++++++ .../src/api/CoderClient.ts | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 plugins/backstage-plugin-coder/src/api/CoderClient.test.ts diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts new file mode 100644 index 00000000..6cdf8f84 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -0,0 +1,49 @@ +import { CoderClient } from './CoderClient'; +import type { IdentityApi } from '@backstage/core-plugin-api'; +import { UrlSync } from './UrlSync'; +import { + getMockConfigApi, + getMockDiscoveryApi, + getMockIdentityApi, + mockCoderAuthToken, +} from '../testHelpers/mockBackstageData'; + +type ConstructorApis = Readonly<{ + identityApi: IdentityApi; + urlSync: UrlSync; +}>; + +function getConstructorApis(): ConstructorApis { + const configApi = getMockConfigApi(); + const discoveryApi = getMockDiscoveryApi(); + const urlSync = new UrlSync({ + apis: { configApi, discoveryApi }, + }); + + const identityApi = getMockIdentityApi(); + return { urlSync, identityApi }; +} + +describe(`${CoderClient.name}`, () => { + describe('syncToken functionality', () => {}); + + describe('cleanupClient functionality', () => { + it('Will prevent any new SDK requests from going through', async () => { + const client = new CoderClient({ apis: getConstructorApis() }); + client.cleanupClient(); + + await expect(() => { + // Request should fail, even though token is valid + return client.syncToken(mockCoderAuthToken); + }).rejects.toThrow(); + + await expect(() => { + return client.sdk.getUserLoginType(); + }).rejects.toThrow(); + }); + }); + + describe('Making fetch requests in general', () => {}); + + describe('Coder SDK', () => {}); +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index c893238c..26417aea 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -77,6 +77,7 @@ export class CoderClient implements CoderClientApi { this.urlSync = urlSync; this.identityApi = identityApi; + this.axios = globalAxios.create(); this.loadedSessionToken = initialToken; this.requestTimeoutMs = requestTimeoutMs; @@ -84,7 +85,6 @@ export class CoderClient implements CoderClientApi { this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); - this.axios = globalAxios.create(); this.sdk = this.getBackstageCoderSdk(this.axios); this.addBaseRequestInterceptors(); } From fb1062402e3dc06c0366be06c1ddbd1f6c4b558e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 15:00:03 +0000 Subject: [PATCH 44/64] fix: update error-detection logic in test --- .../src/api/CoderClient.test.ts | 17 ++++++++++------- .../src/api/CoderClient.ts | 8 +++++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 6cdf8f84..ed0e0547 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -1,4 +1,4 @@ -import { CoderClient } from './CoderClient'; +import { CoderClient, disabledClientError } from './CoderClient'; import type { IdentityApi } from '@backstage/core-plugin-api'; import { UrlSync } from './UrlSync'; import { @@ -32,14 +32,17 @@ describe(`${CoderClient.name}`, () => { const client = new CoderClient({ apis: getConstructorApis() }); client.cleanupClient(); - await expect(() => { - // Request should fail, even though token is valid - return client.syncToken(mockCoderAuthToken); - }).rejects.toThrow(); + // Request should fail, even though token is valid + await expect(() => client.syncToken(mockCoderAuthToken)).rejects.toThrow( + disabledClientError, + ); await expect(() => { - return client.sdk.getUserLoginType(); - }).rejects.toThrow(); + return client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + }).rejects.toThrow(disabledClientError); }); }); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 26417aea..bfddce05 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -45,6 +45,10 @@ const sharedCleanupAbortReason = new DOMException( 'AbortError', ); +export const disabledClientError = new Error( + 'Requests have been disabled for this client. Please create a new client', +); + type ConstructorInputs = Readonly<{ initialToken?: string; requestTimeoutMs?: number; @@ -320,9 +324,7 @@ export class CoderClient implements CoderClientApi { this.loadedSessionToken = undefined; this.axios.interceptors.request.use(() => { - throw new Error( - 'Requests have been disabled for this client. Please create a new client', - ); + throw disabledClientError; }); }; } From 239661da0fcee45daba8680b366c37e9970335d6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 15:07:20 +0000 Subject: [PATCH 45/64] wip: add all test stubs for CoderClient --- .../src/api/CoderClient.test.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index ed0e0547..1d3496e4 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -25,7 +25,19 @@ function getConstructorApis(): ConstructorApis { } describe(`${CoderClient.name}`, () => { - describe('syncToken functionality', () => {}); + describe('syncToken functionality', () => { + it('Will load the provided token into the client if it is valid', async () => { + expect.hasAssertions(); + }); + + it('Will NOT load the provided token into the client if it is invalid', async () => { + expect.hasAssertions(); + }); + + it('Will propagate any other error types to the caller', async () => { + expect.hasAssertions(); + }); + }); describe('cleanupClient functionality', () => { it('Will prevent any new SDK requests from going through', async () => { @@ -33,9 +45,9 @@ describe(`${CoderClient.name}`, () => { client.cleanupClient(); // Request should fail, even though token is valid - await expect(() => client.syncToken(mockCoderAuthToken)).rejects.toThrow( - disabledClientError, - ); + await expect(() => { + return client.syncToken(mockCoderAuthToken); + }).rejects.toThrow(disabledClientError); await expect(() => { return client.sdk.getWorkspaces({ @@ -44,9 +56,22 @@ describe(`${CoderClient.name}`, () => { }); }).rejects.toThrow(disabledClientError); }); + + it('Will abort any pending requests', async () => { + expect.hasAssertions(); + }); }); - describe('Making fetch requests in general', () => {}); + // Eventually the Coder SDK is going to get too big to test every single + // function. Focus tests on the functionality specifically being patched in + // for Backstage + describe('Coder SDK', () => { + it('Will remap all workspace icon URLs to use the proxy URL if necessary', async () => { + expect.hasAssertions(); + }); - describe('Coder SDK', () => {}); + it('Lets the user search for workspaces by repo URL', async () => { + expect.hasAssertions(); + }); + }); }); From cc4e4e7f4af966f84ecf20b33edc006bc9b9a89b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 15:17:30 +0000 Subject: [PATCH 46/64] chore: add test cases for syncToken's main return type --- .../src/api/CoderClient.test.ts | 43 +++++++++++++++++-- .../src/testHelpers/server.ts | 2 +- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 1d3496e4..2de67c96 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -1,11 +1,18 @@ -import { CoderClient, disabledClientError } from './CoderClient'; +import { + CODER_AUTH_HEADER_KEY, + CoderClient, + disabledClientError, +} from './CoderClient'; import type { IdentityApi } from '@backstage/core-plugin-api'; import { UrlSync } from './UrlSync'; +import { rest } from 'msw'; +import { server } from '../testHelpers/server'; import { getMockConfigApi, getMockDiscoveryApi, getMockIdentityApi, mockCoderAuthToken, + mockBackstageProxyEndpoint as root, } from '../testHelpers/mockBackstageData'; type ConstructorApis = Readonly<{ @@ -27,14 +34,42 @@ function getConstructorApis(): ConstructorApis { describe(`${CoderClient.name}`, () => { describe('syncToken functionality', () => { it('Will load the provided token into the client if it is valid', async () => { - expect.hasAssertions(); + const client = new CoderClient({ apis: getConstructorApis() }); + + const syncResult = await client.syncToken(mockCoderAuthToken); + expect(syncResult).toBe(true); + + let serverToken: string | null = null; + server.use( + rest.get(`${root}/users/me/login-type`, (req, res, ctx) => { + serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); + return res(ctx.status(200)); + }), + ); + + await client.sdk.getUserLoginType(); + expect(serverToken).toBe(mockCoderAuthToken); }); it('Will NOT load the provided token into the client if it is invalid', async () => { - expect.hasAssertions(); + const client = new CoderClient({ apis: getConstructorApis() }); + + const syncResult = await client.syncToken('Definitely not valid'); + expect(syncResult).toBe(false); + + let serverToken: string | null = null; + server.use( + rest.get(`${root}/users/me/login-type`, (req, res, ctx) => { + serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); + return res(ctx.status(200)); + }), + ); + + await client.sdk.getUserLoginType(); + expect(serverToken).toBe(null); }); - it('Will propagate any other error types to the caller', async () => { + it.only('Will propagate any other error types to the caller', async () => { expect.hasAssertions(); }); }); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index cc023464..42a3db14 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -70,7 +70,7 @@ export function wrapInDefaultMiddleware( }, resolver); } -function wrappedGet( +export function wrappedGet( path: string, resolver: RestResolver, ): RestHandler { From 56bcbbdc581a7b666d1e122a1db145db1cb22d88 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 15:43:14 +0000 Subject: [PATCH 47/64] chore: add more test cases --- .../src/api/CoderClient.test.ts | 69 +++++++++++++++++-- .../src/api/CoderClient.ts | 9 ++- .../backstage-plugin-coder/src/utils/time.ts | 9 +++ 3 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/utils/time.ts diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 2de67c96..aa8b47d0 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -6,7 +6,7 @@ import { import type { IdentityApi } from '@backstage/core-plugin-api'; import { UrlSync } from './UrlSync'; import { rest } from 'msw'; -import { server } from '../testHelpers/server'; +import { server, wrappedGet } from '../testHelpers/server'; import { getMockConfigApi, getMockDiscoveryApi, @@ -14,6 +14,10 @@ import { mockCoderAuthToken, mockBackstageProxyEndpoint as root, } from '../testHelpers/mockBackstageData'; +import { CanceledError } from 'axios'; +import { delay } from '../utils/time'; +import { mockWorkspacesList } from '../testHelpers/mockCoderAppData'; +import type { Workspace, WorkspacesResponse } from '../typesConstants'; type ConstructorApis = Readonly<{ identityApi: IdentityApi; @@ -69,8 +73,26 @@ describe(`${CoderClient.name}`, () => { expect(serverToken).toBe(null); }); - it.only('Will propagate any other error types to the caller', async () => { - expect.hasAssertions(); + it('Will propagate any other error types to the caller', async () => { + const client = new CoderClient({ + // Setting the timeout to 0 will make requests instantly fail from the + // next microtask queue tick + requestTimeoutMs: 0, + apis: getConstructorApis(), + }); + + server.use( + rest.get(`${root}/users/me/login-type`, async (_, res, ctx) => { + // MSW is so fast that sometimes it can respond before a forced + // timeout; have to introduce artificial delay + await delay(50_000); + return res(ctx.status(200)); + }), + ); + + await expect(() => { + return client.syncToken(mockCoderAuthToken); + }).rejects.toThrow(CanceledError); }); }); @@ -102,10 +124,47 @@ describe(`${CoderClient.name}`, () => { // for Backstage describe('Coder SDK', () => { it('Will remap all workspace icon URLs to use the proxy URL if necessary', async () => { - expect.hasAssertions(); + const apis = getConstructorApis(); + const client = new CoderClient({ + apis, + initialToken: mockCoderAuthToken, + }); + + server.use( + wrappedGet(`${root}/workspaces`, (_, res, ctx) => { + const withRelativePaths = mockWorkspacesList.map(ws => { + return { + ...ws, + template_icon: '/emojis/blueberry.svg', + }; + }); + + return res( + ctx.status(200), + ctx.json({ + workspaces: withRelativePaths, + count: withRelativePaths.length, + }), + ); + }), + ); + + const { workspaces } = await client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + + const { urlSync } = apis; + const apiEndpoint = await urlSync.getApiEndpoint(); + + const allWorkspacesAreRemapped = !workspaces.some(ws => + ws.template_icon.startsWith(apiEndpoint), + ); + + expect(allWorkspacesAreRemapped).toBe(true); }); - it('Lets the user search for workspaces by repo URL', async () => { + it.only('Lets the user search for workspaces by repo URL', async () => { expect.hasAssertions(); }); }); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index bfddce05..0005056f 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -45,8 +45,13 @@ const sharedCleanupAbortReason = new DOMException( 'AbortError', ); -export const disabledClientError = new Error( - 'Requests have been disabled for this client. Please create a new client', +// Can't make this value readonly at the type level because it has +// non-enumerable properties. Next best thing is runtime freezing to force it +// to act like a constant +export const disabledClientError = Object.freeze( + new Error( + 'Requests have been disabled for this client. Please create a new client', + ), ); type ConstructorInputs = Readonly<{ diff --git a/plugins/backstage-plugin-coder/src/utils/time.ts b/plugins/backstage-plugin-coder/src/utils/time.ts new file mode 100644 index 00000000..b37ce94b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/time.ts @@ -0,0 +1,9 @@ +export function delay(timeoutMs: number): Promise { + if (!Number.isInteger(timeoutMs) || timeoutMs < 0) { + throw new Error('Cannot delay by non-integer or negative values'); + } + + return new Promise(resolve => { + window.setTimeout(resolve, timeoutMs); + }); +} From 907f78e580d70ed2c881003171ee549bea409260 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 15:59:58 +0000 Subject: [PATCH 48/64] fix: remove Object.freeze logic --- plugins/backstage-plugin-coder/src/api/CoderClient.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 0005056f..fa0ac192 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -46,12 +46,10 @@ const sharedCleanupAbortReason = new DOMException( ); // Can't make this value readonly at the type level because it has -// non-enumerable properties. Next best thing is runtime freezing to force it -// to act like a constant -export const disabledClientError = Object.freeze( - new Error( - 'Requests have been disabled for this client. Please create a new client', - ), +// non-enumerable properties, and Object.freeze causes errors. Just have to +// treat this like a constant +export const disabledClientError = new Error( + 'Requests have been disabled for this client. Please create a new client', ); type ConstructorInputs = Readonly<{ From 94cd97a187ba7843e50191326f570ee098aaf471 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 16:00:13 +0000 Subject: [PATCH 49/64] refactor: consolidate mock API endpoints in one spot --- .../src/testHelpers/server.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 42a3db14..20f510dc 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -78,8 +78,14 @@ export function wrappedGet( return rest.get(path, wrapped); } +export const mockServerEndpoints = { + workspaces: `${root}/workspaces`, + userLoginType: `${root}/users/me/login-type`, + workspaceBuildParameters: `${root}/workspacebuilds/:workspaceBuildId/parameters`, +} as const satisfies Record; + const mainTestHandlers: readonly RestHandler[] = [ - wrappedGet(`${root}/workspaces`, (req, res, ctx) => { + wrappedGet(mockServerEndpoints.workspaces, (req, res, ctx) => { const queryText = String(req.url.searchParams.get('q')); let returnedWorkspaces: Workspace[]; @@ -100,22 +106,19 @@ const mainTestHandlers: readonly RestHandler[] = [ ); }), - wrappedGet( - `${root}/workspacebuilds/:workspaceBuildId/parameters`, - (req, res, ctx) => { - const buildId = String(req.params.workspaceBuildId); - const selectedParams = mockWorkspaceBuildParameters[buildId]; + wrappedGet(mockServerEndpoints.workspaceBuildParameters, (req, res, ctx) => { + const buildId = String(req.params.workspaceBuildId); + const selectedParams = mockWorkspaceBuildParameters[buildId]; - if (selectedParams !== undefined) { - return res(ctx.status(200), ctx.json(selectedParams)); - } + if (selectedParams !== undefined) { + return res(ctx.status(200), ctx.json(selectedParams)); + } - return res(ctx.status(404)); - }, - ), + return res(ctx.status(404)); + }), // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me/login-type`, (_, res, ctx) => { + wrappedGet(mockServerEndpoints.userLoginType, (_, res, ctx) => { return res( ctx.status(200), ctx.json({ From 648c4a4b9fcc8ab8288f731252afb22ad03bb82b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 16:02:26 +0000 Subject: [PATCH 50/64] wip: commit current test progress --- .../src/api/CoderClient.test.ts | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index aa8b47d0..24c24608 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -6,18 +6,18 @@ import { import type { IdentityApi } from '@backstage/core-plugin-api'; import { UrlSync } from './UrlSync'; import { rest } from 'msw'; -import { server, wrappedGet } from '../testHelpers/server'; +import { mockServerEndpoints, server, wrappedGet } from '../testHelpers/server'; +import { CanceledError } from 'axios'; +import { delay } from '../utils/time'; +import { mockWorkspacesList } from '../testHelpers/mockCoderAppData'; +import type { Workspace, WorkspacesResponse } from '../typesConstants'; import { getMockConfigApi, getMockDiscoveryApi, getMockIdentityApi, mockCoderAuthToken, - mockBackstageProxyEndpoint as root, + mockCoderWorkspacesConfig, } from '../testHelpers/mockBackstageData'; -import { CanceledError } from 'axios'; -import { delay } from '../utils/time'; -import { mockWorkspacesList } from '../testHelpers/mockCoderAppData'; -import type { Workspace, WorkspacesResponse } from '../typesConstants'; type ConstructorApis = Readonly<{ identityApi: IdentityApi; @@ -45,7 +45,7 @@ describe(`${CoderClient.name}`, () => { let serverToken: string | null = null; server.use( - rest.get(`${root}/users/me/login-type`, (req, res, ctx) => { + rest.get(mockServerEndpoints.userLoginType, (req, res, ctx) => { serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); return res(ctx.status(200)); }), @@ -63,7 +63,7 @@ describe(`${CoderClient.name}`, () => { let serverToken: string | null = null; server.use( - rest.get(`${root}/users/me/login-type`, (req, res, ctx) => { + rest.get(mockServerEndpoints.userLoginType, (req, res, ctx) => { serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); return res(ctx.status(200)); }), @@ -82,7 +82,7 @@ describe(`${CoderClient.name}`, () => { }); server.use( - rest.get(`${root}/users/me/login-type`, async (_, res, ctx) => { + rest.get(mockServerEndpoints.userLoginType, async (_, res, ctx) => { // MSW is so fast that sometimes it can respond before a forced // timeout; have to introduce artificial delay await delay(50_000); @@ -131,7 +131,7 @@ describe(`${CoderClient.name}`, () => { }); server.use( - wrappedGet(`${root}/workspaces`, (_, res, ctx) => { + wrappedGet(mockServerEndpoints.workspaces, (_, res, ctx) => { const withRelativePaths = mockWorkspacesList.map(ws => { return { ...ws, @@ -164,7 +164,46 @@ describe(`${CoderClient.name}`, () => { expect(allWorkspacesAreRemapped).toBe(true); }); - it.only('Lets the user search for workspaces by repo URL', async () => { + it('Lets the user search for workspaces by repo URL', async () => { + const client = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: getConstructorApis(), + }); + + const mockBuildParameterId = 'blah'; + server.use( + wrappedGet(mockServerEndpoints.workspaces, (req, res, next) => { + // + }), + wrappedGet( + mockServerEndpoints.workspaceBuildParameters, + (req, res, ctx) => { + // + }, + ), + ); + + const fullResults = await client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + + const workspaces = await client.sdk.getWorkspacesByRepo( + 'owner:me', + mockCoderWorkspacesConfig, + ); + + expect(workspaces.length).toBe(1); + + const allWorkspacesAreForRepo = workspaces.every(ws => { + return ws.latest_build.resources.every(resource => { + return resource.agents?.some(agent => { + return agent; + }); + }); + }); + + expect(workspaces[0].latest_build.resources); expect.hasAssertions(); }); }); From 08d38c626b1ccb77000e64d4d47d3bdaae2600de Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 16:16:24 +0000 Subject: [PATCH 51/64] refactor: rename mock API endpoint variable for clarity --- plugins/backstage-plugin-coder/src/api/UrlSync.test.ts | 4 ++-- .../CoderWorkspacesCard/WorkspacesListIcon.test.tsx | 4 ++-- .../backstage-plugin-coder/src/hooks/useUrlSync.test.tsx | 6 +++--- .../src/testHelpers/mockBackstageData.ts | 2 +- plugins/backstage-plugin-coder/src/testHelpers/server.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts index 7776fadb..4932edea 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -4,7 +4,7 @@ import { getMockConfigApi, getMockDiscoveryApi, mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, + mockBackstageApiEndpoint, mockBackstageUrlRoot, } from '../testHelpers/mockBackstageData'; @@ -23,7 +23,7 @@ describe(`${UrlSync.name}`, () => { const cachedUrls = urlSync.getCachedUrls(); expect(cachedUrls).toEqual({ baseUrl: mockBackstageUrlRoot, - apiRoute: mockBackstageProxyEndpoint, + apiRoute: mockBackstageApiEndpoint, assetsRoute: mockBackstageAssetsEndpoint, }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx index 1803bec9..3987c5ee 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { mockBackstageProxyEndpoint } from '../../testHelpers/mockBackstageData'; +import { mockBackstageApiEndpoint } from '../../testHelpers/mockBackstageData'; import { WorkspacesListIcon } from './WorkspacesListIcon'; describe(`${WorkspacesListIcon.name}`, () => { it('Should display a fallback UI element instead of a broken image when the image fails to load', async () => { const workspaceName = 'blah'; - const imgPath = `${mockBackstageProxyEndpoint}/wrongUrlPal.png`; + const imgPath = `${mockBackstageApiEndpoint}/wrongUrlPal.png`; await renderInCoderEnvironment({ children: ( diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index 2769e2d8..dfa87803 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -6,13 +6,13 @@ import { type UseUrlSyncResult, useUrlSync } from './useUrlSync'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import { mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, + mockBackstageApiEndpoint, mockBackstageUrlRoot, getMockConfigApi, } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint: string = mockBackstageProxyEndpoint; + let proxyEndpoint: string = mockBackstageApiEndpoint; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; @@ -53,7 +53,7 @@ describe(`${useUrlSync.name}`, () => { state: { baseUrl: mockBackstageUrlRoot, assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageProxyEndpoint, + apiRoute: mockBackstageApiEndpoint, }, }), ); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index b36bc2ec..57a6d5c3 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -72,7 +72,7 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; * The string literal expression is complicated, but hover over it to see what * the final result is. */ -export const mockBackstageProxyEndpoint = +export const mockBackstageApiEndpoint = `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; /** diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 20f510dc..2f308a87 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -17,7 +17,7 @@ import { import { mockBearerToken, mockCoderAuthToken, - mockBackstageProxyEndpoint as root, + mockBackstageApiEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; From c8cbba7b87baa1e849d0f5cf4451eacae17e6075 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 16:18:22 +0000 Subject: [PATCH 52/64] chore: finish test for aborting queued requests --- .../src/api/CoderClient.test.ts | 25 +++++++++++++++++-- .../src/testHelpers/mockCoderAppData.ts | 10 ++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 24c24608..ae76e715 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -115,7 +115,28 @@ describe(`${CoderClient.name}`, () => { }); it('Will abort any pending requests', async () => { - expect.hasAssertions(); + const client = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: getConstructorApis(), + }); + + // Sanity check to ensure that request can still go through normally + const workspacesPromise1 = client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + + await expect(workspacesPromise1).resolves.toEqual({ + workspaces: mockWorkspacesList, + count: mockWorkspacesList.length, + }); + + const workspacesPromise2 = client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + client.cleanupClient(); + await expect(() => workspacesPromise2).rejects.toThrow(); }); }); @@ -164,7 +185,7 @@ describe(`${CoderClient.name}`, () => { expect(allWorkspacesAreRemapped).toBe(true); }); - it('Lets the user search for workspaces by repo URL', async () => { + it.only('Lets the user search for workspaces by repo URL', async () => { const client = new CoderClient({ initialToken: mockCoderAuthToken, apis: getConstructorApis(), diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts index 6e122aad..ce63590f 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts @@ -1,5 +1,5 @@ import type { Workspace, WorkspaceBuildParameter } from '../typesConstants'; -import { cleanedRepoUrl } from './mockBackstageData'; +import { cleanedRepoUrl, mockBackstageApiEndpoint } from './mockBackstageData'; /** * The main mock for a workspace whose repo URL matches cleanedRepoUrl @@ -7,7 +7,7 @@ import { cleanedRepoUrl } from './mockBackstageData'; export const mockWorkspaceWithMatch: Workspace = { id: 'workspace-with-match', name: 'Test-Workspace', - template_icon: '/emojis/dog.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/dog.svg`, owner_name: 'lil brudder', latest_build: { id: 'workspace-with-match-build', @@ -30,7 +30,7 @@ export const mockWorkspaceWithMatch: Workspace = { export const mockWorkspaceWithMatch2: Workspace = { id: 'workspace-with-match-2', name: 'Another-Test', - template_icon: '/emojis/z.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/z.svg`, owner_name: 'Coach Z', latest_build: { id: 'workspace-with-match-2-build', @@ -51,7 +51,7 @@ export const mockWorkspaceWithMatch2: Workspace = { export const mockWorkspaceNoMatch: Workspace = { id: 'workspace-no-match', name: 'No-match', - template_icon: '/emojis/star.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/star.svg`, owner_name: 'homestar runner', latest_build: { id: 'workspace-no-match-build', @@ -74,7 +74,7 @@ export const mockWorkspaceNoMatch: Workspace = { export const mockWorkspaceNoParameters: Workspace = { id: 'workspace-no-parameters', name: 'No-parameters', - template_icon: '/emojis/cheese.png', + template_icon: `${mockBackstageApiEndpoint}/emojis/cheese.png`, owner_name: 'The Cheat', latest_build: { id: 'workspace-no-parameters-build', From 9f1c63e019b10ad65e1138700bffe00081fcefc3 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 16:34:13 +0000 Subject: [PATCH 53/64] chore: finish initial versions of all CoderClient tests --- .../src/api/CoderClient.test.ts | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index ae76e715..67bfb213 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -185,47 +185,30 @@ describe(`${CoderClient.name}`, () => { expect(allWorkspacesAreRemapped).toBe(true); }); - it.only('Lets the user search for workspaces by repo URL', async () => { + it('Lets the user search for workspaces by repo URL', async () => { const client = new CoderClient({ initialToken: mockCoderAuthToken, apis: getConstructorApis(), }); - const mockBuildParameterId = 'blah'; - server.use( - wrappedGet(mockServerEndpoints.workspaces, (req, res, next) => { - // - }), - wrappedGet( - mockServerEndpoints.workspaceBuildParameters, - (req, res, ctx) => { - // - }, - ), - ); - - const fullResults = await client.sdk.getWorkspaces({ - q: 'owner:me', - limit: 0, - }); - - const workspaces = await client.sdk.getWorkspacesByRepo( + const repoWorkspaces = await client.sdk.getWorkspacesByRepo( 'owner:me', mockCoderWorkspacesConfig, ); - expect(workspaces.length).toBe(1); + const buildParameterGroups = await Promise.all( + repoWorkspaces.map(ws => + client.sdk.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); - const allWorkspacesAreForRepo = workspaces.every(ws => { - return ws.latest_build.resources.every(resource => { - return resource.agents?.some(agent => { - return agent; - }); + for (const paramGroup of buildParameterGroups) { + const atLeastOneParamMatchesForGroup = paramGroup.some(param => { + return param.value === mockCoderWorkspacesConfig.repoUrl; }); - }); - expect(workspaces[0].latest_build.resources); - expect.hasAssertions(); + expect(atLeastOneParamMatchesForGroup).toBe(true); + } }); }); }); From c7fb74e71a7098fb0a9e33fa2ba8b2078081b31d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 16:43:00 +0000 Subject: [PATCH 54/64] fix: delete helper that was never used --- .../src/testHelpers/mockBackstageData.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 57a6d5c3..28e258f5 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -283,15 +283,6 @@ export function getMockDiscoveryApi(): DiscoveryApi { ); } -export function getMockCoderClient( - urlSync: UrlSync, - identityApi: IdentityApi, -): CoderClient { - return new CoderClient({ - apis: { urlSync, identityApi }, - }); -} - type ApiTuple = readonly [ApiRef>, NonNullable]; export function getMockApiList(): readonly ApiTuple[] { From 549e12e1c7904024b8ee182e8d271d644760585a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 16:48:41 +0000 Subject: [PATCH 55/64] fix: update getWorkspacesByRepo function signature to be more consistent with base function --- .../src/api/CoderClient.test.ts | 2 +- .../src/api/CoderClient.ts | 16 ++++++++-------- .../src/api/MockCoderSdk.ts | 15 ++------------- .../src/api/queryOptions.ts | 7 +++++-- .../src/testHelpers/server.ts | 2 +- .../backstage-plugin-coder/src/typesConstants.ts | 13 +++++++++++++ 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 67bfb213..b3f8ae68 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -192,7 +192,7 @@ describe(`${CoderClient.name}`, () => { }); const repoWorkspaces = await client.sdk.getWorkspacesByRepo( - 'owner:me', + { q: 'owner:me' }, mockCoderWorkspacesConfig, ); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index fa0ac192..e7d5c177 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -4,7 +4,11 @@ import globalAxios, { type InternalAxiosRequestConfig as RequestConfig, } from 'axios'; import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; -import { type Workspace, CODER_API_REF_ID_PREFIX } from '../typesConstants'; +import { + type Workspace, + CODER_API_REF_ID_PREFIX, + WorkspacesRequest, +} from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import { CoderSdk } from './MockCoderSdk'; @@ -19,7 +23,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; export type BackstageCoderSdk = Readonly< CoderSdk & { getWorkspacesByRepo: ( - coderQuery: string, + request: WorkspacesRequest, config: CoderWorkspacesConfig, ) => Promise; } @@ -190,14 +194,10 @@ export class CoderClient implements CoderClientApi { }; const getWorkspacesByRepo = async ( - coderQuery: string, + request: WorkspacesRequest, config: CoderWorkspacesConfig, ): Promise => { - const { workspaces } = await baseSdk.getWorkspaces({ - q: coderQuery, - limit: 0, - }); - + const { workspaces } = await baseSdk.getWorkspaces(request); const paramResults = await Promise.allSettled( workspaces.map(ws => this.sdk.getWorkspaceBuildParameters(ws.latest_build.id), diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts index 6b12dc1a..d351a3e3 100644 --- a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts @@ -7,23 +7,12 @@ */ import globalAxios, { type AxiosInstance } from 'axios'; import { + type UserLoginType, + type WorkspacesRequest, type WorkspaceBuildParameter, type WorkspacesResponse, } from '../typesConstants'; -type WorkspacesRequest = Readonly<{ - after_id?: string; - limit?: number; - offset?: number; - q?: string; -}>; - -// Return value used for the dummy requests used to verify a user's auth status -// for the Coder token auth logic -export type UserLoginType = Readonly<{ - login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; -}>; - type CoderSdkApi = { getUserLoginType: () => Promise; getWorkspaces: (options: WorkspacesRequest) => Promise; diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index 1459e8db..f97a5b9c 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,5 +1,5 @@ import type { UseQueryOptions } from '@tanstack/react-query'; -import type { Workspace } from '../typesConstants'; +import type { Workspace, WorkspacesRequest } from '../typesConstants'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import type { BackstageCoderSdk } from './CoderClient'; import type { CoderAuth } from '../components/CoderProvider'; @@ -90,9 +90,12 @@ export function workspacesByRepo({ return { queryKey: [...getSharedWorkspacesQueryKey(coderQuery), workspacesConfig], - queryFn: () => coderSdk.getWorkspacesByRepo(coderQuery, workspacesConfig), enabled, keepPreviousData: enabled, refetchInterval: getCoderWorkspacesRefetchInterval, + queryFn: () => { + const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; + return coderSdk.getWorkspacesByRepo(request, workspacesConfig); + }, }; } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 2f308a87..1e78d4ff 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -21,7 +21,7 @@ import { } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; -import { UserLoginType } from '../api/MockCoderSdk'; +import { UserLoginType } from '../typesConstants'; type RestResolver = ResponseResolver< RestRequest, diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index b92d0cdb..ab16d1ef 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -98,3 +98,16 @@ export type WorkspacesResponse = Output; export type WorkspaceBuildParameter = Output< typeof workspaceBuildParameterSchema >; + +export type WorkspacesRequest = Readonly<{ + after_id?: string; + limit?: number; + offset?: number; + q?: string; +}>; + +// Return value used for the dummy requests used to verify a user's auth status +// for the Coder token auth logic +export type UserLoginType = Readonly<{ + login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +}>; From 4a7fa1459a9d9ffccae1a744a9f563dd3f9f8836 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 17:46:14 +0000 Subject: [PATCH 56/64] docs: add comment reminder about arrow functions for CoderClient --- plugins/backstage-plugin-coder/src/api/CoderClient.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index e7d5c177..1606dc90 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -282,6 +282,11 @@ export class CoderClient implements CoderClientApi { }); } + /* *************************************************************************** + * All public functions should be defined as arrow functions to ensure they + * can be passed around React without risk of losing their `this` context + ****************************************************************************/ + syncToken = async (newToken: string): Promise => { // Because this newly-added interceptor will run before any other // interceptors, you could make it so that the syncToken request will @@ -300,7 +305,6 @@ export class CoderClient implements CoderClientApi { // that don't require request bodies await this.sdk.getUserLoginType(); this.loadedSessionToken = newToken; - return true; } catch (err) { const tokenIsInvalid = From 9c1dc252f3093e8faa6a388828b7c0181b475d59 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 17:54:23 +0000 Subject: [PATCH 57/64] docs: add comment explaining use of interceptor logic --- plugins/backstage-plugin-coder/src/api/CoderClient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 1606dc90..f726d982 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -330,6 +330,9 @@ export class CoderClient implements CoderClientApi { this.cleanupController.abort(sharedCleanupAbortReason); this.loadedSessionToken = undefined; + // Not using this.addRequestInterceptor, because we don't want to track this + // interceptor at all. It should never be ejected once the client has been + // disabled this.axios.interceptors.request.use(() => { throw disabledClientError; }); From c91250da7951f90cc48919c64fdc0506c80306fd Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 29 Apr 2024 18:53:59 +0000 Subject: [PATCH 58/64] fix: update return type of getWorkspacesByRepo function --- .../backstage-plugin-coder/src/api/CoderClient.test.ts | 4 ++-- plugins/backstage-plugin-coder/src/api/CoderClient.ts | 10 +++++++--- plugins/backstage-plugin-coder/src/api/queryOptions.ts | 5 +++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index b3f8ae68..8391beae 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -191,13 +191,13 @@ describe(`${CoderClient.name}`, () => { apis: getConstructorApis(), }); - const repoWorkspaces = await client.sdk.getWorkspacesByRepo( + const { workspaces } = await client.sdk.getWorkspacesByRepo( { q: 'owner:me' }, mockCoderWorkspacesConfig, ); const buildParameterGroups = await Promise.all( - repoWorkspaces.map(ws => + workspaces.map(ws => client.sdk.getWorkspaceBuildParameters(ws.latest_build.id), ), ); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index f726d982..ebbefb7b 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -8,6 +8,7 @@ import { type Workspace, CODER_API_REF_ID_PREFIX, WorkspacesRequest, + WorkspacesResponse, } from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; @@ -25,7 +26,7 @@ export type BackstageCoderSdk = Readonly< getWorkspacesByRepo: ( request: WorkspacesRequest, config: CoderWorkspacesConfig, - ) => Promise; + ) => Promise; } >; @@ -196,7 +197,7 @@ export class CoderClient implements CoderClientApi { const getWorkspacesByRepo = async ( request: WorkspacesRequest, config: CoderWorkspacesConfig, - ): Promise => { + ): Promise => { const { workspaces } = await baseSdk.getWorkspaces(request); const paramResults = await Promise.allSettled( workspaces.map(ws => @@ -225,7 +226,10 @@ export class CoderClient implements CoderClientApi { } } - return matchedWorkspaces; + return { + workspaces: matchedWorkspaces, + count: matchedWorkspaces.length, + }; }; return { diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index f97a5b9c..f2fafb19 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -93,9 +93,10 @@ export function workspacesByRepo({ enabled, keepPreviousData: enabled, refetchInterval: getCoderWorkspacesRefetchInterval, - queryFn: () => { + queryFn: async () => { const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; - return coderSdk.getWorkspacesByRepo(request, workspacesConfig); + const res = await coderSdk.getWorkspacesByRepo(request, workspacesConfig); + return res.workspaces; }, }; } From e370197b80bb310827c8a92bd6006bae553d980a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 30 Apr 2024 14:49:16 +0000 Subject: [PATCH 59/64] 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 60/64] 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: [ From bb05d8a6d16946b250e07bf17a603dea695764ed Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 30 Apr 2024 16:19:43 +0000 Subject: [PATCH 61/64] refactor: simplify interceptor removal logic --- .../backstage-plugin-coder/src/api/CoderClient.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index ebbefb7b..caa3cc66 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -117,14 +117,16 @@ export class CoderClient implements CoderClientApi { } private removeRequestInterceptorById(ejectionId: number): boolean { - const sizeBeforeRemoval = this.trackedEjectionIds.size; - + // Even if we somehow pass in an ID that hasn't been associated with the + // Axios instance, that's a noop. No harm in calling method no matter what this.axios.interceptors.request.eject(ejectionId); - if (this.trackedEjectionIds.has(ejectionId)) { - this.trackedEjectionIds.delete(ejectionId); + + if (!this.trackedEjectionIds.has(ejectionId)) { + return false; } - return sizeBeforeRemoval !== this.trackedEjectionIds.size; + this.trackedEjectionIds.delete(ejectionId); + return true; } private addBaseRequestInterceptors(): void { From 800bfc896c26d4231b9a40ebc9d8ceb940330936 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 30 Apr 2024 16:43:40 +0000 Subject: [PATCH 62/64] refactor: update how Backstage SDK is set up --- plugins/backstage-plugin-coder/src/api/CoderClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index caa3cc66..0e8b0bfa 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -183,15 +183,14 @@ export class CoderClient implements CoderClientApi { ): BackstageCoderSdk { const baseSdk = new CoderSdk(axiosInstance); - const originalGetWorkspaces = baseSdk.getWorkspaces; - baseSdk.getWorkspaces = async request => { - const workspacesRes = await originalGetWorkspaces(request); + const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { + const workspacesRes = await baseSdk.getWorkspaces(request); const remapped = await this.remapWorkspaceIconUrls( workspacesRes.workspaces, ); return { - count: remapped.length, + ...workspacesRes, workspaces: remapped, }; }; @@ -236,6 +235,7 @@ export class CoderClient implements CoderClientApi { return { ...baseSdk, + getWorkspaces, getWorkspacesByRepo, }; } From 5dad5fb1925e5ad5280464e2c3baae1a67ca242b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 30 Apr 2024 16:59:04 +0000 Subject: [PATCH 63/64] refactor: update dummy request for authenticating --- .../src/api/CoderClient.test.ts | 15 ++++++++------- .../backstage-plugin-coder/src/api/CoderClient.ts | 2 +- .../src/api/MockCoderSdk.ts | 13 +++++-------- .../src/testHelpers/server.ts | 13 ++++++++----- .../backstage-plugin-coder/src/typesConstants.ts | 12 ++++++++---- 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 8391beae..945d8317 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -45,13 +45,13 @@ describe(`${CoderClient.name}`, () => { let serverToken: string | null = null; server.use( - rest.get(mockServerEndpoints.userLoginType, (req, res, ctx) => { + rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => { serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); return res(ctx.status(200)); }), ); - await client.sdk.getUserLoginType(); + await client.sdk.getAuthenticatedUser(); expect(serverToken).toBe(mockCoderAuthToken); }); @@ -63,13 +63,13 @@ describe(`${CoderClient.name}`, () => { let serverToken: string | null = null; server.use( - rest.get(mockServerEndpoints.userLoginType, (req, res, ctx) => { + rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => { serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); return res(ctx.status(200)); }), ); - await client.sdk.getUserLoginType(); + await client.sdk.getAuthenticatedUser(); expect(serverToken).toBe(null); }); @@ -82,10 +82,11 @@ describe(`${CoderClient.name}`, () => { }); server.use( - rest.get(mockServerEndpoints.userLoginType, async (_, res, ctx) => { + rest.get(mockServerEndpoints.authenticatedUser, async (_, res, ctx) => { // MSW is so fast that sometimes it can respond before a forced - // timeout; have to introduce artificial delay - await delay(50_000); + // timeout; have to introduce artificial delay (that shouldn't matter + // as long as the abort logic goes through properly) + await delay(2_000); return res(ctx.status(200)); }), ); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 0e8b0bfa..41d438d7 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -309,7 +309,7 @@ export class CoderClient implements CoderClientApi { // Actual request type doesn't matter; just need to make some kind of // dummy request. Should favor requests that all users have access to and // that don't require request bodies - await this.sdk.getUserLoginType(); + await this.sdk.getAuthenticatedUser(); this.loadedSessionToken = newToken; return true; } catch (err) { diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts index d351a3e3..4245a65a 100644 --- a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts @@ -7,15 +7,15 @@ */ import globalAxios, { type AxiosInstance } from 'axios'; import { - type UserLoginType, + type User, type WorkspacesRequest, type WorkspaceBuildParameter, type WorkspacesResponse, } from '../typesConstants'; type CoderSdkApi = { - getUserLoginType: () => Promise; - getWorkspaces: (options: WorkspacesRequest) => Promise; + getAuthenticatedUser: () => Promise; + getWorkspaces: (request: WorkspacesRequest) => Promise; getWorkspaceBuildParameters: ( workspaceBuildId: string, ) => Promise; @@ -55,11 +55,8 @@ export class CoderSdk implements CoderSdkApi { return response.data; }; - getUserLoginType = async (): Promise => { - const response = await this.axios.get( - '/users/me/login-type', - ); - + getAuthenticatedUser = async (): Promise => { + const response = await this.axios.get('/users/me'); return response.data; }; } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 1e78d4ff..47751269 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -15,13 +15,14 @@ import { mockWorkspaceBuildParameters, } from './mockCoderAppData'; import { + mockBackstageAssetsEndpoint, mockBearerToken, mockCoderAuthToken, mockBackstageApiEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; -import { UserLoginType } from '../typesConstants'; +import { User } from '../typesConstants'; type RestResolver = ResponseResolver< RestRequest, @@ -80,7 +81,7 @@ export function wrappedGet( export const mockServerEndpoints = { workspaces: `${root}/workspaces`, - userLoginType: `${root}/users/me/login-type`, + authenticatedUser: `${root}/users/me`, workspaceBuildParameters: `${root}/workspacebuilds/:workspaceBuildId/parameters`, } as const satisfies Record; @@ -118,11 +119,13 @@ const mainTestHandlers: readonly RestHandler[] = [ }), // This is the dummy request used to verify a user's auth status - wrappedGet(mockServerEndpoints.userLoginType, (_, res, ctx) => { + wrappedGet(mockServerEndpoints.authenticatedUser, (_, res, ctx) => { return res( ctx.status(200), - ctx.json({ - login_type: 'token', + ctx.json({ + id: '1', + avatar_url: `${mockBackstageAssetsEndpoint}/blueberry.png`, + username: 'blueberry', }), ); }), diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index ab16d1ef..788a2dba 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -106,8 +106,12 @@ export type WorkspacesRequest = Readonly<{ q?: string; }>; -// Return value used for the dummy requests used to verify a user's auth status -// for the Coder token auth logic -export type UserLoginType = Readonly<{ - login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +// This is actually the MinimalUser type from Coder core (User extends from +// ReducedUser, which extends from MinimalUser). Don't need all the properties +// until we roll out full SDK support, so going with the least privileged +// type definition for now +export type User = Readonly<{ + id: string; + username: string; + avatar_url: string; }>; From 3c011c853b4db7823c57f9bc71f17582e413e1e0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 30 Apr 2024 17:33:05 +0000 Subject: [PATCH 64/64] fix: add user parsing logic to CoderClient --- .../src/api/CoderClient.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 41d438d7..047c08ca 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -9,6 +9,7 @@ import { CODER_API_REF_ID_PREFIX, WorkspacesRequest, WorkspacesResponse, + User, } from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; @@ -309,7 +310,13 @@ export class CoderClient implements CoderClientApi { // Actual request type doesn't matter; just need to make some kind of // dummy request. Should favor requests that all users have access to and // that don't require request bodies - await this.sdk.getAuthenticatedUser(); + const dummyUser = await this.sdk.getAuthenticatedUser(); + + // Most of the time, we're going to trust the types returned back from the + // server without doing any type-checking, but because this request does + // deal with auth, we're going to do some extra validation steps + assertValidUser(dummyUser); + this.loadedSessionToken = newToken; return true; } catch (err) { @@ -345,6 +352,24 @@ export class CoderClient implements CoderClientApi { }; } +function assertValidUser(value: unknown): asserts value is User { + if (value === null || typeof value !== 'object') { + throw new Error('Returned JSON value is not an object'); + } + + const hasFields = + 'id' in value && + typeof value.id === 'string' && + 'username' in value && + typeof value.username === 'string'; + + if (!hasFields) { + throw new Error( + 'User object is missing expected fields for authentication request', + ); + } +} + export const coderClientApiRef = createApiRef({ id: `${CODER_API_REF_ID_PREFIX}.coder-client`, });