Просмотр исходного кода

add experimental file HttpApi read slice

Move the shared file DTOs to Effect Schema, add a parallel experimental file HttpApi surface for list/content/status, and cover the new read-only slice with a server test.
Kit Langton 4 дней назад
Родитель
Сommit
5922182166

+ 52 - 57
packages/opencode/src/file/index.ts

@@ -3,7 +3,8 @@ import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { Git } from "@/git"
-import { Effect, Layer, Context } from "effect"
+import { zod } from "@/util/effect-zod"
+import { Effect, Layer, Context, Schema } from "effect"
 import * as Stream from "effect/Stream"
 import { formatPatch, structuredPatch } from "diff"
 import fuzzysort from "fuzzysort"
@@ -17,62 +18,56 @@ import { Protected } from "./protected"
 import { Ripgrep } from "./ripgrep"
 
 export namespace File {
-  export const Info = z
-    .object({
-      path: z.string(),
-      added: z.number().int(),
-      removed: z.number().int(),
-      status: z.enum(["added", "deleted", "modified"]),
-    })
-    .meta({
-      ref: "File",
-    })
-
-  export type Info = z.infer<typeof Info>
-
-  export const Node = z
-    .object({
-      name: z.string(),
-      path: z.string(),
-      absolute: z.string(),
-      type: z.enum(["file", "directory"]),
-      ignored: z.boolean(),
-    })
-    .meta({
-      ref: "FileNode",
-    })
-  export type Node = z.infer<typeof Node>
-
-  export const Content = z
-    .object({
-      type: z.enum(["text", "binary"]),
-      content: z.string(),
-      diff: z.string().optional(),
-      patch: z
-        .object({
-          oldFileName: z.string(),
-          newFileName: z.string(),
-          oldHeader: z.string().optional(),
-          newHeader: z.string().optional(),
-          hunks: z.array(
-            z.object({
-              oldStart: z.number(),
-              oldLines: z.number(),
-              newStart: z.number(),
-              newLines: z.number(),
-              lines: z.array(z.string()),
-            }),
-          ),
-          index: z.string().optional(),
-        })
-        .optional(),
-      encoding: z.literal("base64").optional(),
-      mimeType: z.string().optional(),
-    })
-    .meta({
-      ref: "FileContent",
-    })
-  export type Content = z.infer<typeof Content>
+  export class Info extends Schema.Class<Info>("File")({
+    path: Schema.String,
+    added: Schema.Number,
+    removed: Schema.Number,
+    status: Schema.Union([Schema.Literal("added"), Schema.Literal("deleted"), Schema.Literal("modified")]),
+  }) {
+    static readonly zod = zod(this)
+  }
+
+  export class Node extends Schema.Class<Node>("FileNode")({
+    name: Schema.String,
+    path: Schema.String,
+    absolute: Schema.String,
+    type: Schema.Union([Schema.Literal("file"), Schema.Literal("directory")]),
+    ignored: Schema.Boolean,
+  }) {
+    static readonly zod = zod(this)
+  }
+
+  export class Hunk extends Schema.Class<Hunk>("FileContentHunk")({
+    oldStart: Schema.Number,
+    oldLines: Schema.Number,
+    newStart: Schema.Number,
+    newLines: Schema.Number,
+    lines: Schema.Array(Schema.String),
+  }) {
+    static readonly zod = zod(this)
+  }
+
+  export class Patch extends Schema.Class<Patch>("FileContentPatch")({
+    oldFileName: Schema.String,
+    newFileName: Schema.String,
+    oldHeader: Schema.optional(Schema.String),
+    newHeader: Schema.optional(Schema.String),
+    hunks: Schema.Array(Hunk),
+    index: Schema.optional(Schema.String),
+  }) {
+    static readonly zod = zod(this)
+  }
+
+  export class Content extends Schema.Class<Content>("FileContent")({
+    type: Schema.Union([Schema.Literal("text"), Schema.Literal("binary")]),
+    content: Schema.String,
+    diff: Schema.optional(Schema.String),
+    patch: Schema.optional(Patch),
+    encoding: Schema.optional(Schema.Literal("base64")),
+    mimeType: Schema.optional(Schema.String),
+  }) {
+    static readonly zod = zod(this)
+  }
 
   export const Event = {
     Edited: BusEvent.define(

+ 3 - 3
packages/opencode/src/server/instance/file.ts

@@ -126,7 +126,7 @@ export const FileRoutes = lazy(() =>
             description: "Files and directories",
             content: {
               "application/json": {
-                schema: resolver(File.Node.array()),
+                schema: resolver(z.array(File.Node.zod)),
               },
             },
           },
@@ -159,7 +159,7 @@ export const FileRoutes = lazy(() =>
             description: "File content",
             content: {
               "application/json": {
-                schema: resolver(File.Content),
+                schema: resolver(File.Content.zod),
               },
             },
           },
@@ -192,7 +192,7 @@ export const FileRoutes = lazy(() =>
             description: "File status",
             content: {
               "application/json": {
-                schema: resolver(File.Info.array()),
+                schema: resolver(z.array(File.Info.zod)),
               },
             },
           },

+ 96 - 0
packages/opencode/src/server/instance/httpapi/file.ts

@@ -0,0 +1,96 @@
+import { AppLayer } from "@/effect/app-runtime"
+import { memoMap } from "@/effect/run-service"
+import { File } from "@/file"
+import { lazy } from "@/util/lazy"
+import { Effect, Layer, Schema } from "effect"
+import { HttpRouter, HttpServer } from "effect/unstable/http"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import type { Handler } from "hono"
+
+const root = "/experimental/httpapi/file"
+
+const Api = HttpApi.make("file")
+  .add(
+    HttpApiGroup.make("file")
+      .add(
+        HttpApiEndpoint.get("list", root, {
+          query: { path: Schema.optional(Schema.String) },
+          success: Schema.Array(File.Node),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "file.list",
+            summary: "List files",
+            description: "List files and directories in a specified path.",
+          }),
+        ),
+        HttpApiEndpoint.get("content", `${root}/content`, {
+          query: { path: Schema.String },
+          success: File.Content,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "file.read",
+            summary: "Read file",
+            description: "Read the content of a specified file.",
+          }),
+        ),
+        HttpApiEndpoint.get("status", `${root}/status`, {
+          success: Schema.Array(File.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "file.status",
+            summary: "Get file status",
+            description: "Get the git status of all files in the project.",
+          }),
+        ),
+      )
+      .annotateMerge(
+        OpenApi.annotations({
+          title: "file",
+          description: "Experimental HttpApi file routes.",
+        }),
+      ),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode experimental HttpApi",
+      version: "0.0.1",
+      description: "Experimental HttpApi surface for selected instance routes.",
+    }),
+  )
+
+const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path?: string } }) {
+  const svc = yield* File.Service
+  return Schema.decodeUnknownSync(Schema.Array(File.Node))(yield* svc.list(ctx.query.path))
+})
+
+const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
+  const svc = yield* File.Service
+  return Schema.decodeUnknownSync(File.Content)(yield* svc.read(ctx.query.path))
+})
+
+const status = Effect.fn("FileHttpApi.status")(function* () {
+  const svc = yield* File.Service
+  return Schema.decodeUnknownSync(Schema.Array(File.Info))(yield* svc.status())
+})
+
+const FileLive = HttpApiBuilder.group(Api, "file", (handlers) =>
+  handlers.handle("list", list).handle("content", content).handle("status", status),
+)
+
+const web = lazy(() =>
+  HttpRouter.toWebHandler(
+    Layer.mergeAll(
+      AppLayer,
+      HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
+        Layer.provide(FileLive),
+        Layer.provide(HttpServer.layerServices),
+      ),
+    ),
+    {
+      disableLogger: true,
+      memoMap,
+    },
+  ),
+)
+
+export const FileHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)

