Skip to content
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

fix(db): Add a safe db fetch wrapper #10420

Merged
merged 3 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/wild-suits-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/db": patch
---

Fixes some situations where failing requests would not error properly
131 changes: 68 additions & 63 deletions packages/db/src/core/cli/commands/link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ora from 'ora';
import prompts from 'prompts';
import { MISSING_SESSION_ID_ERROR } from '../../../errors.js';
import { PROJECT_ID_FILE, getSessionIdFromFile } from '../../../tokens.js';
import { getAstroStudioUrl } from '../../../utils.js';
import { type Result, getAstroStudioUrl, safeFetch } from '../../../utils.js';

export async function cmd() {
const sessionToken = await getSessionIdFromFile();
Expand Down Expand Up @@ -51,29 +51,31 @@ async function linkProject(id: string) {

async function getWorkspaceId(): Promise<string> {
const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/workspaces.list');
const response = await fetch(linkUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getSessionIdFromFile()}`,
'Content-Type': 'application/json',
const response = await safeFetch(
linkUrl,
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getSessionIdFromFile()}`,
'Content-Type': 'application/json',
},
},
});
if (!response.ok) {
// Unauthorized
if (response.status === 401) {
console.error(
`${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan(
'astro db login'
)} to authenticate and then try linking again.\n\n`
);
(res) => {
// Unauthorized
if (res.status === 401) {
console.error(
`${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan(
'astro db login'
)} to authenticate and then try linking again.\n\n`
);
process.exit(1);
}
console.error(`Failed to fetch user workspace: ${res.status} ${res.statusText}`);
process.exit(1);
}
console.error(`Failed to fetch user workspace: ${response.status} ${response.statusText}`);
process.exit(1);
}
const { data, success } = (await response.json()) as
| { success: false; data: unknown }
| { success: true; data: { id: string }[] };
);

const { data, success } = (await response.json()) as Result<{ id: string }[]>;
if (!success) {
console.error(`Failed to fetch user's workspace.`);
process.exit(1);
Expand All @@ -91,30 +93,32 @@ export async function createNewProject({
region: string;
}) {
const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.create');
const response = await fetch(linkUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getSessionIdFromFile()}`,
'Content-Type': 'application/json',
const response = await safeFetch(
linkUrl,
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getSessionIdFromFile()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceId, name, region }),
},
body: JSON.stringify({ workspaceId, name, region }),
});
if (!response.ok) {
// Unauthorized
if (response.status === 401) {
console.error(
`${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan(
'astro db login'
)} to authenticate and then try linking again.\n\n`
);
(res) => {
// Unauthorized
if (res.status === 401) {
console.error(
`${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan(
'astro db login'
)} to authenticate and then try linking again.\n\n`
);
process.exit(1);
}
console.error(`Failed to create project: ${res.status} ${res.statusText}`);
process.exit(1);
}
console.error(`Failed to create project: ${response.status} ${response.statusText}`);
process.exit(1);
}
const { data, success } = (await response.json()) as
| { success: false; data: unknown }
| { success: true; data: { id: string; idName: string } };
);

const { data, success } = (await response.json()) as Result<{ id: string; idName: string }>;
if (!success) {
console.error(`Failed to create project.`);
process.exit(1);
Expand All @@ -124,30 +128,31 @@ export async function createNewProject({

export async function promptExistingProjectName({ workspaceId }: { workspaceId: string }) {
const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.list');
const response = await fetch(linkUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getSessionIdFromFile()}`,
'Content-Type': 'application/json',
const response = await safeFetch(
linkUrl,
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getSessionIdFromFile()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceId }),
},
body: JSON.stringify({ workspaceId }),
});
if (!response.ok) {
// Unauthorized
if (response.status === 401) {
console.error(
`${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan(
'astro db login'
)} to authenticate and then try linking again.\n\n`
);
(res) => {
if (res.status === 401) {
console.error(
`${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan(
'astro db login'
)} to authenticate and then try linking again.\n\n`
);
process.exit(1);
}
console.error(`Failed to fetch projects: ${res.status} ${res.statusText}`);
process.exit(1);
}
console.error(`Failed to fetch projects: ${response.status} ${response.statusText}`);
process.exit(1);
}
const { data, success } = (await response.json()) as
| { success: false; data: unknown }
| { success: true; data: { id: string; idName: string }[] };
);

const { data, success } = (await response.json()) as Result<{ id: string; idName: string }[]>;
if (!success) {
console.error(`Failed to fetch projects.`);
process.exit(1);
Expand Down
32 changes: 18 additions & 14 deletions packages/db/src/core/cli/commands/push/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Arguments } from 'yargs-parser';
import { MIGRATION_VERSION } from '../../../consts.js';
import { getManagedAppTokenOrExit } from '../../../tokens.js';
import { type DBConfig, type DBSnapshot } from '../../../types.js';
import { getRemoteDatabaseUrl } from '../../../utils.js';
import { type Result, getRemoteDatabaseUrl, safeFetch } from '../../../utils.js';
import {
createCurrentSnapshot,
createEmptySnapshot,
Expand Down Expand Up @@ -82,19 +82,23 @@ async function pushSchema({
return new Response(null, { status: 200 });
}
const url = new URL('/db/push', getRemoteDatabaseUrl());
const response = await fetch(url, {
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${appToken}`,
}),
body: JSON.stringify(requestBody),
});
if (!response.ok) {
console.error(`${url.toString()} failed: ${response.status} ${response.statusText}`);
console.error(await response.text());
throw new Error(`/db/push fetch failed: ${response.status} ${response.statusText}`);
}
const result = (await response.json()) as { success: false } | { success: true };
const response = await safeFetch(
url,
{
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${appToken}`,
}),
body: JSON.stringify(requestBody),
},
async (res) => {
console.error(`${url.toString()} failed: ${res.status} ${res.statusText}`);
console.error(await res.text());
throw new Error(`/db/push fetch failed: ${res.status} ${res.statusText}`);
}
);

