Skip to content

Commit 9f7ecd6

Browse files
authored
feat(httpapi): bridge file read endpoints (#24098)
1 parent f8e939d commit 9f7ecd6

7 files changed

Lines changed: 207 additions & 64 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,8 +412,9 @@ Current instance route inventory:
412412
- `workspace` - `bridged`
413413
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
414414
defer create/remove mutations first
415-
- `file` - `later`
416-
good JSON-only candidate set, but larger than the current first-wave slices
415+
- `file` - `bridged` (partial)
416+
bridged endpoints: `GET /file`, `GET /file/content`, `GET /file/status`
417+
defer search endpoints first
417418
- `mcp` - `later`
418419
has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fit
419420
- `session` - `defer`
@@ -449,7 +450,7 @@ Recommended near-term sequence:
449450
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
450451
- [x] port `GET /config` full read endpoint
451452
- [x] port `workspace` read endpoints
452-
- [ ] port `file` JSON read endpoints
453+
- [x] port `file` JSON read endpoints
453454
- [ ] decide when to remove the flag and make Effect routes the default
454455

455456
## Rule of thumb

packages/opencode/src/file/index.ts

Lines changed: 52 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,69 +9,63 @@ import { formatPatch, structuredPatch } from "diff"
99
import fuzzysort from "fuzzysort"
1010
import ignore from "ignore"
1111
import path from "path"
12-
import z from "zod"
1312
import { Global } from "../global"
1413
import { Instance } from "../project/instance"
1514
import { Log } from "../util"
1615
import { Protected } from "./protected"
1716
import { Ripgrep } from "./ripgrep"
18-
19-
export const Info = z
20-
.object({
21-
path: z.string(),
22-
added: z.number().int(),
23-
removed: z.number().int(),
24-
status: z.enum(["added", "deleted", "modified"]),
25-
})
26-
.meta({
27-
ref: "File",
28-
})
29-
30-
export type Info = z.infer<typeof Info>
31-
32-
export const Node = z
33-
.object({
34-
name: z.string(),
35-
path: z.string(),
36-
absolute: z.string(),
37-
type: z.enum(["file", "directory"]),
38-
ignored: z.boolean(),
39-
})
40-
.meta({
41-
ref: "FileNode",
42-
})
43-
export type Node = z.infer<typeof Node>
44-
45-
export const Content = z
46-
.object({
47-
type: z.enum(["text", "binary"]),
48-
content: z.string(),
49-
diff: z.string().optional(),
50-
patch: z
51-
.object({
52-
oldFileName: z.string(),
53-
newFileName: z.string(),
54-
oldHeader: z.string().optional(),
55-
newHeader: z.string().optional(),
56-
hunks: z.array(
57-
z.object({
58-
oldStart: z.number(),
59-
oldLines: z.number(),
60-
newStart: z.number(),
61-
newLines: z.number(),
62-
lines: z.array(z.string()),
63-
}),
64-
),
65-
index: z.string().optional(),
66-
})
67-
.optional(),
68-
encoding: z.literal("base64").optional(),
69-
mimeType: z.string().optional(),
70-
})
71-
.meta({
72-
ref: "FileContent",
73-
})
74-
export type Content = z.infer<typeof Content>
17+
import { zod } from "@/util/effect-zod"
18+
import { type DeepMutable, withStatics } from "@/util/schema"
19+
20+
export const Info = Schema.Struct({
21+
path: Schema.String,
22+
added: Schema.Int,
23+
removed: Schema.Int,
24+
status: Schema.Literals(["added", "deleted", "modified"]),
25+
})
26+
.annotate({ identifier: "File" })
27+
.pipe(withStatics((s) => ({ zod: zod(s) })))
28+
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
29+
30+
export const Node = Schema.Struct({
31+
name: Schema.String,
32+
path: Schema.String,
33+
absolute: Schema.String,
34+
type: Schema.Literals(["file", "directory"]),
35+
ignored: Schema.Boolean,
36+
})
37+
.annotate({ identifier: "FileNode" })
38+
.pipe(withStatics((s) => ({ zod: zod(s) })))
39+
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
40+
41+
const Hunk = Schema.Struct({
42+
oldStart: Schema.Number,
43+
oldLines: Schema.Number,
44+
newStart: Schema.Number,
45+
newLines: Schema.Number,
46+
lines: Schema.Array(Schema.String),
47+
})
48+
49+
const Patch = Schema.Struct({
50+
oldFileName: Schema.String,
51+
newFileName: Schema.String,
52+
oldHeader: Schema.optional(Schema.String),
53+
newHeader: Schema.optional(Schema.String),
54+
hunks: Schema.Array(Hunk),
55+
index: Schema.optional(Schema.String),
56+
})
57+
58+
export const Content = Schema.Struct({
59+
type: Schema.Literals(["text", "binary"]),
60+
content: Schema.String,
61+
diff: Schema.optional(Schema.String),
62+
patch: Schema.optional(Patch),
63+
encoding: Schema.optional(Schema.Literal("base64")),
64+
mimeType: Schema.optional(Schema.String),
65+
})
66+
.annotate({ identifier: "FileContent" })
67+
.pipe(withStatics((s) => ({ zod: zod(s) })))
68+
export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
7569

7670
export const Event = {
7771
Edited: BusEvent.define(

packages/opencode/src/server/routes/instance/file.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export const FileRoutes = lazy(() =>
117117
description: "Files and directories",
118118
content: {
119119
"application/json": {
120-
schema: resolver(File.Node.array()),
120+
schema: resolver(File.Node.zod.array()),
121121
},
122122
},
123123
},
@@ -146,7 +146,7 @@ export const FileRoutes = lazy(() =>
146146
description: "File content",
147147
content: {
148148
"application/json": {
149-
schema: resolver(File.Content),
149+
schema: resolver(File.Content.zod),
150150
},
151151
},
152152
},
@@ -175,7 +175,7 @@ export const FileRoutes = lazy(() =>
175175
description: "File status",
176176
content: {
177177
"application/json": {
178-
schema: resolver(File.Info.array()),
178+
schema: resolver(File.Info.zod.array()),
179179
},
180180
},
181181
},
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { File } from "@/file"
2+
import { Effect, Layer, Schema } from "effect"
3+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
4+
5+
const FileQuery = Schema.Struct({
6+
path: Schema.String,
7+
})
8+
9+
export const FilePaths = {
10+
list: "/file",
11+
content: "/file/content",
12+
status: "/file/status",
13+
} as const
14+
15+
export const FileApi = HttpApi.make("file")
16+
.add(
17+
HttpApiGroup.make("file")
18+
.add(
19+
HttpApiEndpoint.get("list", FilePaths.list, {
20+
query: FileQuery,
21+
success: Schema.Array(File.Node),
22+
}).annotateMerge(
23+
OpenApi.annotations({
24+
identifier: "file.list",
25+
summary: "List files",
26+
description: "List files and directories in a specified path.",
27+
}),
28+
),
29+
HttpApiEndpoint.get("content", FilePaths.content, {
30+
query: FileQuery,
31+
success: File.Content,
32+
}).annotateMerge(
33+
OpenApi.annotations({
34+
identifier: "file.read",
35+
summary: "Read file",
36+
description: "Read the content of a specified file.",
37+
}),
38+
),
39+
HttpApiEndpoint.get("status", FilePaths.status, {
40+
success: Schema.Array(File.Info),
41+
}).annotateMerge(
42+
OpenApi.annotations({
43+
identifier: "file.status",
44+
summary: "Get file status",
45+
description: "Get the git status of all files in the project.",
46+
}),
47+
),
48+
)
49+
.annotateMerge(
50+
OpenApi.annotations({
51+
title: "file",
52+
description: "Experimental HttpApi file routes.",
53+
}),
54+
),
55+
)
56+
.annotateMerge(
57+
OpenApi.annotations({
58+
title: "opencode experimental HttpApi",
59+
version: "0.0.1",
60+
description: "Experimental HttpApi surface for selected instance routes.",
61+
}),
62+
)
63+
64+
export const fileHandlers = Layer.unwrap(
65+
Effect.gen(function* () {
66+
const svc = yield* File.Service
67+
68+
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
69+
return yield* svc.list(ctx.query.path)
70+
})
71+
72+
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
73+
return yield* svc.read(ctx.query.path)
74+
})
75+
76+
const status = Effect.fn("FileHttpApi.status")(function* () {
77+
return yield* svc.status()
78+
})
79+
80+
return HttpApiBuilder.group(FileApi, "file", (handlers) =>
81+
handlers.handle("list", list).handle("content", content).handle("status", status),
82+
)
83+
}),
84+
).pipe(Layer.provide(File.defaultLayer))

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Instance } from "@/project/instance"
1010
import { lazy } from "@/util/lazy"
1111
import { Filesystem } from "@/util"
1212
import { ConfigApi, configHandlers } from "./config"
13+
import { FileApi, fileHandlers } from "./file"
1314
import { PermissionApi, permissionHandlers } from "./permission"
1415
import { ProjectApi, projectHandlers } from "./project"
1516
import { ProviderApi, providerHandlers } from "./provider"
@@ -114,9 +115,11 @@ const ProjectSecured = ProjectApi.middleware(Authorization)
114115
const ProviderSecured = ProviderApi.middleware(Authorization)
115116
const ConfigSecured = ConfigApi.middleware(Authorization)
116117
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
118+
const FileSecured = FileApi.middleware(Authorization)
117119