+ 6 - 1
packages/opencode/src/server/instance/httpapi/index.ts

@@ -1,7 +1,12 @@
 import { lazy } from "@/util/lazy"
 import { Hono } from "hono"
+import { FileHttpApiHandler } from "./file"
 import { QuestionHttpApiHandler } from "./question"
 
 export const HttpApiRoutes = lazy(() =>
-  new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
+  new Hono()
+    .all("/question", QuestionHttpApiHandler)
+    .all("/question/*", QuestionHttpApiHandler)
+    .all("/file", FileHttpApiHandler)
+    .all("/file/*", FileHttpApiHandler),
 )

+ 45 - 0
packages/opencode/test/server/file-httpapi.test.ts

@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { Server } from "../../src/server/server"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+describe("experimental file httpapi", () => {
+  test("lists files, reads content, reports status, and serves docs", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "note.txt"), "hello")
+      },
+    })
+    const app = Server.Default().app
+    const headers = {
+      "content-type": "application/json",
+      "x-opencode-directory": tmp.path,
+    }
+
+    const list = await app.request("/experimental/httpapi/file?path=.", { headers })
+    expect(list.status).toBe(200)
+    const items = await list.json()
+    expect(items.some((item: { name: string }) => item.name === "note.txt")).toBe(true)
+
+    const read = await app.request("/experimental/httpapi/file/content?path=note.txt", { headers })
+    expect(read.status).toBe(200)
+    const content = await read.json()
+    expect(content.type).toBe("text")
+    expect(content.content).toContain("hello")
+
+    const status = await app.request("/experimental/httpapi/file/status", { headers })
+    expect(status.status).toBe(200)
+    expect(Array.isArray(await status.json())).toBe(true)
+
+    const doc = await app.request("/experimental/httpapi/file/doc", { headers })
+    expect(doc.status).toBe(200)
+    const spec = await doc.json()
+    expect(spec.paths["/experimental/httpapi/file"]?.get?.operationId).toBe("file.list")
+    expect(spec.paths["/experimental/httpapi/file/content"]?.get?.operationId).toBe("file.read")
+    expect(spec.paths["/experimental/httpapi/file/status"]?.get?.operationId).toBe("file.status")
+  })
+})