Skip to content

refactor(Coder plugin): update workspace queries to use updated API endpoint definitions #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 66 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
67cb4b3
wip: commit progress on UrlSync class/hook
Parkreiner Apr 26, 2024
e53d127
refactor: consolidate emoji-testing logic
Parkreiner Apr 26, 2024
f26184f
docs: update comments for clarity
Parkreiner Apr 26, 2024
b54324b
refactor: rename helpers to renderHelpers
Parkreiner Apr 26, 2024
7daf597
wip: finish initial implementation of UrlSync
Parkreiner Apr 26, 2024
86b5acc
chore: finish tests for UrlSync class
Parkreiner Apr 26, 2024
5d14d5a
chore: add mock DiscoveryApi helper
Parkreiner Apr 26, 2024
afd0203
chore: finish tests for useUrlSync
Parkreiner Apr 26, 2024
1e16c82
refactor: consolidate mock URL logic for useUrlSync
Parkreiner Apr 26, 2024
d786e34
fix: update test helper to use API list
Parkreiner Apr 26, 2024
063ecf2
fix: remove unneeded imports
Parkreiner Apr 26, 2024
1d1ab3c
fix: get tests for all current code passing
Parkreiner Apr 26, 2024
58566c8
fix: remove typo
Parkreiner Apr 26, 2024
cd5fef6
fix: update useUrlSync to expose underlying api
Parkreiner Apr 26, 2024
abfd949
refactor: increase data hiding for hook
Parkreiner Apr 26, 2024
26ae96d
fix: make useUrlSync tests less dependent on implementation details
Parkreiner Apr 26, 2024
5aabe86
refactor: remove reliance on baseUrl argument for fetch calls
Parkreiner Apr 26, 2024
425d50f
refactor: split Backstage error type into separate file
Parkreiner Apr 26, 2024
04f0f3e
refactor: clean up imports for api file
Parkreiner Apr 26, 2024
111df43
refactor: split main query options into separate file
Parkreiner Apr 26, 2024
1575f8e
consolidate how mock endpoints are defined
Parkreiner Apr 26, 2024
dc6e75c
fix: remove base URL from auth calls
Parkreiner Apr 26, 2024
fd8b3cb
refactor: consolidate almost all auth logic into CoderAuthProvider
Parkreiner Apr 26, 2024
5af006c
move api file into api directory
Parkreiner Apr 26, 2024
644e632
fix: revert prop that was changed for debugging
Parkreiner Apr 26, 2024
a667b48
fix: revert prop definition
Parkreiner Apr 26, 2024
cca343d
refactor: extract token-checking logic into middleware for server
Parkreiner Apr 26, 2024
aac5a0c
refactor: move shared auth key to queryOptions file
Parkreiner Apr 26, 2024
32fb344
docs: add reminder about arrow functions
Parkreiner Apr 26, 2024
170d451
wip: add initial versions of CoderClient code
Parkreiner Apr 28, 2024
9f548a3
wip: delete entire api.ts file
Parkreiner Apr 28, 2024
340399e
fix: remove temp api escape hatch for useUrlSync
Parkreiner Apr 28, 2024
5e6d812
chore: update syncToken logic to use temporary interceptors
Parkreiner Apr 28, 2024
b8affbd
refactor: update variable name for clarity
Parkreiner Apr 28, 2024
0a306f5
fix: prevent double-cancellation of timeout signals
Parkreiner Apr 28, 2024
24f1c29
fix: cleanup timeout logic
Parkreiner Apr 28, 2024
e2e2034
refactor: split pseudo-SDK into separate file
Parkreiner Apr 28, 2024
09b0c48
fix: resolve issue with conflicting interceptors
Parkreiner Apr 29, 2024
7b534cc
chore: improve cleanup logic
Parkreiner Apr 29, 2024
7dc8b24
fix: update majority of breaking tests
Parkreiner Apr 29, 2024
72edd92
fix: resolve all breaking tests
Parkreiner Apr 29, 2024
8d3ad60
fix: beef up CoderClient validation logic
Parkreiner Apr 29, 2024
af0cae8
chore: commit first passing test for CoderClient
Parkreiner Apr 29, 2024
fb10624
fix: update error-detection logic in test
Parkreiner Apr 29, 2024
239661d
wip: add all test stubs for CoderClient
Parkreiner Apr 29, 2024
cc4e4e7
chore: add test cases for syncToken's main return type
Parkreiner Apr 29, 2024
56bcbbd
chore: add more test cases
Parkreiner Apr 29, 2024
907f78e
fix: remove Object.freeze logic
Parkreiner Apr 29, 2024
94cd97a
refactor: consolidate mock API endpoints in one spot
Parkreiner Apr 29, 2024
648c4a4
wip: commit current test progress
Parkreiner Apr 29, 2024
08d38c6
refactor: rename mock API endpoint variable for clarity
Parkreiner Apr 29, 2024
c8cbba7
chore: finish test for aborting queued requests
Parkreiner Apr 29, 2024
9f1c63e
chore: finish initial versions of all CoderClient tests
Parkreiner Apr 29, 2024
c7fb74e
fix: delete helper that was never used
Parkreiner Apr 29, 2024
549e12e
fix: update getWorkspacesByRepo function signature to be more consist…
Parkreiner Apr 29, 2024
4a7fa14
docs: add comment reminder about arrow functions for CoderClient
Parkreiner Apr 29, 2024
9c1dc25
docs: add comment explaining use of interceptor logic
Parkreiner Apr 29, 2024
c91250d
fix: update return type of getWorkspacesByRepo function
Parkreiner Apr 29, 2024
47a5a68
fix: finish initial implementation of new API logic
Parkreiner Apr 29, 2024
19242ee
wip: commit progress for updating test setup
Parkreiner Apr 29, 2024
ec58a24
fix: update test for CoderClient
Parkreiner Apr 29, 2024
07984be
fix: update more tests
Parkreiner Apr 29, 2024
85a0ca8
fix: get all tests passing
Parkreiner Apr 29, 2024
653221e
chore: remove all build parameter logic
Parkreiner Apr 29, 2024
052dfef
Merge branch 'main' into mes/by-repo
Parkreiner Apr 30, 2024
3ad5186
fix: add check for missing key/value for workspaces query
Parkreiner May 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions plugins/backstage-plugin-coder/src/api/CoderClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { rest } from 'msw';
import { mockServerEndpoints, server, wrappedGet } from '../testHelpers/server';
import { CanceledError } from 'axios';
import { delay } from '../utils/time';
import { mockWorkspacesList } from '../testHelpers/mockCoderAppData';
import {
mockWorkspacesList,
mockWorkspacesListForRepoSearch,
} from '../testHelpers/mockCoderAppData';
import type { Workspace, WorkspacesResponse } from '../typesConstants';
import {
getMockConfigApi,
Expand Down Expand Up @@ -197,19 +200,7 @@ describe(`${CoderClient.name}`, () => {
mockCoderWorkspacesConfig,
);

const buildParameterGroups = await Promise.all(
workspaces.map(ws =>
client.sdk.getWorkspaceBuildParameters(ws.latest_build.id),
),
);

for (const paramGroup of buildParameterGroups) {
const atLeastOneParamMatchesForGroup = paramGroup.some(param => {
return param.value === mockCoderWorkspacesConfig.repoUrl;
});

expect(atLeastOneParamMatchesForGroup).toBe(true);
}
expect(workspaces).toEqual(mockWorkspacesListForRepoSearch);
});
});
});
65 changes: 44 additions & 21 deletions plugins/backstage-plugin-coder/src/api/CoderClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,37 +200,39 @@ export class CoderClient implements CoderClientApi {
request: WorkspacesRequest,
config: CoderWorkspacesConfig,
): Promise<WorkspacesResponse> => {
const { workspaces } = await baseSdk.getWorkspaces(request);
const paramResults = await Promise.allSettled(
workspaces.map(ws =>
this.sdk.getWorkspaceBuildParameters(ws.latest_build.id),
),
if (config.repoUrl === undefined) {
return { workspaces: [], count: 0 };
}

// Have to store value here so that type information doesn't degrade
// back to (string | undefined) inside the .map callback
const stringUrl = config.repoUrl;
const responses = await Promise.allSettled(
config.repoUrlParamKeys.map(key => {
const patchedRequest = {
...request,
q: appendParamToQuery(request.q, key, stringUrl),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are key and stringUrl guaranteed to be strings as opposed to null or undefined?

Copy link
Member Author

@Parkreiner Parkreiner May 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Luckily, they're both guaranteed to be defined

With key, we're getting the values through .map. That doesn't guarantee that we'll have a non-empty array, but it does mean that the mapper callback will only be called if we have a defined element to feed into it. That's one of the benefits of the array methods and for/of over traditional i loops – they can guarantee no null-ish values, because they're going through JS's iterator protocol

And as for stringUrl, it's wonky, but that's also guaranteed to be of type string. If we had this:

const patchedRequest = {
  ...request,
  q: appendParamToQuery(request.q, key, config.repoUrl),
};

config.repoUrl would be type string | undefined. But TS type narrowing helps get around that:

// Rule out that config.repoUrl is not undefined; TS is smart
// enough to shrink size down to just string for all code after
// this if statement
if (config.repoUrl === undefined) {
  return { workspaces: [], count: 0 };
}

// Capture the value after we've proven it's a string only. Because
// this is a const and can't be reassigned, TS knows that undefined
// can never be assigned to this version of the value
const stringUrl = config.repoUrl;

// Normally, the type of a union value would "reset" when you
// have a different callback, because TS can't look at the code and
// make guarantees about whether the value will change before the
// callback runs. But as long as we use the "captured" version of
// the value, it will always be a string and can't be reset
const responses = /* */

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But as long as we use the "captured" version of the value, it will always be a string and can't be reset

I see your comment above now. Thank you for the explanation!

};

return baseSdk.getWorkspaces(patchedRequest);
}),
Comment on lines +210 to +218
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was one of the limitations I ran into with the new API endpoint: you can't make a single request for every repo key that the user adds to their workspaces config. If your config has the repo URL keys [repo_url, alt_repo_url], then a request like this:

owner:me param:"repo_url=url.com" param:"alt_repo_url=url.com"

Won't work, because it will search for workspaces that have both keys, even though realistically, a workspace will only ever have one of them

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a huge deal – the previous logic was using Promise.allSettled, too – but it's not making the API logic that much faster

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, this is just an unfortunate consequence of our search filtering. We have to make opinions, and cannot support both union and intersection type searches easily at the moment.

Our search filter is very limited at present, and there is no effort to improve it in it's current state. It's just too much of a headache is the answer atm.

Another idea I had awhile back worth peaking at: coder/coder#12706

Copy link
Member

@Kira-Pilot Kira-Pilot May 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^ What steven said
I chatted with Steven a little bit about this constraint and I guess our current search filtering cannot support both intersection and union searches. To implement both we'd basically have to duplicate all of this logic, which is already really long.

There's not a shorter syntax because we use SQLc so we must use CASE statements. Here is an open discussion we can return to in the future if this ever becomes more problematic.

Anyway, all this to say I think what you have is great!

);

const matchedWorkspaces: Workspace[] = [];
for (const [index, res] of paramResults.entries()) {
const uniqueWorkspaces = new Map<string, Workspace>();
for (const res of responses) {
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;
}
for (const workspace of res.value.workspaces) {
uniqueWorkspaces.set(workspace.id, workspace);
}
}

const serialized = [...uniqueWorkspaces.values()];
return {
workspaces: matchedWorkspaces,
count: matchedWorkspaces.length,
workspaces: serialized,
count: serialized.length,
};
};

Expand Down Expand Up @@ -352,6 +354,27 @@ export class CoderClient implements CoderClientApi {
};
}

