Skip to content

Refactor REST client #286

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 5 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
61 changes: 61 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Api } from "coder/site/src/api/api"
import fs from "fs/promises"
import * as https from "https"
import * as os from "os"
import * as vscode from "vscode"
import { CertificateError } from "./error"
import { Storage } from "./storage"

// expandPath will expand ${userHome} in the input string.
const expandPath = (input: string): string => {
const userHome = os.homedir()
return input.replace(/\${userHome}/g, userHome)
}

/**
* Create an sdk instance using the provided URL and token and hook it up to
* configuration. The token may be undefined if some other form of
* authentication is being used.
*/
export async function makeCoderSdk(baseUrl: string, token: string | undefined, storage: Storage): Promise<Api> {
const restClient = new Api()
restClient.setHost(baseUrl)
if (token) {
restClient.setSessionToken(token)
}

restClient.getAxiosInstance().interceptors.request.use(async (config) => {
Copy link
Member

Choose a reason for hiding this comment

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

Not a huge deal, but is there a reason why the Axios instance isn't a separate variable?

Copy link
Member

Choose a reason for hiding this comment

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

Though I guess another option would be to add both interceptors in one call of interceptors.request.use

Copy link
Member Author

@code-asher code-asher May 30, 2024

Choose a reason for hiding this comment

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

Oh like why not split out into const axios = restClient.getAxiosInstance() type of thing?

Could do that, I tend to like leaving things on their objects and accessing them that way, mostly just a preference thing I think. I think maybe I like having them "namespaced" or something like that.

Edit: to add that this only applies to simple getters, if the function does work or the return value can change I would encapsulate into a var for sure

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 and one is a request interceptor and the other a response interceptor so I think they have to be separate calls

Copy link
Member

Choose a reason for hiding this comment

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

Oh, wait – you're right. Totally missed that they were different names

// Add headers from the header command.
Object.entries(await storage.getHeaders(baseUrl)).forEach(([key, value]) => {
config.headers[key] = value
})

// Configure TLS.
const cfg = vscode.workspace.getConfiguration()
const insecure = Boolean(cfg.get("coder.insecure"))
const certFile = expandPath(String(cfg.get("coder.tlsCertFile") ?? "").trim())
const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim())
const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim())

config.httpsAgent = new https.Agent({
cert: certFile === "" ? undefined : await fs.readFile(certFile),
key: keyFile === "" ? undefined : await fs.readFile(keyFile),
ca: caFile === "" ? undefined : await fs.readFile(caFile),
// rejectUnauthorized defaults to true, so we need to explicitly set it to false
// if we want to allow self-signed certificates.
rejectUnauthorized: !insecure,
})

return config
})

// Wrap certificate errors.
restClient.getAxiosInstance().interceptors.response.use(
(r) => r,
async (err) => {
throw await CertificateError.maybeWrap(err, baseUrl, storage)
},
)