118120
export const routes = Layer.mergeAll(
119121
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
122+
HttpApiBuilder.layer(FileSecured).pipe(Layer.provide(fileHandlers)),
120123
HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)),
121124
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
122125
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { QuestionRoutes } from "./question"
1616
import { PermissionRoutes } from "./permission"
1717
import { Flag } from "@/flag/flag"
1818
import { ExperimentalHttpApiServer } from "./httpapi/server"
19+
import { FilePaths } from "./httpapi/file"
1920
import { ProjectRoutes } from "./project"
2021
import { SessionRoutes } from "./session"
2122
import { PtyRoutes } from "./pty"
@@ -48,6 +49,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
4849
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
4950
app.get("/project", (c) => handler(c.req.raw, context))
5051
app.get("/project/current", (c) => handler(c.req.raw, context))
52+
app.get(FilePaths.list, (c) => handler(c.req.raw, context))
53+
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
54+
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
5155
}
5256

5357
return app
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { Context } from "effect"
3+
import path from "path"
4+
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
5+
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
6+
import { Instance } from "../../src/project/instance"
7+
import { Log } from "../../src/util"
8+
import { resetDatabase } from "../fixture/db"
9+
import { tmpdir } from "../fixture/fixture"
10+
11+
void Log.init({ print: false })
12+
13+
const context = Context.empty() as Context.Context<unknown>
14+
15+
function request(route: string, directory: string, query?: Record<string, string>) {
16+
const url = new URL(`http://localhost${route}`)
17+
for (const [key, value] of Object.entries(query ?? {})) {
18+
url.searchParams.set(key, value)
19+
}
20+
return ExperimentalHttpApiServer.webHandler().handler(
21+
new Request(url, {
22+
headers: {
23+
"x-opencode-directory": directory,
24+
},
25+
}),
26+
context,
27+
)
28+
}
29+
30+
afterEach(async () => {
31+
await Instance.disposeAll()
32+
await resetDatabase()
33+
})
34+
35+
describe("file HttpApi", () => {
36+
test("serves read endpoints", async () => {
37+
await using tmp = await tmpdir({ git: true })
38+
await Bun.write(path.join(tmp.path, "hello.txt"), "hello")
39+
40+
const [list, content, status] = await Promise.all([
41+
request(FilePaths.list, tmp.path, { path: "." }),
42+
request(FilePaths.content, tmp.path, { path: "hello.txt" }),
43+
request(FilePaths.status, tmp.path),
44+
])
45+
46+
expect(list.status).toBe(200)
47+
expect(await list.json()).toContainEqual(
48+
expect.objectContaining({ name: "hello.txt", path: "hello.txt", type: "file" }),
49+
)
50+
51+
expect(content.status).toBe(200)
52+
expect(await content.json()).toMatchObject({ type: "text", content: "hello" })
53+
54+
expect(status.status).toBe(200)
55+
expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" })
56+
})
57+
})

0 commit comments

Comments
 (0)