function appendParamToQuery(
query: string | undefined,
key: string,
value: string,
): string {
if (!key || !value) {
return '';
}

const keyValuePair = `param:"${key}=${value}"`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth check that these don't contain empty strings?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good call. Let me fix that really quick

if (!query) {
return keyValuePair;
}

if (query.includes(keyValuePair)) {
return query;
}

return `${query} ${keyValuePair}`;
}

function assertValidUser(value: unknown): asserts value is User {
if (value === null || typeof value !== 'object') {
throw new Error('Returned JSON value is not an object');
Expand Down
14 changes: 0 additions & 14 deletions plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,12 @@ import globalAxios, { type AxiosInstance } from 'axios';
import {
type User,
type WorkspacesRequest,
type WorkspaceBuildParameter,
type WorkspacesResponse,
} from '../typesConstants';

type CoderSdkApi = {
getAuthenticatedUser: () => Promise<User>;
getWorkspaces: (request: WorkspacesRequest) => Promise<WorkspacesResponse>;
getWorkspaceBuildParameters: (
workspaceBuildId: string,
) => Promise<readonly WorkspaceBuildParameter[]>;
};

export class CoderSdk implements CoderSdkApi {
Expand All @@ -45,16 +41,6 @@ export class CoderSdk implements CoderSdkApi {
return response.data;
};

getWorkspaceBuildParameters = async (
workspaceBuildId: string,
): Promise<readonly WorkspaceBuildParameter[]> => {
const response = await this.axios.get<readonly WorkspaceBuildParameter[]>(
`/workspacebuilds/${workspaceBuildId}/parameters`,
);

return response.data;
};

getAuthenticatedUser = async (): Promise<User> => {
const response = await this.axios.get<User>('/users/me');
return response.data;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe(`${CoderWorkspacesCard.name}`, () => {

await user.tripleClick(searchbox);
await user.keyboard('[Backspace]');
await user.keyboard('I can do it - I can do it nine times');
await user.keyboard('I-can-do-it-I-can-do-it-nine-times');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the spaces (1) to account for the mock server logic better, and (2) to account for the fact that the actual Coder API doesn't expect spaces like this, either


await waitFor(() => {
// getAllByRole will throw if there isn't at least one node matched
Expand Down Expand Up @@ -153,12 +153,12 @@ describe(`${CoderWorkspacesCard.name}`, () => {
});

/**
* 2024-03-28 - MES - This is a test case to account for a previous
* limitation around querying workspaces by repo URL.
* For performance reasons, the queries for getting workspaces by repo are
* disabled when the query string is empty.
*
* This limitation no longer exists, so this test should be removed once the
* rest of the codebase is updated to support the new API endpoint for
* searching by build parameter
* Even with the API endpoint for searching workspaces by build parameter,
* you still have to shoot off a bunch of requests just to find everything
* that could possibly match your Backstage deployment's config options.
*/
it('Will not show any workspaces at all when the query text is empty', async () => {
await renderWorkspacesCard({ readEntityData: true });
Expand Down
28 changes: 6 additions & 22 deletions plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Workspace, WorkspaceBuildParameter } from '../typesConstants';
import { cleanedRepoUrl, mockBackstageApiEndpoint } from './mockBackstageData';
import type { Workspace } from '../typesConstants';
import { mockBackstageApiEndpoint } from './mockBackstageData';

/**
* The main mock for a workspace whose repo URL matches cleanedRepoUrl
Expand Down Expand Up @@ -98,23 +98,7 @@ export const mockWorkspacesList: Workspace[] = [
mockWorkspaceNoParameters,
];

export const mockWorkspaceBuildParameters: Record<
string,
readonly WorkspaceBuildParameter[]
> = {
[mockWorkspaceWithMatch.latest_build.id]: [
{ name: 'repo_url', value: cleanedRepoUrl },
],

[mockWorkspaceWithMatch2.latest_build.id]: [
{ name: 'repo_url', value: cleanedRepoUrl },
],

[mockWorkspaceNoMatch.latest_build.id]: [
{ name: 'repo_url', value: 'https://www.github.com/wombo/zom' },
],

[mockWorkspaceNoParameters.latest_build.id]: [
// Intentionally kept empty
],
};
export const mockWorkspacesListForRepoSearch: Workspace[] = [
mockWorkspaceWithMatch,
mockWorkspaceWithMatch2,
];
53 changes: 31 additions & 22 deletions plugins/backstage-plugin-coder/src/testHelpers/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ import { setupServer } from 'msw/node';

import {
mockWorkspacesList,
mockWorkspaceBuildParameters,
mockWorkspacesListForRepoSearch,
} from './mockCoderAppData';
import {
mockBackstageAssetsEndpoint,
mockBearerToken,
mockCoderAuthToken,
mockCoderWorkspacesConfig,
mockBackstageApiEndpoint as root,
} from './mockBackstageData';
import type { Workspace, WorkspacesResponse } from '../typesConstants';
import type { WorkspacesResponse } from '../typesConstants';
import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient';
import { User } from '../typesConstants';

Expand Down Expand Up @@ -87,37 +88,45 @@ export const mockServerEndpoints = {

const mainTestHandlers: readonly RestHandler[] = [
wrappedGet(mockServerEndpoints.workspaces, (req, res, ctx) => {
const queryText = String(req.url.searchParams.get('q'));
const { repoUrl } = mockCoderWorkspacesConfig;
const paramMatcherRe = new RegExp(
`param:"\\w+?=${repoUrl.replace('/', '\\/')}"`,
);

let returnedWorkspaces: Workspace[];
if (queryText === 'owner:me') {
returnedWorkspaces = mockWorkspacesList;
} else {
returnedWorkspaces = mockWorkspacesList.filter(ws =>
ws.name.includes(queryText),
const queryText = String(req.url.searchParams.get('q'));
const requestContainsRepoInfo = paramMatcherRe.test(queryText);

const baseWorkspaces = requestContainsRepoInfo
? mockWorkspacesListForRepoSearch
: mockWorkspacesList;

const customSearchTerms = queryText
.split(' ')
.filter(text => text !== 'owner:me' && !paramMatcherRe.test(text));

if (customSearchTerms.length === 0) {
return res(
ctx.status(200),
ctx.json<WorkspacesResponse>({
workspaces: baseWorkspaces,
count: baseWorkspaces.length,
}),
);
}

const filtered = mockWorkspacesList.filter(ws => {
return customSearchTerms.some(term => ws.name.includes(term));
});

return res(
ctx.status(200),
ctx.json<WorkspacesResponse>({
workspaces: returnedWorkspaces,
count: returnedWorkspaces.length,
workspaces: filtered,
count: filtered.length,
}),
);
}),

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));
}

return res(ctx.status(404));
}),

// This is the dummy request used to verify a user's auth status
wrappedGet(mockServerEndpoints.authenticatedUser, (_, res, ctx) => {
return res(
Expand Down
12 changes: 0 additions & 12 deletions plugins/backstage-plugin-coder/src/typesConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,6 @@ export const workspaceSchema = object({
latest_build: workspaceBuildSchema,
});

export const workspaceBuildParameterSchema = object({
name: string(),
value: string(),
});

export const workspaceBuildParametersSchema = array(
workspaceBuildParameterSchema,
);

export const workspacesResponseSchema = object({
count: number(),
workspaces: array(workspaceSchema),
Expand All @@ -95,9 +86,6 @@ export type WorkspaceStatus = Output<typeof workspaceStatusSchema>;
export type WorkspaceBuild = Output<typeof workspaceBuildSchema>;
export type Workspace = Output<typeof workspaceSchema>;
export type WorkspacesResponse = Output<typeof workspacesResponseSchema>;
export type WorkspaceBuildParameter = Output<
typeof workspaceBuildParameterSchema
>;

export type WorkspacesRequest = Readonly<{
after_id?: string;
Expand Down