diff --git a/apps/webapp/app/routes/_app._orgaccount.personal-access-tokens/route.tsx b/apps/webapp/app/routes/_app._orgaccount.personal-access-tokens/route.tsx new file mode 100644 index 00000000000..402a335a186 --- /dev/null +++ b/apps/webapp/app/routes/_app._orgaccount.personal-access-tokens/route.tsx @@ -0,0 +1,172 @@ +import { Form, useFetcher } from "@remix-run/react"; +import { Button } from "~/components/primitives/Buttons"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { ActionFunction, json } from "@remix-run/server-runtime"; +import { requireUserId } from "~/services/session.server"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { personalAccessTokensPath } from "~/utils/pathBuilder"; +import { NamedIcon } from "~/components/primitives/NamedIcon"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { prisma } from "~/db.server"; +import { customAlphabet } from "nanoid"; +import { Spinner } from "~/components/primitives/Spinner"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { + PageDescription, + PageHeader, + PageTitle, + PageTitleRow, +} from "~/components/primitives/PageHeader"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Badge } from "~/components/primitives/Badge"; +import { cn } from "~/utils/cn"; + +export const loader = async ({ request }: { request: Request }) => { + const userid = await requireUserId(request); + const tokens = await prisma.personalAccessToken.findMany({ + where: { + userId: userid, + }, + }); + return typedjson({ tokens }); +}; + +export const action: ActionFunction = async ({ request }) => { + const userId = await requireUserId(request); + try { + const apiKeyId = customAlphabet( + "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + 12 + ); + + const personalAccessToken = `tr_pat_${apiKeyId(20)}`; + + await prisma.personalAccessToken.create({ + data: { + token: personalAccessToken, + userId: userId, + }, + }); + + return redirectWithSuccessMessage( + personalAccessTokensPath(), + request, + "Personal Token Access Generated." + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } +}; + +export default function Page() { + const { tokens } = useTypedLoaderData(); + const fetcher = useFetcher(); + + const isLoading = + fetcher.state === "submitting" || + (fetcher.state === "loading" && fetcher.formMethod === "DELETE"); + + const badgeClass = + "py-1 px-1.5 text-xs font-normal inline-flex items-center justify-center whitespace-nowrap rounded-sm"; + + return ( + + + + + + Manage your Personal Access Tokens. + + + + + + Token + Last accessed at + Status + Action + + + + {tokens.length > 0 && + tokens.map((token) => { + return ( + + + + + + {token.lastAccessedAt ? ( + + ) : ( + "Not used yet" + )} + + + {token.revokedAt === null ? ( + + Active + + ) : ( + + Revoked + + )} + + + + + + + + ); + })} + {tokens.length === 0 &&

You have no generatde tokens

} +
+
+ +
+
+ + Generate Token + + } + /> + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app._orgaccount/route.tsx b/apps/webapp/app/routes/_app._orgaccount/route.tsx index f23f336f5d0..803310215f5 100644 --- a/apps/webapp/app/routes/_app._orgaccount/route.tsx +++ b/apps/webapp/app/routes/_app._orgaccount/route.tsx @@ -15,6 +15,7 @@ import { organizationsPath, accountPath, logoutPath, + personalAccessTokensPath, } from "~/utils/pathBuilder"; export default function Page() { @@ -38,6 +39,7 @@ export default function Page() { tabs={[ { label: "Organizations", to: organizationsPath() }, { label: "Account", to: accountPath() }, + { label: "Personal Access Tokens", to: personalAccessTokensPath() }, ]} /> diff --git a/apps/webapp/app/routes/api.v2.cancel-job.ts b/apps/webapp/app/routes/api.v2.cancel-job.ts new file mode 100644 index 00000000000..5f5d5cd8e9a --- /dev/null +++ b/apps/webapp/app/routes/api.v2.cancel-job.ts @@ -0,0 +1,44 @@ +import type { ActionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { authenticateApiRequest } from "~/services/PATAuth.server"; +import { generateErrorMessage } from "zod-error"; +import { CancelRunService } from "~/services/runs/cancelRun.server"; +import { logger } from "~/services/logger.server"; +import { CancelJobSchema } from "@trigger.dev/core"; + + +export async function action({ request }: ActionArgs) { + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Personal Access Token" }, { status: 401 }); + } + const anyBody = await request.json(); + const body = CancelJobSchema.safeParse(anyBody); + + if (!body.success) { + return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 }); + } + const { runId } = body.data; + + try { + const cancelRunService = new CancelRunService(); + await cancelRunService.call({ runId }); + + return json({ message: "Canceled run. Any pending tasks will be canceled." }, { status: 200 }); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to cancel run", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + return json({ errors: { body: error.message } }, { status: 400 }); + } else { + logger.error("Failed to cancel run", { error }); + return json({ errors: { body: "Unknown error" } }, { status: 400 }); + } + } +} diff --git a/apps/webapp/app/routes/api.v2.job-runs.ts b/apps/webapp/app/routes/api.v2.job-runs.ts new file mode 100644 index 00000000000..22914c4a6f0 --- /dev/null +++ b/apps/webapp/app/routes/api.v2.job-runs.ts @@ -0,0 +1,89 @@ +import type { ActionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { authenticateApiRequest } from "~/services/PATAuth.server"; +import { generateErrorMessage } from "zod-error"; +import { requireUserIdByPAT } from "~/services/session.server"; +import { prisma } from "~/db.server"; +import { JobRunsSchema } from "@trigger.dev/core"; + + +export async function action({ request }: ActionArgs) { + if (request.method.toUpperCase() !== "POST") { + return { status: 405, body: "Method Not Allowed" }; + } + + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Personal Access Token" }, { status: 401 }); + } + const userId = await requireUserIdByPAT(authenticationResult); + + if (!userId) { + return json({ error: "Invalid or Missing Personal Access Token" }, { status: 401 }); + } + const anyBody = await request.json(); + + const body = JobRunsSchema.safeParse(anyBody); + + if (!body.success) { + return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 }); + } + const { jobSlug, projectSlug, organizationSlug, status, environment } = body.data; + + const runs = await prisma.jobRun.findMany({ + select: { + id: true, + number: true, + startedAt: true, + completedAt: true, + createdAt: true, + isTest: true, + status: true, + environment: { + select: { + type: true, + slug: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + }, + version: { + select: { + version: true, + }, + }, + }, + where: { + ...(status ? { status: status } : {}), + job: { + slug: jobSlug, + }, + project: { + slug: projectSlug, + }, + organization: { slug: organizationSlug, members: { some: { userId } } }, + environment: { + ...(environment ? { type: environment } : {}), + OR: [ + { + orgMember: null, + }, + { + orgMember: { + userId, + }, + }, + ], + }, + }, + orderBy: [{ id: "desc" }], + }); + + return json({ + data: runs, + }); +} diff --git a/apps/webapp/app/routes/api.v2.rerun-job.ts b/apps/webapp/app/routes/api.v2.rerun-job.ts new file mode 100644 index 00000000000..534f6a4b5f5 --- /dev/null +++ b/apps/webapp/app/routes/api.v2.rerun-job.ts @@ -0,0 +1,41 @@ +import type { ActionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { authenticateApiRequest } from "~/services/PATAuth.server"; +import { generateErrorMessage } from "zod-error"; +import { ContinueRunService } from "~/services/runs/continueRun.server"; +import { ReRunService } from "~/services/runs/reRun.server"; +import { RerunJobSchema } from "@trigger.dev/core"; + +export async function action({ request }: ActionArgs) { + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Personal Access Token" }, { status: 401 }); + } + const anyBody = await request.json(); + + const body = RerunJobSchema.safeParse(anyBody); + + if (!body.success) { + return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 }); + } + try { + const { runId, intent } = body.data + if (intent === "start") { + const rerunService = new ReRunService(); + const run = await rerunService.call({ runId }); + + if (!run) { + return json({ message: "Unable to retry run" }, { status: 400 }); + } + return json({ message: `Created new run`, runId: run.id }); + } else if (intent === "continue") { + + const continueService = new ContinueRunService(); + await continueService.call({ runId }); + return json({ message: `Resuming run ${runId}` }); + } + } catch (error) { + return json({ errors: { body: (error as Error).message } }, { status: 400 }); + } +} diff --git a/apps/webapp/app/routes/api.v2.test-job.ts b/apps/webapp/app/routes/api.v2.test-job.ts new file mode 100644 index 00000000000..e257161f1fd --- /dev/null +++ b/apps/webapp/app/routes/api.v2.test-job.ts @@ -0,0 +1,36 @@ +import type { ActionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { authenticateApiRequest } from "~/services/PATAuth.server"; +import { TestJobService } from "~/services/jobs/testJob.server"; +import { generateErrorMessage } from "zod-error"; +import { TestJobSchema } from "@trigger.dev/core"; + +export async function action({ request }: ActionArgs) { + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Personal Access Token" }, { status: 401 }); + } + const anyBody = await request.json(); + + const body = TestJobSchema.safeParse(anyBody); + + if (!body.success) { + return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 }); + } + + const { environmentId, payload, versionId } = body.data + + const testService = new TestJobService(); + const run = await testService.call({ + environmentId: environmentId, + payload: payload, + versionId: versionId, + }); + + if (!run) { + return json({ error: "Unable to start a test run: Something went wrong" }, { status: 500 }); + } + + return json({ message: `Test run created for ${run.id}` }); +} diff --git a/apps/webapp/app/routes/personal-access-tokens.$tokenId.ts b/apps/webapp/app/routes/personal-access-tokens.$tokenId.ts new file mode 100644 index 00000000000..e7b3ee3922a --- /dev/null +++ b/apps/webapp/app/routes/personal-access-tokens.$tokenId.ts @@ -0,0 +1,72 @@ +import { ActionFunction } from "@remix-run/node"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { + jsonWithErrorMessage, + jsonWithSuccessMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; + +const ParamSchema = z.object({ + tokenId: z.string(), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const { tokenId } = ParamSchema.parse(params); + const userId = await requireUserId(request); + + const token = await prisma.personalAccessToken.findFirst({ + where: { + id: tokenId, + userId: userId + }, + }); + + if (!token) { + return jsonWithErrorMessage({ ok: false }, request, `Token doesn't exist.`); + } + + try { + await prisma.personalAccessToken.update({ + where: { + id: tokenId, + }, + data: { + revokedAt: new Date() + } + }) + + const url = new URL(request.url); + const redirectTo = url.searchParams.get("redirectTo"); + + logger.debug("Token revoked", { + url, + redirectTo, + job: token, + }); + + if (typeof redirectTo === "string" && redirectTo.length > 0) { + return redirectWithSuccessMessage( + redirectTo, + request, + `Token has been revoked.` + ); + } + + return jsonWithSuccessMessage( + { ok: true }, + request, + `Token has been revoked.` + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + return jsonWithErrorMessage( + { ok: false }, + request, + `Token could not be revoked: ${message}` + ); + } +}; diff --git a/apps/webapp/app/services/PATAuth.server.ts b/apps/webapp/app/services/PATAuth.server.ts new file mode 100644 index 00000000000..9295faceb18 --- /dev/null +++ b/apps/webapp/app/services/PATAuth.server.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { prisma } from "~/db.server"; + +const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/); + +type ApiAuthenticationResult = undefined | string; + +export async function authenticateApiRequest(request: Request): Promise { + const token = getApiKeyFromRequest(request); + + if (!token) { + return; + } + const isValidKey = await prisma.personalAccessToken.findFirst({ + where: { + token: token, + }, + }); + + if (isValidKey) { + await prisma.personalAccessToken.update({ + where: { + token: token, + }, + data: { + lastAccessedAt: new Date(), + }, + }); + return token; + } + + return; +} + +export function getApiKeyFromRequest(request: Request) { + const rawAuthorization = request.headers.get("Authorization"); + + const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization); + if (!authorization.success) { + return; + } + const token = authorization.data.replace(/^Bearer /, ""); + return token; +} diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 8240ca2e7f9..ae0fcbdda63 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -2,6 +2,7 @@ import { redirect } from "@remix-run/node"; import { getUserById } from "~/models/user.server"; import { authenticator } from "./auth.server"; import { getImpersonationId } from "./impersonation.server"; +import { prisma } from "~/db.server"; export async function getUserId(request: Request): Promise { const impersonatedUserId = await getImpersonationId(request); @@ -22,6 +23,21 @@ export async function getUser(request: Request) { throw await logout(request); } +export async function requireUserIdByPAT(personalAccessToken: string) { + const data = await prisma.personalAccessToken.findUnique({ + where: { + token: personalAccessToken + }, + include: { + user: true + }, + }); + if (!data?.user.id) { + return; + } + return data?.user.id; +} + export async function requireUserId(request: Request, redirectTo?: string) { const userId = await getUserId(request); if (!userId) { diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index ccd5388f5c0..9f5474a9f53 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -65,6 +65,10 @@ export function accountPath() { return `/account`; } +export function personalAccessTokensPath() { + return `/personal-access-tokens`; +} + export function invitesPath() { return `/invites`; } diff --git a/packages/core/src/schemas/api.ts b/packages/core/src/schemas/api.ts index 112c33191ae..4e20f677c40 100644 --- a/packages/core/src/schemas/api.ts +++ b/packages/core/src/schemas/api.ts @@ -729,3 +729,90 @@ export const CreateExternalConnectionBodySchema = z.object({ }); export type CreateExternalConnectionBody = z.infer; + +/* TriggerManagmentAPI Schemas */ + +export const CancelJobSchema = z.object({ + runId: z.string(), +}); + +export type CancelJobSchemaInput = z.infer; + +export const JobRunsSchema = z.object({ + organizationSlug: z.string(), + projectSlug: z.string(), + jobSlug: z.string(), + status: z + .enum([ + "PENDING", + "QUEUED", + "WAITING_ON_CONNECTIONS", + "PREPROCESSING", + "STARTED", + "SUCCESS", + "FAILURE", + "TIMED_OUT", + "ABORTED", + "CANCELED", + ]) + .optional(), + environment: z.enum(["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"]).optional(), +}); + +export type JobRunsSchemaInput = z.infer; + +export const RerunJobSchema = z.object({ + runId: z.string(), + intent: z.union([z.literal("start"), z.literal("continue")]), +}); + +export type RerunJobSchemaInput = z.infer; + +export const TestJobSchema = z.object({ + environmentId: z.string(), + payload: z.any(), + versionId: z.string(), +}); +export type TestJobSchemaInput = z.infer; + +export const JobRunsResponseSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + number: z.number(), + startedAt: z.union([z.coerce.date(), z.null()]), + completedAt: z.union([z.coerce.date(), z.null()]), + createdAt: z.coerce.date(), + isTest: z.boolean(), + status: z.enum([ + "PENDING", + "QUEUED", + "WAITING_ON_CONNECTIONS", + "PREPROCESSING", + "STARTED", + "SUCCESS", + "FAILURE", + "TIMED_OUT", + "ABORTED", + "CANCELED", + ]), + environment: z.object({ + type: z.enum(["DEVELOPMENT", "PREVIEW", "PRODUCTION", "STAGING"]), + slug: z.string(), + orgMember: z.union([z.object({ userId: z.string() }), z.null()]), + }), + version: z.object({ + version: z.string(), + }), + }) + ), +}); + +export type JobRunsResponse = z.infer; + +export const TriggerManagementResponseSchema = z.object({ + errors: z.object({ body: z.string() }).optional(), + message: z.string().optional(), +}); + +export type TriggerManagementResponse = z.infer; diff --git a/packages/database/prisma/migrations/20230824115107_add_personal_access_tokens/migration.sql b/packages/database/prisma/migrations/20230824115107_add_personal_access_tokens/migration.sql new file mode 100644 index 00000000000..9b5c3ca4243 --- /dev/null +++ b/packages/database/prisma/migrations/20230824115107_add_personal_access_tokens/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "PersonalAccessToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "revokedAt" TIMESTAMP(3), + "lastAccessedAt" TIMESTAMP(3), + "userId" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token"); + +-- AddForeignKey +ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 2878bc638cf..7b2c4eca4ce 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -38,8 +38,18 @@ model User { sentInvites OrgMemberInvite[] apiVotes ApiIntegrationVote[] - invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) - invitationCodeId String? + invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) + invitationCodeId String? + PersonalAccessToken PersonalAccessToken[] +} + +model PersonalAccessToken { + id String @id @default(cuid()) + token String @unique + revokedAt DateTime? + lastAccessedAt DateTime? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) } // @deprecated This model is no longer used as the Cloud is out of private beta diff --git a/packages/trigger-sdk/src/TriggerManagementAPI.ts b/packages/trigger-sdk/src/TriggerManagementAPI.ts new file mode 100644 index 00000000000..378c3b6d42f --- /dev/null +++ b/packages/trigger-sdk/src/TriggerManagementAPI.ts @@ -0,0 +1,167 @@ +import { + LogLevel, + Logger, + CancelJobSchema, + TriggerManagementResponseSchema as ResponseSchema, + CancelJobSchemaInput, + JobRunsSchema, + JobRunsSchemaInput, + RerunJobSchema, + RerunJobSchemaInput, + TestJobSchema, + TestJobSchemaInput, + JobRunsResponseSchema +} from "@trigger.dev/core"; +import fetch, { type RequestInit } from "node-fetch"; +import { z } from "zod"; + +export type ApiClientOptions = { + personalAccessToken: string; + apiUrl?: string; + logLevel?: LogLevel; +}; + +export class TriggerManagementAPI { + #apiUrl: string; + #options: ApiClientOptions; + #logger: Logger; + + constructor(options: ApiClientOptions) { + this.#options = options; + this.#apiUrl = this.#options.apiUrl ?? process.env.TRIGGER_API_URL ?? "https://api.trigger.dev"; + this.#logger = new Logger("trigger.dev", this.#options.logLevel); + } + + async cancelJob(input: CancelJobSchemaInput) { + const { runId } = CancelJobSchema.parse(input) + const apiKey = this.#options.personalAccessToken; + + this.#logger.debug("canceling job", { + runId, + }); + + const response = await zodfetch(ResponseSchema, `${this.#apiUrl}/api/v2/cancel-job`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ runId }), + }); + return response + } + + async getJobRuns(input: JobRunsSchemaInput) { + const { organizationSlug, projectSlug, jobSlug, status, environment } = JobRunsSchema.parse(input) + + const apiKey = this.#options.personalAccessToken; + + this.#logger.debug("getting job runs", { + organizationSlug, + projectSlug, + jobSlug, + status, + environment, + }); + + const response = await zodfetch(JobRunsResponseSchema, `${this.#apiUrl}/api/v2/job-runs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + organizationSlug, + projectSlug, + jobSlug, + status, + environment, + }), + }); + + return response; + } + + async rerunJob(input: RerunJobSchemaInput) { + const { runId, intent } = RerunJobSchema.parse(input) + + const apiKey = this.#options.personalAccessToken; + + this.#logger.debug("rerunning job", { + runId, + intent, + }); + + const response = await zodfetch(ResponseSchema, `${this.#apiUrl}/api/v2/rerun-job`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ runId, intent }), + }); + + return response + } + + async testJob(input: TestJobSchemaInput) { + const { environmentId, payload, versionId } = TestJobSchema.parse(input) + + const apiKey = this.#options.personalAccessToken; + + this.#logger.debug("testing job", { + environmentId, + payload, + versionId, + }); + + const response = await zodfetch(ResponseSchema, `${this.#apiUrl}/api/v2/test-job`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ environmentId, payload, versionId }), + }); + return response + } + +} + +async function zodfetch( + schema: z.Schema, + url: string, + requestInit?: RequestInit, + options?: { + errorMessage?: string; + optional?: TOptional; + } +): Promise { + const response = await fetch(url, requestInit); + + if ( + (!requestInit || requestInit.method === "GET") && + response.status === 404 && + options?.optional + ) { + // @ts-ignore + return; + } + + if (response.status >= 400 && response.status < 500) { + const text = await response.text(); + console.log(text); + const body = JSON.parse(text) + throw new Error(body.error); + } + + if (response.status !== 200) { + throw new Error( + options?.errorMessage ?? `Failed to fetch ${url}, got status code ${response.status}` + ); + } + + const jsonBody = await response.json(); + return schema.parse(jsonBody); +} +