diff --git a/packages/UserGuide.md b/packages/UserGuide.md new file mode 100644 index 0000000..c41aec3 --- /dev/null +++ b/packages/UserGuide.md @@ -0,0 +1,16 @@ + +HTTP endpoints to call Label Service +Test cases were added in 3 different files: +1. HTTP endpoint tests +Tests the core functionality of the endpoints, making sure all CRUD operations are working and returning the correct HTTP response. Also includes some integration tests for testing consecutive operations. +cd packages/opencode && bun test test/server/httpapi-v2-label.test.ts + +2. OpenAPI contract tests +Checks that the machine readable description of the API includes the new API routes (/api/label and /api/label/{labelID}) +cd packages/opencode && bun test test/server/httpapi-public-openapi.test.ts + +3. Schema tests +Tests for the labels schema in packages/schema +cd packages/schema && bun test test/contract-hygiene.test.ts + +These tests are sufficient for ensuring that HTTP endpoints to call Label service works. Alongside adding unit tests for individual CRUD operations, integration tests for consecutive CRUD operations exist as well. The API routes and schema are also testing for further completeness. \ No newline at end of file diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 024c978..8714f07 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -681,6 +681,64 @@ const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({ refresh: Endpoint17_2(raw), }) +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { readonly parentID?: Endpoint18_0Request["query"]["parentID"] } +const Endpoint18_0 = (raw: RawClient["server.label"]) => (input?: Endpoint18_0Input) => + raw["label.list"]({ query: { parentID: input?.["parentID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint18_1Request = Parameters[0] +type Endpoint18_1Input = { readonly labelID: Endpoint18_1Request["params"]["labelID"] } +const Endpoint18_1 = (raw: RawClient["server.label"]) => (input: Endpoint18_1Input) => + raw["label.get"]({ params: { labelID: input["labelID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint18_2Request = Parameters[0] +type Endpoint18_2Input = { + readonly name: Endpoint18_2Request["payload"]["name"] + readonly parentID?: Endpoint18_2Request["payload"]["parentID"] +} +const Endpoint18_2 = (raw: RawClient["server.label"]) => (input: Endpoint18_2Input) => + raw["label.create"]({ payload: { name: input["name"], parentID: input["parentID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint18_3Request = Parameters[0] +type Endpoint18_3Input = { + readonly labelID: Endpoint18_3Request["params"]["labelID"] + readonly name?: Endpoint18_3Request["payload"]["name"] + readonly parentID?: Endpoint18_3Request["payload"]["parentID"] +} +const Endpoint18_3 = (raw: RawClient["server.label"]) => (input: Endpoint18_3Input) => + raw["label.update"]({ + params: { labelID: input["labelID"] }, + payload: { name: input["name"], parentID: input["parentID"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint18_4Request = Parameters[0] +type Endpoint18_4Input = { readonly labelID: Endpoint18_4Request["params"]["labelID"] } +const Endpoint18_4 = (raw: RawClient["server.label"]) => (input: Endpoint18_4Input) => + raw["label.remove"]({ params: { labelID: input["labelID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const adaptGroup18 = (raw: RawClient["server.label"]) => ({ + list: Endpoint18_0(raw), + get: Endpoint18_1(raw), + create: Endpoint18_2(raw), + update: Endpoint18_3(raw), + remove: Endpoint18_4(raw), +}) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), @@ -700,6 +758,7 @@ const adaptClient = (raw: RawClient) => ({ questions: adaptGroup15(raw["server.question"]), references: adaptGroup16(raw["server.reference"]), projectCopies: adaptGroup17(raw["server.projectCopy"]), + "server.label": adaptGroup18(raw["server.label"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 27ec3d8..83e9950 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -112,6 +112,16 @@ import type { ProjectCopiesRemoveOutput, ProjectCopiesRefreshInput, ProjectCopiesRefreshOutput, + ServerLabelListInput, + ServerLabelListOutput, + ServerLabelGetInput, + ServerLabelGetOutput, + ServerLabelCreateInput, + ServerLabelCreateOutput, + ServerLabelUpdateInput, + ServerLabelUpdateOutput, + ServerLabelRemoveInput, + ServerLabelRemoveOutput, } from "./types" import { ClientError } from "./client-error" @@ -987,6 +997,66 @@ export function make(options: ClientOptions) { requestOptions, ), }, + "server.label": { + list: (input?: ServerLabelListInput, requestOptions?: RequestOptions) => + request<{ readonly data: ServerLabelListOutput }>( + { + method: "GET", + path: `/api/label`, + query: { parentID: input?.["parentID"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: ServerLabelGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: ServerLabelGetOutput }>( + { + method: "GET", + path: `/api/label/${encodeURIComponent(input.labelID)}`, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + create: (input: ServerLabelCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: ServerLabelCreateOutput }>( + { + method: "POST", + path: `/api/label`, + body: { name: input["name"], parentID: input["parentID"] }, + successStatus: 200, + declaredStatuses: [404, 400, 409, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + update: (input: ServerLabelUpdateInput, requestOptions?: RequestOptions) => + request<{ readonly data: ServerLabelUpdateOutput }>( + { + method: "PATCH", + path: `/api/label/${encodeURIComponent(input.labelID)}`, + body: { name: input["name"], parentID: input["parentID"] }, + successStatus: 200, + declaredStatuses: [404, 400, 409, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + remove: (input: ServerLabelRemoveInput, requestOptions?: RequestOptions) => + request<{ readonly data: ServerLabelRemoveOutput }>( + { + method: "DELETE", + path: `/api/label/${encodeURIComponent(input.labelID)}`, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c..02f115e 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -101,6 +101,14 @@ export type ProjectCopyError = { export const isProjectCopyError = (value: unknown): value is ProjectCopyError => typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" +export type LabelNotFoundError = { + readonly _tag: "LabelNotFoundError" + readonly labelID: string + readonly message: string +} +export const isLabelNotFoundError = (value: unknown): value is LabelNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LabelNotFoundError" + export type HealthGetOutput = { readonly healthy: true } export type LocationGetInput = { @@ -2805,3 +2813,72 @@ export type ProjectCopiesRefreshInput = { } export type ProjectCopiesRefreshOutput = void + +export type ServerLabelListInput = { + readonly parentID?: { readonly parentID?: "root" | string | undefined }["parentID"] +} + +export type ServerLabelListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly name: string + readonly parentID?: string + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly updated: number | "Infinity" | "-Infinity" | "NaN" + } + }> +}["data"] + +export type ServerLabelGetInput = { readonly labelID: { readonly labelID: string }["labelID"] } + +export type ServerLabelGetOutput = { + readonly data: { + readonly id: string + readonly name: string + readonly parentID?: string + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly updated: number | "Infinity" | "-Infinity" | "NaN" + } + } +}["data"] + +export type ServerLabelCreateInput = { + readonly name: { readonly name: string; readonly parentID?: string }["name"] + readonly parentID?: { readonly name: string; readonly parentID?: string }["parentID"] +} + +export type ServerLabelCreateOutput = { + readonly data: { + readonly id: string + readonly name: string + readonly parentID?: string + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly updated: number | "Infinity" | "-Infinity" | "NaN" + } + } +}["data"] + +export type ServerLabelUpdateInput = { + readonly labelID: { readonly labelID: string }["labelID"] + readonly name?: { readonly name?: string; readonly parentID?: string | null }["name"] + readonly parentID?: { readonly name?: string; readonly parentID?: string | null }["parentID"] +} + +export type ServerLabelUpdateOutput = { + readonly data: { + readonly id: string + readonly name: string + readonly parentID?: string + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly updated: number | "Infinity" | "-Infinity" | "NaN" + } + } +}["data"] + +export type ServerLabelRemoveInput = { readonly labelID: { readonly labelID: string }["labelID"] } + +export type ServerLabelRemoveOutput = { readonly data: ReadonlyArray }["data"] diff --git a/packages/core/src/label.ts b/packages/core/src/label.ts index ecebfcf..6fc25c5 100644 --- a/packages/core/src/label.ts +++ b/packages/core/src/label.ts @@ -2,8 +2,7 @@ export * as Label from "./label" import { and, asc, eq, inArray, isNull, ne, sql } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" -import { ascending } from "@opencode-ai/schema/identifier" -import { statics } from "@opencode-ai/schema/schema" +import { Label } from "@opencode-ai/schema/label" import { Database } from "./database/database" import { makeGlobalNode } from "./effect/app-node" import { LabelTable } from "./label/sql" @@ -11,45 +10,20 @@ import { LabelTable } from "./label/sql" /** Maximum label name length, so names stay readable in the session list UI. */ export const MAX_NAME_LENGTH = 64 -export const ID = Schema.String.pipe( - Schema.brand("Label.ID"), - statics((schema) => ({ create: () => schema.make("lbl_" + ascending()) })), -) -export type ID = typeof ID.Type +export const ID = Label.ID +export type ID = Label.ID -export const Info = Schema.Struct({ - id: ID, - name: Schema.String, - /** Parent label, or undefined for a top-level label. */ - parentID: ID.pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.Number, - updated: Schema.Number, - }), -}).annotate({ identifier: "Label.Info" }) -export type Info = typeof Info.Type +export const Info = Label.Info +export type Info = Label.Info -export const CreateInput = Schema.Struct({ - name: Schema.String, - parentID: ID.pipe(Schema.optional), -}).annotate({ identifier: "Label.CreateInput" }) -export type CreateInput = typeof CreateInput.Type +export const CreateInput = Label.CreateInput +export type CreateInput = Label.CreateInput -export const ListInput = Schema.Struct({ - /** - * `undefined` lists every label, `null` lists only top-level labels, - * and an ID lists the direct children of that label. - */ - parentID: Schema.NullOr(ID).pipe(Schema.optional), -}).annotate({ identifier: "Label.ListInput" }) -export type ListInput = typeof ListInput.Type - -export const UpdateInput = Schema.Struct({ - name: Schema.String.pipe(Schema.optional), - /** `undefined` leaves the parent unchanged, `null` moves the label to the top level. */ - parentID: Schema.NullOr(ID).pipe(Schema.optional), -}).annotate({ identifier: "Label.UpdateInput" }) -export type UpdateInput = typeof UpdateInput.Type +export const ListInput = Label.ListInput +export type ListInput = Label.ListInput + +export const UpdateInput = Label.UpdateInput +export type UpdateInput = Label.UpdateInput export class NotFoundError extends Schema.TaggedErrorClass()("Label.NotFoundError", { id: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fb9d2db..11e708a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -58,6 +58,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Npm } from "@opencode-ai/core/npm" import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { Label } from "@opencode-ai/core/label" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectCopy } from "@opencode-ai/core/project/copy" import { PtyTicket } from "@opencode-ai/core/pty/ticket" @@ -231,6 +232,7 @@ const app = LayerNode.group([ Question.node, Permission.node, PermissionSaved.node, + Label.node, Todo.node, Session.node, SessionProjector.node, diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 310ebae..14be824 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -347,4 +347,23 @@ describe("PublicApi OpenAPI v2 errors", () => { "ProjectNotFoundError", ) }) + + test("documents label CRUD routes with their errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const collection = spec.paths["/api/label"] + const item = spec.paths["/api/label/{labelID}"] + + expect(collection?.get?.parameters?.find((parameter) => parameter.name === "parentID")?.in).toBe("query") + expect(collection?.post?.requestBody?.required).toBe(true) + expect(item?.patch?.requestBody?.required).toBe(true) + + for (const operation of [item?.get, item?.patch, item?.delete, collection?.post]) { + expect(componentNames(operation?.responses?.["404"])).toContain("LabelNotFoundError") + } + for (const operation of [collection?.post, item?.patch]) { + expect(componentNames(operation?.responses?.["400"])).toContain("InvalidRequestError") + expect(componentNames(operation?.responses?.["409"])).toContain("ConflictError") + } + expect(collection?.get?.responses?.["404"]).toBeUndefined() + }) }) diff --git a/packages/opencode/test/server/httpapi-v2-labelservice.test.ts b/packages/opencode/test/server/httpapi-v2-labelservice.test.ts new file mode 100644 index 0000000..bd9fae9 --- /dev/null +++ b/packages/opencode/test/server/httpapi-v2-labelservice.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { webHandler } from "@opencode-ai/server/routes" +import { Context } from "effect" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances } from "../fixture/fixture" + +type Label = { id: string; name: string; parentID?: string } + +const context = Context.empty() as Context.Context + +function request(route: string, init: RequestInit = {}) { + return HttpApiApp.webHandler().handler(new Request(`http://localhost${route}`, init), context) +} + +function send(method: string, route: string, body?: unknown) { + return request(route, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }) +} + +async function create(name: string, parentID?: string) { + const response = await send("POST", "/api/label", { name, parentID }) + expect(response.status).toBe(200) + return ((await response.json()) as { data: Label }).data +} + +async function list(query = "") { + const response = await request(`/api/label${query}`) + expect(response.status).toBe(200) + return ((await response.json()) as { data: Label[] }).data +} + +async function names(query = "") { + return (await list(query)).map((label) => label.name) +} + +afterEach(async () => { + // Tests share one in-memory database, so clear every label tree between tests. + await Promise.all((await list("?parentID=root")).map((label) => send("DELETE", `/api/label/${label.id}`))) + await disposeAllInstances() + await resetDatabase() +}) + +describe("v2 label HttpApi", () => { + describe("POST /api/label", () => { + test("creates a top-level label with a trimmed name and omits parentID", async () => { + const response = await send("POST", "/api/label", { name: " Coursework " }) + expect(response.status).toBe(200) + const body = (await response.json()) as { data: Label & { time: { created: number; updated: number } } } + + expect(body.data.id).toStartWith("lbl_") + expect(body.data.name).toBe("Coursework") + expect(body.data).not.toHaveProperty("parentID") + expect(body.data.time.created).toBeNumber() + expect(body.data.time.updated).toBe(body.data.time.created) + }) + + test("creates a nested label under an existing parent", async () => { + const coursework = await create("Coursework") + const databases = await create("Databases", coursework.id) + expect(databases.parentID).toBe(coursework.id) + }) + + test("returns 404 when the parent does not exist", async () => { + const response = await send("POST", "/api/label", { name: "Orphan", parentID: "lbl_missing" }) + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ _tag: "LabelNotFoundError", labelID: "lbl_missing" }) + }) + + test("returns 400 for empty and overly long names", async () => { + for (const name of [" ", "x".repeat(65)]) { + const response = await send("POST", "/api/label", { name }) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + _tag: "InvalidRequestError", + kind: "invalid_name", + field: "name", + }) + } + expect(await names()).toEqual([]) + }) + + test("returns 400 when the payload does not match the schema", async () => { + expect((await send("POST", "/api/label", {})).status).toBe(400) + expect((await send("POST", "/api/label", { name: 42 })).status).toBe(400) + }) + + test("returns 409 for a case-insensitive duplicate sibling name but allows it at another level", async () => { + const coursework = await create("Coursework") + + const duplicate = await send("POST", "/api/label", { name: "coursework" }) + expect(duplicate.status).toBe(409) + expect(await duplicate.json()).toMatchObject({ _tag: "ConflictError", resource: "label" }) + + expect((await create("Coursework", coursework.id)).parentID).toBe(coursework.id) + }) + }) + + describe("GET /api/label", () => { + test("lists every label, top-level labels, or one label's children, sorted by name", async () => { + const coursework = await create("Coursework") + await create("Networks", coursework.id) + await create("Databases", coursework.id) + await create("Personal") + + expect(await names()).toEqual(["Coursework", "Databases", "Networks", "Personal"]) + expect(await names("?parentID=root")).toEqual(["Coursework", "Personal"]) + expect(await names(`?parentID=${coursework.id}`)).toEqual(["Databases", "Networks"]) + }) + + test("returns an empty list when there are no labels or the parent has no children", async () => { + expect(await list()).toEqual([]) + const leaf = await create("Leaf") + expect(await list(`?parentID=${leaf.id}`)).toEqual([]) + }) + }) + + describe("GET /api/label/:labelID", () => { + test("returns one label", async () => { + const coursework = await create("Coursework") + const databases = await create("Databases", coursework.id) + + const response = await request(`/api/label/${databases.id}`) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: { id: databases.id, name: "Databases", parentID: coursework.id }, + }) + }) + + test("returns 404 for a label that does not exist", async () => { + const response = await request("/api/label/lbl_missing") + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + _tag: "LabelNotFoundError", + labelID: "lbl_missing", + message: "Label lbl_missing does not exist", + }) + }) + }) + + describe("PATCH /api/label/:labelID", () => { + test("renames a label and keeps its parent", async () => { + const coursework = await create("Coursework") + const databases = await create("Databases", coursework.id) + + const response = await send("PATCH", `/api/label/${databases.id}`, { name: "SQL" }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ data: { id: databases.id, name: "SQL", parentID: coursework.id } }) + expect(await names(`?parentID=${coursework.id}`)).toEqual(["SQL"]) + }) + + test("moves a label under a new parent, then to the top level with parentID null", async () => { + const coursework = await create("Coursework") + const personal = await create("Personal") + const notes = await create("Notes", coursework.id) + + const moved = await send("PATCH", `/api/label/${notes.id}`, { parentID: personal.id }) + expect(moved.status).toBe(200) + expect(await names(`?parentID=${personal.id}`)).toEqual(["Notes"]) + expect(await names(`?parentID=${coursework.id}`)).toEqual([]) + + const top = await send("PATCH", `/api/label/${notes.id}`, { parentID: null }) + expect(top.status).toBe(200) + expect(((await top.json()) as { data: Label }).data).not.toHaveProperty("parentID") + expect(await names("?parentID=root")).toEqual(["Coursework", "Notes", "Personal"]) + }) + + test("returns 404 for a missing label or a missing new parent", async () => { + expect((await send("PATCH", "/api/label/lbl_missing", { name: "Renamed" })).status).toBe(404) + + const label = await create("Coursework") + const response = await send("PATCH", `/api/label/${label.id}`, { parentID: "lbl_missing" }) + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ _tag: "LabelNotFoundError", labelID: "lbl_missing" }) + }) + + test("returns 400 for an invalid new name", async () => { + const label = await create("Coursework") + const response = await send("PATCH", `/api/label/${label.id}`, { name: "" }) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ _tag: "InvalidRequestError", field: "name" }) + }) + + test("returns 409 for a rename that collides with a sibling", async () => { + await create("Coursework") + const personal = await create("Personal") + const response = await send("PATCH", `/api/label/${personal.id}`, { name: "COURSEWORK" }) + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ _tag: "ConflictError", resource: "label" }) + }) + + test("returns 409 when moving a label under itself or one of its descendants", async () => { + const coursework = await create("Coursework") + const databases = await create("Databases", coursework.id) + const week1 = await create("Week 1", databases.id) + + for (const parentID of [coursework.id, week1.id]) { + const response = await send("PATCH", `/api/label/${coursework.id}`, { parentID }) + expect(response.status).toBe(409) + expect(((await response.json()) as { message: string }).message).toContain("cycle") + } + expect(await names("?parentID=root")).toEqual(["Coursework"]) + }) + }) + + describe("DELETE /api/label/:labelID", () => { + test("removes a label with all its descendants, returns their IDs, and leaves other labels alone", async () => { + const coursework = await create("Coursework") + const databases = await create("Databases", coursework.id) + const week1 = await create("Week 1", databases.id) + await create("Personal") + + const response = await send("DELETE", `/api/label/${coursework.id}`) + expect(response.status).toBe(200) + const body = (await response.json()) as { data: string[] } + expect(body.data.toSorted()).toEqual([coursework.id, databases.id, week1.id].toSorted()) + expect(await names()).toEqual(["Personal"]) + }) + + test("returns 404 for every operation on a deleted label", async () => { + const label = await create("Coursework") + expect((await send("DELETE", `/api/label/${label.id}`)).status).toBe(200) + + for (const [method, body] of [ + ["GET", undefined], + ["PATCH", { name: "Renamed" }], + ["DELETE", undefined], + ] as const) { + const response = await send(method, `/api/label/${label.id}`, body) + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ _tag: "LabelNotFoundError", labelID: label.id }) + } + }) + }) + + test("the generated SDK client calls every label endpoint", async () => { + const sdk = createOpencodeClient({ + baseUrl: "http://localhost", + fetch: ((input: Request) => HttpApiApp.webHandler().handler(input, context)) as unknown as typeof fetch, + }) + + const coursework = await sdk.v2.label.create({ name: "Coursework" }) + expect(coursework.response.status).toBe(200) + const parentID = coursework.data!.data.id + const databases = await sdk.v2.label.create({ name: "Databases", parentID }) + const childID = databases.data!.data.id + + expect((await sdk.v2.label.list({ parentID: "root" })).data?.data.map((label) => label.name)).toEqual([ + "Coursework", + ]) + expect((await sdk.v2.label.get({ labelID: childID })).data?.data.parentID).toBe(parentID) + expect((await sdk.v2.label.update({ labelID: childID, name: "SQL" })).data?.data.name).toBe("SQL") + expect((await sdk.v2.label.remove({ labelID: parentID })).data?.data.toSorted()).toEqual( + [parentID, childID].toSorted(), + ) + + const missing = await sdk.v2.label.get({ labelID: childID }) + expect(missing.response.status).toBe(404) + expect(missing.error).toMatchObject({ _tag: "LabelNotFoundError" }) + }) + + test("the standalone server also serves label routes", async () => { + const handler = webHandler() + const created = await handler.handler( + new Request("http://localhost/api/label", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Standalone" }), + }), + context, + ) + expect(created.status).toBe(200) + const label = ((await created.json()) as { data: Label }).data + + const fetched = await handler.handler(new Request(`http://localhost/api/label/${label.id}`), context) + expect(fetched.status).toBe(200) + expect(await fetched.json()).toMatchObject({ data: { id: label.id, name: "Standalone" } }) + await handler.dispose() + }) +}) diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 7b422d0..9cdc18a 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -21,6 +21,7 @@ import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" import { ProjectCopyGroup } from "./groups/project-copy" +import { LabelGroup } from "./groups/label" // Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. const makeApiFromGroup = < @@ -53,6 +54,7 @@ const makeApiFromGroup = < .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) .add(ReferenceGroup.middleware(locationMiddleware)) .add(ProjectCopyGroup.middleware(locationMiddleware)) + .add(LabelGroup) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 3b1eced..d74564c 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -109,3 +109,12 @@ export class PtyNotFoundError extends Schema.TaggedErrorClass( }, { httpApiStatus: 404 }, ) {} + +export class LabelNotFoundError extends Schema.TaggedErrorClass()( + "LabelNotFoundError", + { + labelID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/label.ts b/packages/protocol/src/groups/label.ts new file mode 100644 index 0000000..a41963a --- /dev/null +++ b/packages/protocol/src/groups/label.ts @@ -0,0 +1,86 @@ +import { Label } from "@opencode-ai/schema/label" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ConflictError, InvalidRequestError, LabelNotFoundError } from "../errors" + +const Response = Schema.Struct({ data: Label.Info }).annotate({ identifier: "LabelResponse" }) + +// Inline payload structs so generated SDKs take body fields directly instead of a wrapper parameter. +const CreatePayload = Schema.Struct(Label.CreateInput.fields) +const UpdatePayload = Schema.Struct(Label.UpdateInput.fields) + +// Query strings cannot carry `null`, so "root" stands in for `Label.ListInput`'s top-level filter. +export const LabelsQuery = Schema.Struct({ + parentID: Schema.Union([Schema.Literal("root"), Label.ID]).pipe(Schema.optional), +}).annotate({ identifier: "LabelsQuery" }) + +export const LabelGroup = HttpApiGroup.make("server.label") + .add( + HttpApiEndpoint.get("label.list", "/api/label", { + query: LabelsQuery, + success: Schema.Struct({ data: Schema.Array(Label.Info) }).annotate({ identifier: "LabelsResponse" }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.label.list", + summary: "List labels", + description: + "Retrieve labels sorted by name. Omit parentID for every label, pass parentID=root for top-level labels, or pass a label ID for its direct children.", + }), + ), + ) + .add( + HttpApiEndpoint.get("label.get", "/api/label/:labelID", { + params: { labelID: Label.ID }, + success: Response, + error: LabelNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.label.get", + summary: "Get label", + description: "Retrieve a label by ID.", + }), + ), + ) + .add( + HttpApiEndpoint.post("label.create", "/api/label", { + payload: CreatePayload, + success: Response, + error: [LabelNotFoundError, InvalidRequestError, ConflictError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.label.create", + summary: "Create label", + description: + "Create a label, optionally nested under a parent label. Sibling names must be unique, ignoring case.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("label.update", "/api/label/:labelID", { + params: { labelID: Label.ID }, + payload: UpdatePayload, + success: Response, + error: [LabelNotFoundError, InvalidRequestError, ConflictError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.label.update", + summary: "Update label", + description: + "Rename a label or move it under another parent. Pass parentID=null to move it to the top level. Moving a label beneath itself or one of its descendants is rejected.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("label.remove", "/api/label/:labelID", { + params: { labelID: Label.ID }, + success: Schema.Struct({ data: Schema.Array(Label.ID) }).annotate({ identifier: "LabelRemoveResponse" }), + error: LabelNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.label.remove", + summary: "Remove label", + description: "Delete a label and every label nested beneath it. Returns the IDs of all deleted labels.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "label", description: "Hierarchical label management routes." })) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index b7c8e51..4c77e0f 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -5,6 +5,7 @@ export { Credential } from "./credential" export { Event } from "./event" export { FileSystem } from "./filesystem" export { Integration } from "./integration" +export { Label } from "./label" export { LLM } from "./llm" export { Location } from "./location" export { Model } from "./model" diff --git a/packages/schema/src/label.ts b/packages/schema/src/label.ts new file mode 100644 index 0000000..b6b680e --- /dev/null +++ b/packages/schema/src/label.ts @@ -0,0 +1,45 @@ +export * as Label from "./label" + +import { Schema } from "effect" +import { ascending } from "./identifier" +import { optional, statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("Label.ID"), + statics((schema) => ({ create: () => schema.make("lbl_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + name: Schema.String, + /** Parent label, or undefined for a top-level label. */ + parentID: optional(ID), + time: Schema.Struct({ + created: Schema.Number, + updated: Schema.Number, + }), +}).annotate({ identifier: "Label.Info" }) +export type Info = typeof Info.Type + +export const CreateInput = Schema.Struct({ + name: Schema.String, + parentID: optional(ID), +}).annotate({ identifier: "Label.CreateInput" }) +export type CreateInput = typeof CreateInput.Type + +export const ListInput = Schema.Struct({ + /** + * `undefined` lists every label, `null` lists only top-level labels, + * and an ID lists the direct children of that label. + */ + parentID: optional(Schema.NullOr(ID)), +}).annotate({ identifier: "Label.ListInput" }) +export type ListInput = typeof ListInput.Type + +export const UpdateInput = Schema.Struct({ + name: optional(Schema.String), + /** `undefined` leaves the parent unchanged, `null` moves the label to the top level. */ + parentID: optional(Schema.NullOr(ID)), +}).annotate({ identifier: "Label.UpdateInput" }) +export type UpdateInput = typeof UpdateInput.Type diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index cf83dbb..e8deed0 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" import { Agent } from "../src/agent" import { FileSystem } from "../src/filesystem" +import { Label } from "../src/label" import { Model } from "../src/model" import { Project } from "../src/project" import { Pty } from "../src/pty" @@ -31,6 +32,26 @@ describe("contract hygiene", () => { test("current ID constructors expose create", () => { expect(Question.ID.create()).toStartWith("que_") expect(Pty.ID.create()).toStartWith("pty_") + expect(Label.ID.create()).toStartWith("lbl_") + }) + + test("label schemas omit an unset parent and keep null as the top-level marker", () => { + const time = { created: 1, updated: 1 } + const id = Label.ID.make("lbl_child") + expect(Schema.encodeSync(Label.Info)({ id, name: "Top", parentID: undefined, time })).toStrictEqual({ + id, + name: "Top", + time, + }) + expect(Schema.encodeSync(Label.Info)({ id, name: "Child", parentID: Label.ID.make("lbl_parent"), time })).toEqual({ + id, + name: "Child", + parentID: "lbl_parent", + time, + }) + expect(Schema.decodeUnknownSync(Label.UpdateInput)({ parentID: null })).toEqual({ parentID: null }) + expect(Schema.decodeUnknownSync(Label.UpdateInput)({})).toStrictEqual({}) + expect(() => Schema.decodeUnknownSync(Label.CreateInput)({})).toThrow() }) test("reusable public identifiers are stable and unique", () => { @@ -46,6 +67,10 @@ describe("contract hygiene", () => { Project.Time, Project.Info, Pty.Info, + Label.Info, + Label.CreateInput, + Label.ListInput, + Label.UpdateInput, Session.ListAnchor, ].map((schema) => schema.ast.annotations?.identifier) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a4d5bbd..6a25716 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -295,6 +295,16 @@ import type { V2IntegrationGetResponses, V2IntegrationListErrors, V2IntegrationListResponses, + V2LabelCreateErrors, + V2LabelCreateResponses, + V2LabelGetErrors, + V2LabelGetResponses, + V2LabelListErrors, + V2LabelListResponses, + V2LabelRemoveErrors, + V2LabelRemoveResponses, + V2LabelUpdateErrors, + V2LabelUpdateResponses, V2LocationGetErrors, V2LocationGetResponses, V2ModelListErrors, @@ -6989,6 +6999,137 @@ export class ProjectCopy2 extends HeyApiClient { } } +export class Label extends HeyApiClient { + /** + * List labels + * + * Retrieve labels sorted by name. Omit parentID for every label, pass parentID=root for top-level labels, or pass a label ID for its direct children. + */ + public list( + parameters?: { + parentID?: "root" | string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "parentID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/label", + ...options, + ...params, + }) + } + + /** + * Create label + * + * Create a label, optionally nested under a parent label. Sibling names must be unique, ignoring case. + */ + public create( + parameters?: { + name?: string + parentID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "body", key: "name" }, + { in: "body", key: "parentID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/label", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove label + * + * Delete a label and every label nested beneath it. Returns the IDs of all deleted labels. + */ + public remove( + parameters: { + labelID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "labelID" }] }]) + return (options?.client ?? this.client).delete({ + url: "/api/label/{labelID}", + ...options, + ...params, + }) + } + + /** + * Get label + * + * Retrieve a label by ID. + */ + public get( + parameters: { + labelID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "labelID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/label/{labelID}", + ...options, + ...params, + }) + } + + /** + * Update label + * + * Rename a label or move it under another parent. Pass parentID=null to move it to the top level. Moving a label beneath itself or one of its descendants is rejected. + */ + public update( + parameters: { + labelID: string + name?: string + parentID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "labelID" }, + { in: "body", key: "name" }, + { in: "body", key: "parentID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/api/label/{labelID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -7074,6 +7215,11 @@ export class V2 extends HeyApiClient { get projectCopy(): ProjectCopy2 { return (this._projectCopy ??= new ProjectCopy2({ client: this.client })) } + + private _label?: Label + get label(): Label { + return (this._label ??= new Label({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 10fc3ca..7f7f8a7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2957,6 +2957,24 @@ export type ProjectCopyError = { } } +export type LabelsResponse = { + data: Array +} + +export type LabelResponse = { + data: LabelInfo +} + +export type LabelNotFoundError = { + _tag: "LabelNotFoundError" + labelID: string + message: string +} + +export type LabelRemoveResponse = { + data: Array +} + export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } @@ -6151,6 +6169,16 @@ export type ProjectCopyCopy = { directory: string } +export type LabelInfo = { + id: string + name: string + parentID?: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + updated: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -13586,6 +13614,189 @@ export type V2ProjectCopyRefreshResponses = { export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses] +export type V2LabelListData = { + body?: never + path?: never + query?: { + parentID?: "root" | string + } + url: "/api/label" +} + +export type V2LabelListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2LabelListError = V2LabelListErrors[keyof V2LabelListErrors] + +export type V2LabelListResponses = { + /** + * LabelsResponse + */ + 200: LabelsResponse +} + +export type V2LabelListResponse = V2LabelListResponses[keyof V2LabelListResponses] + +export type V2LabelCreateData = { + body: { + name: string + parentID?: string + } + path?: never + query?: never + url: "/api/label" +} + +export type V2LabelCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * LabelNotFoundError + */ + 404: LabelNotFoundError + /** + * ConflictError + */ + 409: ConflictError +} + +export type V2LabelCreateError = V2LabelCreateErrors[keyof V2LabelCreateErrors] + +export type V2LabelCreateResponses = { + /** + * LabelResponse + */ + 200: LabelResponse +} + +export type V2LabelCreateResponse = V2LabelCreateResponses[keyof V2LabelCreateResponses] + +export type V2LabelRemoveData = { + body?: never + path: { + labelID: string + } + query?: never + url: "/api/label/{labelID}" +} + +export type V2LabelRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * LabelNotFoundError + */ + 404: LabelNotFoundError +} + +export type V2LabelRemoveError = V2LabelRemoveErrors[keyof V2LabelRemoveErrors] + +export type V2LabelRemoveResponses = { + /** + * LabelRemoveResponse + */ + 200: LabelRemoveResponse +} + +export type V2LabelRemoveResponse = V2LabelRemoveResponses[keyof V2LabelRemoveResponses] + +export type V2LabelGetData = { + body?: never + path: { + labelID: string + } + query?: never + url: "/api/label/{labelID}" +} + +export type V2LabelGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * LabelNotFoundError + */ + 404: LabelNotFoundError +} + +export type V2LabelGetError = V2LabelGetErrors[keyof V2LabelGetErrors] + +export type V2LabelGetResponses = { + /** + * LabelResponse + */ + 200: LabelResponse +} + +export type V2LabelGetResponse = V2LabelGetResponses[keyof V2LabelGetResponses] + +export type V2LabelUpdateData = { + body: { + name?: string + parentID?: string + } + path: { + labelID: string + } + query?: never + url: "/api/label/{labelID}" +} + +export type V2LabelUpdateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * LabelNotFoundError + */ + 404: LabelNotFoundError + /** + * ConflictError + */ + 409: ConflictError +} + +export type V2LabelUpdateError = V2LabelUpdateErrors[keyof V2LabelUpdateErrors] + +export type V2LabelUpdateResponses = { + /** + * LabelResponse + */ + 200: LabelResponse +} + +export type V2LabelUpdateResponse = V2LabelUpdateResponses[keyof V2LabelUpdateResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 3a5e2e7..da3608a 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -17,6 +17,7 @@ import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" import { ProjectCopyHandler } from "./handlers/project-copy" +import { LabelHandler } from "./handlers/label" export const handlers = Layer.mergeAll( HealthHandler, @@ -37,4 +38,5 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, + LabelHandler, ) diff --git a/packages/server/src/handlers/label.ts b/packages/server/src/handlers/label.ts new file mode 100644 index 0000000..d74af65 --- /dev/null +++ b/packages/server/src/handlers/label.ts @@ -0,0 +1,55 @@ +import { Label } from "@opencode-ai/core/label" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { ConflictError, InvalidRequestError, LabelNotFoundError } from "@opencode-ai/protocol/errors" + +export const LabelHandler = HttpApiBuilder.group(Api, "server.label", (handlers) => + Effect.gen(function* () { + const labels = yield* Label.Service + return handlers + .handle( + "label.list", + Effect.fn(function* (ctx) { + const parentID = ctx.query.parentID === "root" ? null : ctx.query.parentID + return { data: yield* labels.list({ parentID }) } + }), + ) + .handle( + "label.get", + Effect.fn(function* (ctx) { + return { data: yield* labels.get(ctx.params.labelID).pipe(Effect.mapError(notFound)) } + }), + ) + .handle( + "label.create", + Effect.fn(function* (ctx) { + return { data: yield* labels.create(ctx.payload).pipe(Effect.mapError(httpError)) } + }), + ) + .handle( + "label.update", + Effect.fn(function* (ctx) { + return { data: yield* labels.update(ctx.params.labelID, ctx.payload).pipe(Effect.mapError(httpError)) } + }), + ) + .handle( + "label.remove", + Effect.fn(function* (ctx) { + return { data: yield* labels.remove(ctx.params.labelID).pipe(Effect.mapError(notFound)) } + }), + ) + }), +) + +function httpError(error: Label.NotFoundError | Label.InvalidNameError | Label.DuplicateNameError | Label.CycleError) { + if (error instanceof Label.NotFoundError) return notFound(error) + if (error instanceof Label.InvalidNameError) + return new InvalidRequestError({ message: error.message, kind: "invalid_name", field: "name" }) + // Duplicate sibling names and cycles both conflict with the current label tree. + return new ConflictError({ message: error.message, resource: "label" }) +} + +function notFound(error: Label.NotFoundError) { + return new LabelNotFoundError({ labelID: error.id, message: error.message }) +} diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index cc1b1ae..2c9f8d8 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -11,6 +11,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Label } from "@opencode-ai/core/label" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" @@ -32,6 +33,7 @@ const applicationServices = LayerNode.group([ PermissionSaved.node, PtyTicket.node, Credential.node, + Label.node, PtyEnvironment.node, LocationServiceMap.node, ])