Skip to content

Commit 3e1972f

Browse files
authored
fix(httpapi): return project not found errors (anomalyco#28856)
1 parent b368e5a commit 3e1972f

8 files changed

Lines changed: 76 additions & 10 deletions

File tree

packages/opencode/src/project/project.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ export const UpdatePayload = Schema.Struct({
101101
}).annotate({ identifier: "ProjectUpdateInput" })
102102
export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
103103

104+
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
105+
projectID: ProjectID,
106+
}) {}
107+
104108
// ---------------------------------------------------------------------------
105109
// Effect service
106110
// ---------------------------------------------------------------------------
@@ -116,7 +120,7 @@ export interface Interface {
116120
readonly discover: (input: Info) => Effect.Effect<void>
117121
readonly list: () => Effect.Effect<Info[]>
118122
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
119-
readonly update: (input: UpdateInput) => Effect.Effect<Info>
123+
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
120124
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
121125
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
122126
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
@@ -372,7 +376,9 @@ export const layer: Layer.Layer<
372376
const base64 = Buffer.from(buffer).toString("base64")
373377
const mime = AppFileSystem.mimeType(shortest)
374378
const url = `data:${mime};base64,${base64}`
375-
yield* update({ projectID: input.id, icon: { url } })
379+
yield* update({ projectID: input.id, icon: { url } }).pipe(
380+
Effect.catchTag("Project.NotFoundError", () => Effect.void),
381+
)
376382
})
377383

378384
const list = Effect.fn("Project.list")(function* () {
@@ -400,7 +406,7 @@ export const layer: Layer.Layer<
400406
.returning()
401407
.get(),
402408
)
403-
if (!result) throw new Error(`Project not found: ${input.projectID}`)
409+
if (!result) return yield* new NotFoundError({ projectID: input.projectID })
404410
const data = fromRow(result)
405411
yield* emitUpdated(data)
406412
return data

packages/opencode/src/server/routes/instance/httpapi/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ export class PtyForbiddenError extends Schema.TaggedErrorClass<PtyForbiddenError
166166
{ httpApiStatus: 403 },
167167
) {}
168168

169+
export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
170+
"ProjectNotFoundError",
171+
{
172+
projectID: Schema.String,
173+
message: Schema.String,
174+
},
175+
{ httpApiStatus: 404 },
176+
) {}
177+
169178
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
170179
{
171180
name: Schema.Literal("NotFoundError"),

packages/opencode/src/server/routes/instance/httpapi/groups/project.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Project } from "@/project/project"
22
import { ProjectID } from "@/project/schema"
33
import { Schema } from "effect"
44
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
5+
import { ProjectNotFoundError } from "../errors"
56
import { Authorization } from "../middleware/authorization"
67
import { InstanceContextMiddleware } from "../middleware/instance-context"
78
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
@@ -53,7 +54,7 @@ export const ProjectApi = HttpApi.make("project")
5354
query: WorkspaceRoutingQuery,
5455
payload: UpdatePayload,
5556
success: described(Project.Info, "Updated project information"),
56-
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
57+
error: [HttpApiError.BadRequest, ProjectNotFoundError],
5758
}).annotateMerge(
5859
OpenApi.annotations({
5960
identifier: "project.update",

packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ProjectID } from "@/project/schema"
44
import { Effect } from "effect"
55
import { HttpApiBuilder } from "effect/unstable/httpapi"
66
import { InstanceHttpApi } from "../api"
7+
import { ProjectNotFoundError } from "../errors"
78
import { markInstanceForReload } from "../lifecycle"
89

910
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
@@ -35,7 +36,16 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
3536
params: { projectID: ProjectID }
3637
payload: Project.UpdatePayload
3738
}) {
38-
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
39+
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
40+
Effect.catchTag("Project.NotFoundError", (error) =>
41+
Effect.fail(
42+
new ProjectNotFoundError({
43+
projectID: error.projectID,
44+
message: `Project not found: ${error.projectID}`,
45+
}),
46+
),
47+
),
48+
)
3949
})
4050

4151
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)

packages/opencode/test/project/project.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const encoder = new TextEncoder()
2222
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
2323
const it = testEffect(layer)
2424

25-
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
25+
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
2626
return Effect.gen(function* () {
2727
const svc = yield* Project.Service
2828
return yield* fn(svc)
@@ -481,7 +481,7 @@ describe("Project.update", () => {
481481
}),
482482
)
483483

484-
it.live("should throw error when project not found", () =>
484+
it.live("should fail when project not found", () =>
485485
Effect.gen(function* () {
486486
const exit = yield* run((svc) =>
487487
svc.update({
@@ -492,9 +492,7 @@ describe("Project.update", () => {
492492
expect(Exit.isFailure(exit)).toBe(true)
493493
if (Exit.isFailure(exit)) {
494494
const error = Cause.squash(exit.cause)
495-
expect(error instanceof Error ? error.message : String(error)).toContain(
496-
"Project not found: nonexistent-project-id",
497-
)
495+
expect(error).toMatchObject({ _tag: "Project.NotFoundError", projectID: "nonexistent-project-id" })
498496
}
499497
}),
500498
)

packages/opencode/test/server/httpapi-exercise/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,15 @@ const scenarios: Scenario[] = [
177177
},
178178
"status",
179179
),
180+
http.protected
181+
.patch("/project/{projectID}", "project.update.missing")
182+
.mutating()
183+
.at((ctx) => ({
184+
path: route("/project/{projectID}", { projectID: "project_httpapi_missing" }),
185+
headers: ctx.headers(),
186+
body: { name: "Missing Project" },
187+
}))
188+
.json(404, object, "status"),
180189
http.protected
181190
.post("/project/git/init", "project.initGit")
182191
.mutating()

packages/opencode/test/server/httpapi-instance.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/co
99
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
1010
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
1111
import { PermissionID } from "../../src/permission/schema"
12+
import { ProjectID } from "../../src/project/schema"
1213
import { QuestionID } from "../../src/question/schema"
1314
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
1415
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
@@ -205,6 +206,30 @@ describe("instance HttpApi", () => {
205206
}),
206207
)
207208

209+
it.live("returns typed not found bodies for missing projects", () =>
210+
Effect.gen(function* () {
211+
const dir = yield* tmpdirScoped({ git: true })
212+
const projectID = ProjectID.make("project_missing")
213+
const response = yield* Effect.promise(() =>
214+
HttpApiApp.webHandler().handler(
215+
new Request(`http://localhost/project/${projectID}`, {
216+
method: "PATCH",
217+
headers: { "x-opencode-directory": dir, "content-type": "application/json" },
218+
body: JSON.stringify({ name: "Missing" }),
219+
}),
220+
handlerContext,
221+
),
222+
)
223+
224+
expect(response.status).toBe(404)
225+
expect(yield* Effect.promise(() => response.json())).toEqual({
226+
_tag: "ProjectNotFoundError",
227+
projectID,
228+
message: `Project not found: ${projectID}`,
229+
})
230+
}),
231+
)
232+
208233
it.live("serves path and VCS read endpoints", () =>
209234
Effect.gen(function* () {
210235
const dir = yield* tmpdirScoped({ git: true })

packages/opencode/test/server/httpapi-public-openapi.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,12 @@ describe("PublicApi OpenAPI v2 errors", () => {
208208
"PtyForbiddenError",
209209
)
210210
})
211+
212+
test("documents project not-found errors", () => {
213+
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
214+
215+
expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
216+
"ProjectNotFoundError",
217+
)
218+
})
211219
})

0 commit comments

Comments
 (0)