8000 refactor: consolidate API logic into CoderClient class (#125) · coder/backstage-plugins@74e7dd3 · GitHub
[go: up one dir, main page]

Skip to content

Commit 74e7dd3

Browse files
authored
refactor: consolidate API logic into CoderClient class (#125)
* wip: commit progress on UrlSync class/hook * refactor: consolidate emoji-testing logic * docs: update comments for clarity * refactor: rename helpers to renderHelpers * wip: finish initial implementation of UrlSync * chore: finish tests for UrlSync class * chore: add mock DiscoveryApi helper * chore: finish tests for useUrlSync * refactor: consolidate mock URL logic for useUrlSync * fix: update test helper to use API list * fix: remove unneeded imports * fix: get tests for all current code passing * fix: remove typo * fix: update 8000 useUrlSync to expose underlying api * refactor: increase data hiding for hook * fix: make useUrlSync tests less dependent on implementation details * refactor: remove reliance on baseUrl argument for fetch calls * refactor: split Backstage error type into separate file * refactor: clean up imports for api file * refactor: split main query options into separate file * consolidate how mock endpoints are defined * fix: remove base URL from auth calls * refactor: consolidate almost all auth logic into CoderAuthProvider * move api file into api directory * fix: revert prop that was changed for debugging * fix: revert prop definition * refactor: extract token-checking logic into middleware for server * refactor: move shared auth key to queryOptions file * docs: add reminder about arrow functions * wip: add initial versions of CoderClient code * wip: delete entire api.ts file * fix: remove temp api escape hatch for useUrlSync * chore: update syncToken logic to use temporary interceptors * refactor: update variable name for clarity * fix: prevent double-cancellation of timeout signals * fix: cleanup timeout logic * refactor: split pseudo-SDK into separate file * fix: resolve issue with conflicting interceptors * chore: improve cleanup logic * fix: update majority of breaking tests * fix: resolve all breaking tests * fix: beef up CoderClient validation logic * chore: commit first passing test for CoderClient * fix: update error-detection logic in test * wip: add all test stubs for CoderClient * chore: add test cases for syncToken's main return type * chore: add more test cases * fix: remove Object.freeze logic * refactor: consolidate mock API endpoints in one spot * wip: commit current test progress * refactor: rename mock API endpoint variable for clarity * chore: finish test for aborting queued requests * chore: finish initial versions of all CoderClient tests * fix: delete helper that was never used * fix: update getWorkspacesByRepo function signature to be more consistent with base function * docs: add comment reminder about arrow functions for CoderClient * docs: add comment explaining use of interceptor logic * fix: update return type of getWorkspacesByRepo function * fix: remove configApi from embedded class properties * fix: update query logic to remove any whitespace * refactor: simplify interceptor removal logic * refactor: update how Backstage SDK is set up * refactor: update dummy request for authenticating * fix: add user parsing logic to CoderClient
1 parent dd2dc38 commit 74e7dd3

23 files changed

+860
-380
lines changed

plugins/backstage-plugin-coder/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@material-ui/icons": "^4.9.1",
4242
"@material-ui/lab": "4.0.0-alpha.61",
4343
"@tanstack/react-query": "4.36.1",
44+
"axios": "^1.6.8",
4445
"use-sync-external-store": "^1.2.1",
4546
"valibot": "^0.28.1"
4647
},
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import {
2+
CODER_AUTH_HEADER_KEY,
3+
CoderClient,
4+
disabledClientError,
5+
} from './CoderClient';
6+
import type { IdentityApi } from '@backstage/core-plugin-api';
7+
import { UrlSync } from './UrlSync';
8+
import { rest } from 'msw';
9+
import { mockServerEndpoints, server, wrappedGet } from '../testHelpers/server';
10+
import { CanceledError } from 'axios';
11+
import { delay } from '../utils/time';
12+
import { mockWorkspacesList } from '../testHelpers/mockCoderAppData';
13+
import type { Workspace, WorkspacesResponse } from '../typesConstants';
14+
import {
15+
getMockConfigApi,
16+
getMockDiscoveryApi,
17+
getMockIdentityApi,
18+
mockCoderAuthToken,
19+
mockCoderWorkspacesConfig,
20+
} from '../testHelpers/mockBackstageData';
21+
22+
type ConstructorApis = Readonly<{
23+
identityApi: IdentityApi;
24+
urlSync: UrlSync;
25+
}>;
26+
27+
function getConstructorApis(): ConstructorApis {
28+
const configApi = getMockConfigApi();
29+
const discoveryApi = getMockDiscoveryApi();
30+
const urlSync = new UrlSync({
31+
apis: { configApi, discoveryApi },
32+
});
33+
34+
const identityApi = getMockIdentityApi();
35+
return { urlSync, identityApi };
36+
}
37+
38+
describe(`${CoderClient.name}`, () => {
39+
describe('syncToken functionality', () => {
40+
it('Will load the provided token into the client if it is valid', async () => {
41+
const client = new CoderClient({ apis: getConstructorApis() });
42+
43+
const syncResult = await client.syncToken(mockCoderAuthToken);
44+
expect(syncResult).toBe(true);
45+
46+
let serverToken: string | null = null;
47+
server.use(
48+
rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => {
49+
serverToken = req.headers.get(CODER_AUTH_HEADER_KEY);
50+
return res(ctx.status(200));
51+
}),
52+
);
53+
54+
await client.sdk.getAuthenticatedUser();
55+
expect(serverToken).toBe(mockCoderAuthToken);
56+
});
57+
58+
it('Will NOT load the provided token into the client if it is invalid', async () => {
59+
const client = new CoderClient({ apis: getConstructorApis() });
60+
61+
const syncResult = await client.syncToken('Definitely not valid');
62+
expect(syncResult).toBe(false);
63+
64+
let serverToken: string | null = null;
65+
server.use(
66+
rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => {
67+
serverToken = req.headers.get(CODER_AUTH_HEADER_KEY);
68+
return res(ctx.status(200));
69+
}),
70+
);
71+
72+
await client.sdk.getAuthenticatedUser();
73+
expect(serverToken).toBe(null);
74+
});
75+
76+
it('Will propagate any other error types to the caller', async () => {
77+
const client = new CoderClient({
78+
// Setting the timeout to 0 will make requests instantly fail from the
79+
// next microtask queue tick
80+
requestTimeoutMs: 0,
81+
apis: getConstructorApis(),
82+
});
83+
84+
server.use(
85+
rest.get(mockServerEndpoints.authenticatedUser, async (_, res, ctx) => {
86+
// MSW is so fast that sometimes it can respond before a forced
87+
// timeout; have to introduce artificial delay (that shouldn't matter
88+
// as long as the abort logic goes through properly)
89+
await delay(2_000);
90+
return res(ctx.status(200));
91+
}),
92+
);
93+
94+
await expect(() => {
95+
return client.syncToken(mockCoderAuthToken);
96+
}).rejects.toThrow(CanceledError);
97+
});
98+
});
99+
100+
describe('cleanupClient functionality', () => {
101+
it('Will prevent any new SDK requests from going through', async () => {
102+
const client = new CoderClient({ apis: getConstructorApis() });
103+
client.cleanupClient();
104+
105+
// Request should fail, even though token is valid
106+
await expect(() => {
107+
return client.syncToken(mockCoderAuthToken);
108+
}).rejects.toThrow(disabledClientError);
109+
110+
await expect(() => {
111+
return client.sdk.getWorkspaces({
112+
q: 'owner:me',
113+
limit: 0,
114+
});
115+
}).rejects.toThrow(disabledClientError);
116+
});
117+
118+
it('Will abort any pending requests', async () => {
119+
const client = new CoderClient({
120+
initialToken: mockCoderAuthToken,
121+
apis: getConstructorApis(),
122+
});
123+
124+
// Sanity check to ensure that request can still go through normally
125+
const workspacesPromise1 = client.sdk.getWorkspaces({
126+
q: 'owner:me',
127+
limit: 0,
128+
});
129+
130+
await expect(workspacesPromise1).resolves.toEqual<WorkspacesResponse>({
131+
workspaces: mockWorkspacesList,
132+
count: mockWorkspacesList.length,
133+
});
134+
135+
const workspacesPromise2 = client.sdk.getWorkspaces({
136+
q: 'owner:me',
137+
limit: 0,
138+
});
139+
client.cleanupClient();
140+
await expect(() => workspacesPromise2).rejects.toThrow();
141+
});
142+
});
143+
144+
// Eventually the Coder SDK is going to get too big to test every single
145+
// function. Focus tests on the functionality specifically being patched in
146+
// for Backstage
147+
describe('Coder SDK', () => {
148+
it('Will remap all workspace icon URLs to use the proxy URL if necessary', async () => {
149+
const apis = getConstructorApis();
150+
const client = new CoderClient({
151+
apis,
152+
initialToken: mockCoderAuthToken,
153+
});
154+
155+
server.use(
156+
wrappedGet(mockServerEndpoints.workspaces, (_, res, ctx) => {
157+
const withRelativePaths = mockWorkspacesList.map<Workspace>(ws => {
158+
return {
159+
...ws,
160+
template_icon: '/emojis/blueberry.svg',
161+
};
162+
});
163+
164+
return res(
165+
ctx.status(200),
166+
ctx.json<WorkspacesResponse>({
167+
workspaces: withRelativePaths,
168+
count: withRelativePaths.length,
169+
}),
170+
);
171+
}),
172+
);
173+
174+
const { workspaces } = await client.sdk.getWorkspaces({
175+
q: 'owner:me',
176+
limit: 0,
177+
});
178+
179+
const { urlSync } = apis;
180+
const apiEndpoint = await urlSync.getApiEndpoint();
181+
182+
const allWorkspacesAreRemapped = !workspaces.some(ws =>
183+
ws.template_icon.startsWith(apiEndpoint),
184+
);
185+
186+
expect(allWorkspacesAreRemapped).toBe(true);
187+
});
188+
189+
it('Lets the user search for workspaces by repo URL', async () => {
190+
const client = new CoderClient({
191+
initialToken: mockCoderAuthToken,
192+
apis: getConstructorApis(),
193+
});
194+
195+
const { workspaces } = await client.sdk.getWorkspacesByRepo(
196+
{ q: 'owner:me' },
197+
mockCoderWorkspacesConfig,
198+
);
199+
200+
const buildParameterGroups = await Promise.all(
201+
workspaces.map(ws =>
202+
client.sdk.getWorkspaceBuildParameters(ws.latest_build.id),
203+
),
204+
);
205+
206+
for (const paramGroup of buildParameterGroups) {
207+
const atLeastOneParamMatchesForGroup = paramGroup.some(param => {
208+
return param.value === mockCoderWorkspacesConfig.repoUrl;
209+
});
210+
211+
expect(atLeastOneParamMatchesForGroup).toBe(true);
212+
}
213+
});
214+
});
215+
});

0 commit comments

Comments
 (0)
0