Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/UserGuide.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions packages/client/src/generated-effect/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,64 @@ const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
refresh: Endpoint17_2(raw),
})

type Endpoint18_0Request = Parameters<RawClient["server.label"]["label.list"]>[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<RawClient["server.label"]["label.get"]>[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<RawClient["server.label"]["label.create"]>[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<RawClient["server.label"]["label.update"]>[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<RawClient["server.label"]["label.remove"]>[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"]),
Expand All @@ -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 }) =>
Expand Down
70 changes: 70 additions & 0 deletions packages/client/src/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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),
},
}
}

Expand Down
77 changes: 77 additions & 0 deletions packages/client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string> }["data"]
50 changes: 12 additions & 38 deletions packages/core/src/label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,28 @@ 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"

/** 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<NotFoundError>()("Label.NotFoundError", {
id: Schema.String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -231,6 +232,7 @@ const app = LayerNode.group([
Question.node,
Permission.node,
PermissionSaved.node,
Label.node,
Todo.node,
Session.node,
SessionProjector.node,
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/server/httpapi-public-openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading
Loading