return restClient
}
212 changes: 139 additions & 73 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from "axios"
import { getAuthenticatedUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api"
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
import { Api } from "coder/site/src/api/api"
import { getErrorMessage } from "coder/site/src/api/errors"
import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
import * as vscode from "vscode"
import { extractAgents } from "./api-helper"
import { CertificateError } from "./error"
Expand All @@ -9,8 +9,20 @@ import { Storage } from "./storage"
import { OpenableTreeItem } from "./workspacesProvider"

export class Commands {
// These will only be populated when actively connected to a workspace and are
// used in commands. Because commands can be executed by the user, it is not
// possible to pass in arguments, so we have to store the current workspace
// and its client somewhere, separately from the current globally logged-in
// client, since you can connect to workspaces not belonging to whatever you
// are logged into (for convenience; otherwise the recents menu can be a pain
// if you use multiple deployments).
public workspace?: Workspace
public workspaceLogPath?: string
public workspaceRestClient?: Api

public constructor(
private readonly vscodeProposed: typeof vscode,
private readonly restClient: Api,
private readonly storage: Storage,
) {}

Expand Down Expand Up @@ -82,7 +94,9 @@ export class Commands {
if (!url) {
return
}
this.restClient.setHost(url)

let user: User | undefined
let token: string | undefined = args.length >= 2 ? args[1] : undefined
if (!token) {
const opened = await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`))
Expand All @@ -97,125 +111,163 @@ export class Commands {
placeHolder: "Copy your API key from the opened browser page.",
value: await this.storage.getSessionToken(),
ignoreFocusOut: true,
validateInput: (value) => {
return axios
.get("/api/v2/users/me", {
baseURL: url,
headers: {
"Coder-Session-Token": value,
},
})
.then(() => {
return undefined
})
.catch((err) => {
if (err instanceof CertificateError) {
err.showNotification()

return {
message: err.x509Err || err.message,
severity: vscode.InputBoxValidationSeverity.Error,
}
}
// This could be something like the header command erroring or an
// invalid session token.
const message =
err?.response?.data?.detail || err?.message || err?.response?.status || "no response from the server"
validateInput: async (value) => {
this.restClient.setSessionToken(value)
try {
user = await this.restClient.getAuthenticatedUser()
if (!user) {
throw new Error("Failed to get authenticated user")
}
} catch (err) {
// For certificate errors show both a notification and add to the
// text under the input box, since users sometimes miss the
// notification.
if (err instanceof CertificateError) {
err.showNotification()

return {
message: "Failed to authenticate: " + message,
message: err.x509Err || err.message,
severity: vscode.InputBoxValidationSeverity.Error,
}
})
}
// This could be something like the header command erroring or an
// invalid session token.
const message = getErrorMessage(err, "no response from the server")
return {
message: "Failed to authenticate: " + message,
severity: vscode.InputBoxValidationSeverity.Error,
}
}
},
})
}
if (!token) {
if (!token || !user) {
return
}

// Store these to be used in later sessions and in the cli.
await this.storage.setURL(url)
await this.storage.setSessionToken(token)
try {
const user = await getAuthenticatedUser()
if (!user) {
throw new Error("Failed to get authenticated user")
}
await vscode.commands.executeCommand("setContext", "coder.authenticated", true)
if (user.roles.find((role) => role.name === "owner")) {
await vscode.commands.executeCommand("setContext", "coder.isOwner", true)
}
vscode.window
.showInformationMessage(
`Welcome to Coder, ${user.username}!`,
{
detail: "You can now use the Coder extension to manage your Coder instance.",
},
"Open Workspace",
)
.then((action) => {
if (action === "Open Workspace") {
vscode.commands.executeCommand("coder.open")
}
})
vscode.commands.executeCommand("coder.refreshWorkspaces")
} catch (error) {
vscode.window.showErrorMessage("Failed to authenticate with Coder: " + error)

await vscode.commands.executeCommand("setContext", "coder.authenticated", true)
if (user.roles.find((role) => role.name === "owner")) {
await vscode.commands.executeCommand("setContext", "coder.isOwner", true)
}

vscode.window
.showInformationMessage(
`Welcome to Coder, ${user.username}!`,
{
detail: "You can now use the Coder extension to manage your Coder instance.",
},
"Open Workspace",
)
.then((action) => {
if (action === "Open Workspace") {
vscode.commands.executeCommand("coder.open")
}
})

// Fetch workspaces for the new deployment.
vscode.commands.executeCommand("coder.refreshWorkspaces")
}

// viewLogs opens the workspace logs.
/**
* View the logs for the currently connected workspace.
*/
public async viewLogs(): Promise<void> {
if (!this.storage.workspaceLogPath) {
vscode.window.showInformationMessage("No logs available.", this.storage.workspaceLogPath || "<unset>")
if (!this.workspaceLogPath) {
vscode.window.showInformationMessage("No logs available.", this.workspaceLogPath || "<unset>")
return
}
const uri = vscode.Uri.file(this.storage.workspaceLogPath)
const uri = vscode.Uri.file(this.workspaceLogPath)
const doc = await vscode.workspace.openTextDocument(uri)
await vscode.window.showTextDocument(doc)
}

/**
* Log out from the currently logged-in deployment.
*/
public async logout(): Promise<void> {
// Clear from the REST client. An empty url will indicate to other parts of
// the code that we are logged out.
this.restClient.setHost("")
this.restClient.setSessionToken("")

// Clear from memory.
await this.storage.setURL(undefined)
await this.storage.setSessionToken(undefined)

await vscode.commands.executeCommand("setContext", "coder.authenticated", false)
vscode.window.showInformationMessage("You've been logged out of Coder!", "Login").then((action) => {
if (action === "Login") {
vscode.commands.executeCommand("coder.login")
}
})

// This will result in clearing the workspace list.
vscode.commands.executeCommand("coder.refreshWorkspaces")
}

/**
* Create a new workspace for the currently logged-in deployment.
*
* Must only be called if currently logged in.
*/
public async createWorkspace(): Promise<void> {
const uri = this.storage.getURL() + "/templates"
const uri = this.storage.getUrl() + "/templates"
await vscode.commands.executeCommand("vscode.open", uri)
}

/**
* Open a link to the workspace in the Coder dashboard.
*
* If passing in a workspace, it must belong to the currently logged-in
* deployment.
*
* Otherwise, the currently connected workspace is used (if any).
*/
public async navigateToWorkspace(workspace: OpenableTreeItem) {
if (workspace) {
const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}`
const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}`
await vscode.commands.executeCommand("vscode.open", uri)
} else if (this.storage.workspace) {
const uri = this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}`
} else if (this.workspace && this.workspaceRestClient) {
const baseUrl = this.workspaceRestClient.getAxiosInstance().defaults.baseURL
const uri = `${baseUrl}/@${this.workspace.owner_name}/${this.workspace.name}`
await vscode.commands.executeCommand("vscode.open", uri)
} else {
vscode.window.showInformationMessage("No workspace found.")
}
}

/**
* Open a link to the workspace settings in the Coder dashboard.
*
* If passing in a workspace, it must belong to the currently logged-in
* deployment.
*
* Otherwise, the currently connected workspace is used (if any).
*/
public async navigateToWorkspaceSettings(workspace: OpenableTreeItem) {
if (workspace) {
const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings`
const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings`
await vscode.commands.executeCommand("vscode.open", uri)
} else if (this.storage.workspace) {
const uri =
this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}/settings`
} else if (this.workspace && this.workspaceRestClient) {
const baseUrl = this.workspaceRestClient.getAxiosInstance().defaults.baseURL
const uri = `${baseUrl}/@${this.workspace.owner_name}/${this.workspace.name}/settings`
await vscode.commands.executeCommand("vscode.open", uri)
} else {
vscode.window.showInformationMessage("No workspace found.")
}
}

/**
* Open a workspace or agent that is showing in the sidebar.
*
* This essentially just builds the host name and passes it to the VS Code
* Remote SSH extension, so it is not necessary to be logged in, although then
* the sidebar would not have any workspaces in it anyway.
*/
public async openFromSidebar(treeItem: OpenableTreeItem) {
if (treeItem) {
await openWorkspace(
Expand All @@ -228,6 +280,11 @@ export class Commands {
}
}

/**
* Open a workspace belonging to the currently logged-in deployment.
*
* This must only be called if logged into a deployment.
*/
public async open(...args: unknown[]): Promise<void> {
let workspaceOwner: string
let workspaceName: string
Expand All @@ -243,9 +300,10 @@ export class Commands {
let lastWorkspaces: readonly Workspace[]
quickPick.onDidChangeValue((value) => {
quickPick.busy = true
getWorkspaces({
q: value,
})
this.restClient
.getWorkspaces({
q: value,
})
.then((workspaces) => {
lastWorkspaces = workspaces.workspaces
const items: vscode.QuickPickItem[] = workspaces.workspaces.map((workspace) => {
Expand Down Expand Up @@ -348,25 +406,33 @@ export class Commands {
await openWorkspace(workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent)
}

/**
* Update the current workspace. If there is no active workspace connection,
* this is a no-op.
*/
public async updateWorkspace(): Promise<void> {
if (!this.storage.workspace) {
if (!this.workspace || !this.workspaceRestClient) {
return
}
const action = await this.vscodeProposed.window.showInformationMessage(
"Update Workspace",
{
useCustom: true,
modal: true,
detail: `${this.storage.workspace.owner_name}/${this.storage.workspace.name} will be updated then this window will reload to watch the build logs and reconnect.`,
detail: `${this.workspace.owner_name}/${this.workspace.name} will be updated then this window will reload to watch the build logs and reconnect.`,
},
"Update",
)
if (action === "Update") {
await updateWorkspaceVersion(this.storage.workspace)
await this.workspaceRestClient.updateWorkspaceVersion(this.workspace)
}
}
}

/**
* Given a workspace, build the host name, find a directory to open, and pass
* both to the Remote SSH plugin.
*/
async function openWorkspace(
workspaceOwner: string,
workspaceName: string,
Expand Down
Loading