Просмотр исходного кода

fix(httpapi): return project not found errors (#28856)

Shoubhit Dash 3 месяцев назад
Родитель
Сommit
3e1972fd92

+ 9 - 3
packages/opencode/src/project/project.ts

@@ -101,6 +101,10 @@ export const UpdatePayload = Schema.Struct({
 }).annotate({ identifier: "ProjectUpdateInput" })
 export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
 
+export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
+  projectID: ProjectID,
+}) {}
+
 // ---------------------------------------------------------------------------
 // Effect service
 // ---------------------------------------------------------------------------
@@ -116,7 +120,7 @@ export interface Interface {
   readonly discover: (input: Info) => Effect.Effect<void>
   readonly list: () => Effect.Effect<Info[]>
   readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
-  readonly update: (input: UpdateInput) => Effect.Effect<Info>
+  readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
   readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
   readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
   readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
@@ -372,7 +376,9 @@ export const layer: Layer.Layer<
       const base64 = Buffer.from(buffer).toString("base64")
       const mime = AppFileSystem.mimeType(shortest)
       const url = `data:${mime};base64,${base64}`
-      yield* update({ projectID: input.id, icon: { url } })
+      yield* update({ projectID: input.id, icon: { url } }).pipe(
+        Effect.catchTag("Project.NotFoundError", () => Effect.void),
+      )
     })
 
     const list = Effect.fn("Project.list")(function* () {
@@ -400,7 +406,7 @@ export const layer: Layer.Layer<
           .returning()
           .get(),
       )
-      if (!result) throw new Error(`Project not found: ${input.projectID}`)
+      if (!result) return yield* new NotFoundError({ projectID: input.projectID })
       const data = fromRow(result)
       yield* emitUpdated(data)
       return data

+ 9 - 0
packages/opencode/src/server/routes/instance/httpapi/errors.ts

@@ -166,6 +166,15 @@ export class PtyForbiddenError extends Schema.TaggedErrorClass<PtyForbiddenError
   { httpApiStatus: 403 },
 ) {}
 
+export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
+  "ProjectNotFoundError",
+  {
+    projectID: Schema.String,
+    message: Schema.String,
+  },
+  { httpApiStatus: 404 },
+) {}
+
 export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
   {
     name: Schema.Literal("NotFoundError"),

+ 2 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/project.ts

@@ -2,6 +2,7 @@ import { Project } from "@/project/project"
 import { ProjectID } from "@/project/schema"
 import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { ProjectNotFoundError } from "../errors"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
@@ -53,7 +54,7 @@ export const ProjectApi = HttpApi.make("project")
           query: WorkspaceRoutingQuery,
           payload: UpdatePayload,
           success: described(Project.Info, "Updated project information"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ProjectNotFoundError],
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "project.update",

+ 11 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts

@@ -4,6 +4,7 @@ import { ProjectID } from "@/project/schema"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
+import { ProjectNotFoundError } from "../errors"
 import { markInstanceForReload } from "../lifecycle"
 
 export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
@@ -35,7 +36,16 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
       params: { projectID: ProjectID }
       payload: Project.UpdatePayload
     }) {
-      return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
+      return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
+        Effect.catchTag("Project.NotFoundError", (error) =>
+          Effect.fail(
+            new ProjectNotFoundError({
+              projectID: error.projectID,
+              message: `Project not found: ${error.projectID}`,
+            }),
+          ),
+        ),
+      )
     })
 
     return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)

+ 3 - 5
packages/opencode/test/project/project.test.ts

@@ -22,7 +22,7 @@ const encoder = new TextEncoder()
 const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
 const it = testEffect(layer)
 
-function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
+function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
   return Effect.gen(function* () {
     const svc = yield* Project.Service
     return yield* fn(svc)
@@ -481,7 +481,7 @@ describe("Project.update", () => {
     }),
   )
 
-  it.live("should throw error when project not found", () =>
+  it.live("should fail when project not found", () =>
     Effect.gen(function* () {
       const exit = yield* run((svc) =>
         svc.update({
@@ -492,9 +492,7 @@ describe("Project.update", () => {
       expect(Exit.isFailure(exit)).toBe(true)
       if (Exit.isFailure(exit)) {
         const error = Cause.squash(exit.cause)
-        expect(error instanceof Error ? error.message : String(error)).toContain(
-          "Project not found: nonexistent-project-id",
-        )
+        expect(error).toMatchObject({ _tag: "Project.NotFoundError", projectID: "nonexistent-project-id" })
       }
     }),
   )

+ 9 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -177,6 +177,15 @@ const scenarios: Scenario[] = [
       },
       "status",
     ),
+  http.protected
+    .patch("/project/{projectID}", "project.update.missing")
+    .mutating()
+    .at((ctx) => ({
+      path: route("/project/{projectID}", { projectID: "project_httpapi_missing" }),
+      headers: ctx.headers(),
+      body: { name: "Missing Project" },
+    }))
+    .json(404, object, "status"),
   http.protected
     .post("/project/git/init", "project.initGit")
     .mutating()

+ 25 - 0
packages/opencode/test/server/httpapi-instance.test.ts

@@ -9,6 +9,7 @@ import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/co
 import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
 import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
 import { PermissionID } from "../../src/permission/schema"
+import { ProjectID } from "../../src/project/schema"
 import { QuestionID } from "../../src/question/schema"
 import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
 import { HEADER as FenceHeader } from "../../src/server/shared/fence"
@@ -205,6 +206,30 @@ describe("instance HttpApi", () => {
     }),
   )
 
+  it.live("returns typed not found bodies for missing projects", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped({ git: true })
+      const projectID = ProjectID.make("project_missing")
+      const response = yield* Effect.promise(() =>
+        HttpApiApp.webHandler().handler(
+          new Request(`http://localhost/project/${projectID}`, {
+            method: "PATCH",
+            headers: { "x-opencode-directory": dir, "content-type": "application/json" },
+            body: JSON.stringify({ name: "Missing" }),
+          }),
+          handlerContext,
+        ),
+      )
+
+      expect(response.status).toBe(404)
+      expect(yield* Effect.promise(() => response.json())).toEqual({
+        _tag: "ProjectNotFoundError",
+        projectID,
+        message: `Project not found: ${projectID}`,
+      })
+    }),
+  )
+
   it.live("serves path and VCS read endpoints", () =>
     Effect.gen(function* () {
       const dir = yield* tmpdirScoped({ git: true })

+ 8 - 0
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -208,4 +208,12 @@ describe("PublicApi OpenAPI v2 errors", () => {
       "PtyForbiddenError",
     )
   })
+
+  test("documents project not-found errors", () => {
+    const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
+
+    expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
+      "ProjectNotFoundError",
+    )
+  })
 })