fix(httpapi): return project not found errors (#28856)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user