const result = (await response.json()) as Result<never>;
if (!result.success) {
console.error(`${url.toString()} unsuccessful`);
console.error(await response.text());
Expand Down
32 changes: 17 additions & 15 deletions packages/db/src/core/cli/migration-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
type NumberColumn,
type TextColumn,
} from '../types.js';
import { getRemoteDatabaseUrl } from '../utils.js';
import { type Result, getRemoteDatabaseUrl, safeFetch } from '../utils.js';

const sqlite = new SQLiteAsyncDialect();
const genTempTableName = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10);
Expand Down Expand Up @@ -425,20 +425,22 @@ export async function getProductionCurrentSnapshot({
}): Promise<DBSnapshot | undefined> {
const url = new URL('/db/schema', getRemoteDatabaseUrl());

const response = await fetch(url, {
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${appToken}`,
}),
});
if (!response.ok) {
console.error(`${url.toString()} failed: ${response.status} ${response.statusText}`);
console.error(await response.text());
throw new Error(`/db/schema fetch failed: ${response.status} ${response.statusText}`);
}
const result = (await response.json()) as
| { success: false; data: undefined }
| { success: true; data: DBSnapshot };
const response = await safeFetch(
url,
{
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${appToken}`,
}),
},
async (res) => {
console.error(`${url.toString()} failed: ${res.status} ${res.statusText}`);
console.error(await res.text());
throw new Error(`/db/schema fetch failed: ${res.status} ${res.statusText}`);
}
);

const result = (await response.json()) as Result<DBSnapshot>;
if (!result.success) {
console.error(`${url.toString()} unsuccessful`);
console.error(await response.text());
Expand Down
43 changes: 28 additions & 15 deletions packages/db/src/core/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { MISSING_PROJECT_ID_ERROR, MISSING_SESSION_ID_ERROR } from './errors.js';
import { getAstroStudioEnv, getAstroStudioUrl } from './utils.js';
import { getAstroStudioEnv, getAstroStudioUrl, safeFetch } from './utils.js';

export const SESSION_LOGIN_FILE = pathToFileURL(join(homedir(), '.astro', 'session-token'));
export const PROJECT_ID_FILE = pathToFileURL(join(process.cwd(), '.astro', 'link'));
Expand Down Expand Up @@ -31,13 +31,20 @@ class ManagedRemoteAppToken implements ManagedAppToken {
renewTimer: NodeJS.Timeout | undefined;

static async create(sessionToken: string, projectId: string) {
const response = await fetch(new URL(`${getAstroStudioUrl()}/auth/cli/token-create`), {
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${sessionToken}`,
}),
body: JSON.stringify({ projectId }),
});
const response = await safeFetch(
new URL(`${getAstroStudioUrl()}/auth/cli/token-create`),
{
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${sessionToken}`,
}),
body: JSON.stringify({ projectId }),
},
(res) => {
throw new Error(`Failed to create token: ${res.status} ${res.statusText}`);
}
);

const { token: shortLivedAppToken, ttl } = await response.json();
return new ManagedRemoteAppToken({
token: shortLivedAppToken,
Expand All @@ -56,14 +63,20 @@ class ManagedRemoteAppToken implements ManagedAppToken {
}

private async fetch(url: string, body: unknown) {
return fetch(`${getAstroStudioUrl()}${url}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.session}`,
'Content-Type': 'application/json',
return safeFetch(
`${getAstroStudioUrl()}${url}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.session}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
body: JSON.stringify(body),
});
() => {
throw new Error(`Failed to fetch ${url}.`);
}
);
}

async renew() {
Expand Down
21 changes: 21 additions & 0 deletions packages/db/src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,24 @@ export function getDbDirectoryUrl(root: URL | string) {
export function defineDbIntegration(integration: AstroDbIntegration): AstroIntegration {
return integration;
}

/**
* Small wrapper around fetch that throws an error if the response is not OK. Allows for custom error handling as well through the onNotOK callback.
*/
export async function safeFetch(
url: Parameters<typeof fetch>[0],
options: Parameters<typeof fetch>[1] = {},
onNotOK: (response: Response) => void | Promise<void> = () => {
throw new Error(`Request to ${url} returned a non-OK status code.`);
}
): Promise<Response> {
const response = await fetch(url, options);

if (!response.ok) {
await onNotOK(response);
}

return response;
}

export type Result<T> = { success: true; data: T } | { success: false; data: unknown };
Loading
Loading