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

This commit is contained in:
Shoubhit Dash
2026-05-22 22:06:20 +05:30
committed by GitHub
parent b368e5adbe
commit 3e1972fd92
8 changed files with 76 additions and 10 deletions

View File

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

View File

@@ -166,6 +166,15 @@ export class PtyForbiddenError extends Schema.TaggedErrorClass<PtyForbiddenError
{ httpApiStatus: 403 }, { 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")( export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
{ {
name: Schema.Literal("NotFoundError"), name: Schema.Literal("NotFoundError"),

View File

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

View File

@@ -4,6 +4,7 @@ import { ProjectID } from "@/project/schema"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" import { InstanceHttpApi } from "../api"
import { ProjectNotFoundError } from "../errors"
import { markInstanceForReload } from "../lifecycle" import { markInstanceForReload } from "../lifecycle"
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
@@ -35,7 +36,16 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
params: { projectID: ProjectID } params: { projectID: ProjectID }
payload: Project.UpdatePayload 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) return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)

View File

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

View File

@@ -177,6 +177,15 @@ const scenarios: Scenario[] = [
}, },
"status", "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 http.protected
.post("/project/git/init", "project.initGit") .post("/project/git/init", "project.initGit")
.mutating() .mutating()

View File

@@ -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 { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { ProjectID } from "../../src/project/schema"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { HEADER as FenceHeader } from "../../src/server/shared/fence" 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", () => it.live("serves path and VCS read endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true }) const dir = yield* tmpdirScoped({ git: true })

View File

@@ -208,4 +208,12 @@ describe("PublicApi OpenAPI v2 errors", () => {
"PtyForbiddenError", "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",
)
})
}) })