feat(client): expose fs read in promise client (#34504)
This commit is contained in:
parent
f80624cf17
commit
ecfa918760
10 changed files with 227 additions and 63 deletions
|
|
@ -1,16 +1,17 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||||
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
|
import { ClientApi, effectOmitEndpoints, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
|
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
|
||||||
|
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints })
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[
|
[
|
||||||
write(
|
write(
|
||||||
emitPromise(contract, {
|
emitPromise(promiseContract, {
|
||||||
outputTypes: {
|
outputTypes: {
|
||||||
"events.subscribe": {
|
"events.subscribe": {
|
||||||
name: "OpenCodeEventEncoded",
|
name: "OpenCodeEventEncoded",
|
||||||
|
|
@ -21,7 +22,7 @@ await Effect.runPromise(
|
||||||
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||||
),
|
),
|
||||||
write(
|
write(
|
||||||
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }),
|
||||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,5 @@ export const endpointNames = {
|
||||||
"question.request.list": "listRequests",
|
"question.request.list": "listRequests",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
|
||||||
|
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ import type {
|
||||||
PermissionsGetOutput,
|
PermissionsGetOutput,
|
||||||
PermissionsReplyInput,
|
PermissionsReplyInput,
|
||||||
PermissionsReplyOutput,
|
PermissionsReplyOutput,
|
||||||
|
FilesReadInput,
|
||||||
|
FilesReadOutput,
|
||||||
FilesListInput,
|
FilesListInput,
|
||||||
FilesListOutput,
|
FilesListOutput,
|
||||||
FilesFindInput,
|
FilesFindInput,
|
||||||
|
|
@ -155,6 +157,7 @@ interface RequestDescriptor {
|
||||||
readonly successStatus: number
|
readonly successStatus: number
|
||||||
readonly declaredStatuses: ReadonlyArray<number>
|
readonly declaredStatuses: ReadonlyArray<number>
|
||||||
readonly empty: boolean
|
readonly empty: boolean
|
||||||
|
readonly binary?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function make(options: ClientOptions) {
|
export function make(options: ClientOptions) {
|
||||||
|
|
@ -200,6 +203,7 @@ export function make(options: ClientOptions) {
|
||||||
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
|
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
|
||||||
const response = await execute(descriptor, requestOptions)
|
const response = await execute(descriptor, requestOptions)
|
||||||
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
|
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
|
||||||
|
if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A
|
||||||
if (descriptor.empty) {
|
if (descriptor.empty) {
|
||||||
try {
|
try {
|
||||||
await response.body?.cancel()
|
await response.body?.cancel()
|
||||||
|
|
@ -840,6 +844,19 @@ export function make(options: ClientOptions) {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
|
read: (input: FilesReadInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<FilesReadOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/fs/read/${encodePath(input.path)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
binary: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
|
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
|
||||||
request<FilesListOutput>(
|
request<FilesListOutput>(
|
||||||
{
|
{
|
||||||
|
|
@ -1143,6 +1160,10 @@ export function make(options: ClientOptions) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodePath(value: string): string {
|
||||||
|
return value.split("/").map(encodeURIComponent).join("/")
|
||||||
|
}
|
||||||
|
|
||||||
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
|
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
|
||||||
if (value === undefined || value === null) return
|
if (value === undefined || value === null) return
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
|
|
|
||||||
|
|
@ -2572,6 +2572,15 @@ export type PermissionsReplyInput = {
|
||||||
|
|
||||||
export type PermissionsReplyOutput = void
|
export type PermissionsReplyOutput = void
|
||||||
|
|
||||||
|
export type FilesReadInput = {
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
readonly path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilesReadOutput = globalThis.Uint8Array
|
||||||
|
|
||||||
export type FilesListInput = {
|
export type FilesListInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,32 @@ test("exposes every standard HTTP API group", () => {
|
||||||
"attemptComplete",
|
"attemptComplete",
|
||||||
"attemptCancel",
|
"attemptCancel",
|
||||||
])
|
])
|
||||||
expect(Object.keys(client.files)).toEqual(["list", "find"])
|
expect(Object.keys(client.files)).toEqual(["read", "list", "find"])
|
||||||
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
|
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
|
||||||
expect(Object.keys(client.project)).toEqual(["current", "directories"])
|
expect(Object.keys(client.project)).toEqual(["current", "directories"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("files.read returns binary content from the public HTTP contract", async () => {
|
||||||
|
let request: Request | undefined
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input) => {
|
||||||
|
request = input instanceof Request ? input : new Request(input)
|
||||||
|
return new Response(new Uint8Array([104, 105]))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = await client.files.read({
|
||||||
|
path: "src/a b#c.ts",
|
||||||
|
location: { directory: "/tmp/project" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Array.from(content)).toEqual([104, 105])
|
||||||
|
expect(request?.url).toBe(
|
||||||
|
"http://localhost:3000/api/fs/read/src/a%20b%23c.ts?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("project methods use the public HTTP contract", async () => {
|
test("project methods use the public HTTP contract", async () => {
|
||||||
const requests: string[] = []
|
const requests: string[] = []
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ type Active = {
|
||||||
// Resolves with the terminal Info once the command exits, times out, or is killed. A wait
|
// Resolves with the terminal Info once the command exits, times out, or is killed. A wait
|
||||||
// started after termination resolves immediately from the already-completed deferred.
|
// started after termination resolves immediately from the already-completed deferred.
|
||||||
done: Deferred.Deferred<Info, NotFoundError>
|
done: Deferred.Deferred<Info, NotFoundError>
|
||||||
timeoutFiber?: Fiber.Fiber<void, never>
|
timeoutFiber?: Fiber.Fiber<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -181,7 +181,7 @@ export const layer = Layer.effect(
|
||||||
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
|
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
|
||||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||||
// end). `create` returns once `ready` resolves with the registered session.
|
// end). `create` returns once `ready` resolves with the registered session.
|
||||||
const ready = Deferred.makeUnsafe<Active, never>()
|
const ready = Deferred.makeUnsafe<Active>()
|
||||||
runFork(
|
runFork(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -205,14 +205,7 @@ export const layer = Layer.effect(
|
||||||
sessions.set(id, session)
|
sessions.set(id, session)
|
||||||
|
|
||||||
const stream = createWriteStream(file)
|
const stream = createWriteStream(file)
|
||||||
yield* Effect.promise(
|
const outputDone = Deferred.makeUnsafe<void>()
|
||||||
() =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
stream.once("open", () => resolve())
|
|
||||||
stream.once("error", () => resolve())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const pump = handle.all.pipe(
|
const pump = handle.all.pipe(
|
||||||
Stream.runForEach((chunk: Uint8Array) =>
|
Stream.runForEach((chunk: Uint8Array) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
|
@ -221,9 +214,27 @@ export const layer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
runFork(pump.pipe(Effect.catch(() => Effect.void)))
|
runFork(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* pump.pipe(Effect.catch(() => Effect.void))
|
||||||
|
yield* Effect.promise(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
stream.end(() => resolve())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* Deferred.succeed(outputDone, undefined)
|
||||||
|
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
||||||
|
)
|
||||||
|
yield* Effect.promise(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
stream.once("open", () => resolve())
|
||||||
|
stream.once("error", () => resolve())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const finish = (status: Info["status"], exit?: number) =>
|
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (session.info.status !== "running") return
|
if (session.info.status !== "running") return
|
||||||
session.info = produce(session.info, (draft) => {
|
session.info = produce(session.info, (draft) => {
|
||||||
|
|
@ -231,7 +242,8 @@ export const layer = Layer.effect(
|
||||||
if (exit !== undefined) draft.exit = exit
|
if (exit !== undefined) draft.exit = exit
|
||||||
draft.time.completed = Date.now()
|
draft.time.completed = Date.now()
|
||||||
})
|
})
|
||||||
stream.end()
|
yield* beforeWait
|
||||||
|
yield* Deferred.await(outputDone)
|
||||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||||
// session still reports success rather than the removal NotFoundError. This runs before
|
// session still reports success rather than the removal NotFoundError. This runs before
|
||||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||||
|
|
@ -257,10 +269,7 @@ export const layer = Layer.effect(
|
||||||
session.timeoutFiber = runFork(
|
session.timeoutFiber = runFork(
|
||||||
Effect.sleep(Duration.millis(input.timeout)).pipe(
|
Effect.sleep(Duration.millis(input.timeout)).pipe(
|
||||||
Effect.flatMap(() =>
|
Effect.flatMap(() =>
|
||||||
Effect.gen(function* () {
|
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||||
yield* finish("timeout")
|
|
||||||
yield* handle.kill().pipe(Effect.catch(() => Effect.void))
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
Effect.catch(() => Effect.void),
|
Effect.catch(() => Effect.void),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export * as BuiltInTools from "./builtins"
|
export * as BuiltInTools from "./builtins"
|
||||||
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { Layer } from "effect"
|
import { Context, Layer } from "effect"
|
||||||
import { ApplyPatchTool } from "./apply-patch"
|
import { ApplyPatchTool } from "./apply-patch"
|
||||||
import { EditTool } from "./edit"
|
import { EditTool } from "./edit"
|
||||||
import { GlobTool } from "./glob"
|
import { GlobTool } from "./glob"
|
||||||
|
|
@ -27,6 +27,8 @@ import { SessionTodo } from "../session/todo"
|
||||||
import { ToolRegistry } from "./registry"
|
import { ToolRegistry } from "./registry"
|
||||||
import { httpClient } from "../effect/app-node-platform"
|
import { httpClient } from "../effect/app-node-platform"
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Record<string, never>>()("@opencode/v2/BuiltInTools") {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||||
* Each tool retains its implementation and focused tests independently. Dynamic
|
* Each tool retains its implementation and focused tests independently. Dynamic
|
||||||
|
|
@ -40,7 +42,7 @@ import { httpClient } from "../effect/app-node-platform"
|
||||||
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
|
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
|
||||||
* transforms separate from this static built-in list.
|
* transforms separate from this static built-in list.
|
||||||
*/
|
*/
|
||||||
export const locationLayer = Layer.mergeAll(
|
const registrations = Layer.mergeAll(
|
||||||
ApplyPatchTool.layer,
|
ApplyPatchTool.layer,
|
||||||
EditTool.layer,
|
EditTool.layer,
|
||||||
GlobTool.layer,
|
GlobTool.layer,
|
||||||
|
|
@ -54,8 +56,10 @@ export const locationLayer = Layer.mergeAll(
|
||||||
WriteTool.layer,
|
WriteTool.layer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const locationLayer = Layer.succeed(Service, Service.of({})).pipe(Layer.provideMerge(registrations))
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
name: "built-in-tools",
|
service: Service,
|
||||||
layer: locationLayer,
|
layer: locationLayer,
|
||||||
deps: [
|
deps: [
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,6 @@ describe("LocationServiceMap", () => {
|
||||||
"grep",
|
"grep",
|
||||||
"question",
|
"question",
|
||||||
"read",
|
"read",
|
||||||
"shell",
|
|
||||||
"skill",
|
"skill",
|
||||||
"todowrite",
|
"todowrite",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
|
|
@ -143,7 +142,6 @@ describe("LocationServiceMap", () => {
|
||||||
"grep",
|
"grep",
|
||||||
"question",
|
"question",
|
||||||
"read",
|
"read",
|
||||||
"shell",
|
|
||||||
"skill",
|
"skill",
|
||||||
"todowrite",
|
"todowrite",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,15 @@ export type InputField = {
|
||||||
readonly source: "params" | "query" | "headers" | "payload"
|
readonly source: "params" | "query" | "headers" | "payload"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OperationInputField = {
|
||||||
|
readonly name: string
|
||||||
|
readonly source: InputField["source"] | "wildcard"
|
||||||
|
}
|
||||||
|
|
||||||
export type Operation = {
|
export type Operation = {
|
||||||
readonly group: string
|
readonly group: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly input: ReadonlyArray<InputField>
|
readonly input: ReadonlyArray<OperationInputField>
|
||||||
readonly inputMode: "none" | "optional" | "required"
|
readonly inputMode: "none" | "optional" | "required"
|
||||||
readonly success: "value" | "void" | "stream"
|
readonly success: "value" | "void" | "stream"
|
||||||
readonly errors: ReadonlyArray<string>
|
readonly errors: ReadonlyArray<string>
|
||||||
|
|
@ -67,6 +72,10 @@ type Slot = {
|
||||||
readonly schema: Schema.Top
|
readonly schema: Schema.Top
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromiseInputField =
|
||||||
|
| (InputField & { readonly optional: boolean })
|
||||||
|
| { readonly name: string; readonly source: "wildcard"; readonly optional: false }
|
||||||
|
|
||||||
const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
|
const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
|
||||||
const resolveHttpApiEncoding = SchemaAST.resolveAt<HttpApiSchema.Encoding>("~httpApiEncoding")
|
const resolveHttpApiEncoding = SchemaAST.resolveAt<HttpApiSchema.Encoding>("~httpApiEncoding")
|
||||||
const resolveContentSchema = SchemaAST.resolveAt<SchemaAST.AST>("contentSchema")
|
const resolveContentSchema = SchemaAST.resolveAt<SchemaAST.AST>("contentSchema")
|
||||||
|
|
@ -246,7 +255,7 @@ export function emitPromise(
|
||||||
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
|
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
operations: operations(groups),
|
operations: promiseOperations(groups),
|
||||||
files: [
|
files: [
|
||||||
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) },
|
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) },
|
||||||
{
|
{
|
||||||
|
|
@ -255,7 +264,7 @@ export function emitPromise(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "client.ts",
|
path: "client.ts",
|
||||||
content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
|
content: normalizePromiseClientContent(renderPromiseClient(groups), groups),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "index.ts",
|
path: "index.ts",
|
||||||
|
|
@ -285,11 +294,11 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
|
||||||
) {
|
) {
|
||||||
throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` })
|
throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` })
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (!HttpApiSchema.isNoContent(success.ast)) {
|
||||||
!HttpApiSchema.isNoContent(success.ast) &&
|
const encoding = resolveHttpApiEncoding(success.ast)?._tag ?? "Json"
|
||||||
(resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json"
|
if (encoding !== "Json" && encoding !== "Uint8Array") {
|
||||||
) {
|
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
|
||||||
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
|
}
|
||||||
}
|
}
|
||||||
for (const error of endpoint.errors) {
|
for (const error of endpoint.errors) {
|
||||||
if (declaredErrorFields(error.schema) === undefined) {
|
if (declaredErrorFields(error.schema) === undefined) {
|
||||||
|
|
@ -305,6 +314,16 @@ function operations(groups: ReadonlyArray<Group>) {
|
||||||
return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation))
|
return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function promiseOperations(groups: ReadonlyArray<Group>) {
|
||||||
|
return groups.flatMap((group) =>
|
||||||
|
group.endpoints.map((endpoint) => ({
|
||||||
|
...endpoint.operation,
|
||||||
|
input: promiseInput(endpoint).map(({ name, source }) => ({ name, source })),
|
||||||
|
inputMode: promiseInputMode(endpoint),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
|
function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
|
||||||
return [
|
return [
|
||||||
...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })),
|
...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })),
|
||||||
|
|
@ -457,8 +476,9 @@ function renderPromiseTypes(
|
||||||
headers: endpoint.headers,
|
headers: endpoint.headers,
|
||||||
payload: endpoint.payloads[0],
|
payload: endpoint.payloads[0],
|
||||||
}
|
}
|
||||||
const input = endpoint.input
|
const input = promiseInput(endpoint)
|
||||||
.map((field) => {
|
.map((field) => {
|
||||||
|
if (field.source === "wildcard") return `readonly ${JSON.stringify(field.name)}: string`
|
||||||
const schema = schemas[field.source]
|
const schema = schemas[field.source]
|
||||||
if (schema === undefined)
|
if (schema === undefined)
|
||||||
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
|
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
|
||||||
|
|
@ -476,7 +496,7 @@ function renderPromiseTypes(
|
||||||
: successSchema,
|
: successSchema,
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
...(promiseInputMode(endpoint) === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
||||||
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
|
@ -493,19 +513,19 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||||
const imports = groups.flatMap((group) =>
|
const imports = groups.flatMap((group) =>
|
||||||
group.endpoints.flatMap((endpoint) => {
|
group.endpoints.flatMap((endpoint) => {
|
||||||
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
|
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
|
||||||
return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`]
|
return [...(promiseInputMode(endpoint) === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const fields = groups.map((group) => {
|
const fields = groups.map((group) => {
|
||||||
const methods = group.endpoints.map((endpoint) => {
|
const methods = group.endpoints.map((endpoint) => {
|
||||||
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
|
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
|
||||||
|
const inputMode = promiseInputMode(endpoint)
|
||||||
const argument =
|
const argument =
|
||||||
endpoint.operation.inputMode === "none"
|
inputMode === "none"
|
||||||
? "requestOptions?: RequestOptions"
|
? "requestOptions?: RequestOptions"
|
||||||
: `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions`
|
: `input${inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions`
|
||||||
const path = promisePath(endpoint.endpoint.path, endpoint.input)
|
const path = promisePath(endpoint.endpoint.path, endpoint.input, promiseWildcardInput(endpoint))
|
||||||
const access = (name: string) =>
|
const access = (name: string) => `input${inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]`
|
||||||
`input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]`
|
|
||||||
const part = (source: InputField["source"]) => {
|
const part = (source: InputField["source"]) => {
|
||||||
const inputs = endpoint.input.filter((field) => field.source === source)
|
const inputs = endpoint.input.filter((field) => field.source === source)
|
||||||
return inputs.length === 0
|
return inputs.length === 0
|
||||||
|
|
@ -518,7 +538,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||||
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
|
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
|
||||||
].filter((value): value is string => value !== undefined)
|
].filter((value): value is string => value !== undefined)
|
||||||
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
|
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
|
||||||
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }`
|
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }`
|
||||||
if (endpoint.operation.success === "stream") {
|
if (endpoint.operation.success === "stream") {
|
||||||
const success = endpoint.successes[0]
|
const success = endpoint.successes[0]
|
||||||
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
|
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
|
||||||
|
|
@ -583,10 +603,67 @@ function structuralType(schema: Schema.Top) {
|
||||||
.replaceAll("Schema.Json", "JsonValue")
|
.replaceAll("Schema.Json", "JsonValue")
|
||||||
}
|
}
|
||||||
|
|
||||||
function promisePath(path: string, input: ReadonlyArray<InputField>) {
|
function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Group>) {
|
||||||
if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` })
|
const endpoints = groups.flatMap((group) => group.endpoints)
|
||||||
|
const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0]))
|
||||||
|
const usesWildcard = endpoints.some((endpoint) => promiseWildcardInput(endpoint) !== undefined)
|
||||||
|
|
||||||
|
const sseReady = replaceOne(content, "let next: ReadableStreamReadResult<Uint8Array>", "let next")
|
||||||
|
const binaryReady = usesBinary
|
||||||
|
? replaceOne(
|
||||||
|
replaceOne(sseReady, "readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary?: true\n}"),
|
||||||
|
"if (descriptor.empty) {",
|
||||||
|
"if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A\n if (descriptor.empty) {",
|
||||||
|
)
|
||||||
|
: sseReady
|
||||||
|
return usesWildcard
|
||||||
|
? replaceOne(
|
||||||
|
binaryReady,
|
||||||
|
"function appendQuery(params: URLSearchParams, key: string, value: unknown): void {",
|
||||||
|
'function encodePath(value: string): string {\n return value.split("/").map(encodeURIComponent).join("/")\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {',
|
||||||
|
)
|
||||||
|
: binaryReady
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOne(input: string, search: string, replacement: string) {
|
||||||
|
if (!input.includes(search))
|
||||||
|
throw new GenerationError({ reason: `Missing Promise client template marker: ${search}` })
|
||||||
|
return input.replace(search, replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function promiseInput(endpoint: Endpoint): ReadonlyArray<PromiseInputField> {
|
||||||
|
const wildcard = promiseWildcardInput(endpoint)
|
||||||
|
if (wildcard === undefined) return endpoint.input
|
||||||
|
return [...endpoint.input, wildcard]
|
||||||
|
}
|
||||||
|
|
||||||
|
function promiseInputMode(endpoint: Endpoint): Operation["inputMode"] {
|
||||||
|
const input = promiseInput(endpoint)
|
||||||
|
if (input.length === 0) return "none"
|
||||||
|
return input.every((field) => field.optional) ? "optional" : "required"
|
||||||
|
}
|
||||||
|
|
||||||
|
function promiseWildcardInput(endpoint: Endpoint): PromiseInputField | undefined {
|
||||||
|
if (!endpoint.endpoint.path.includes("*")) return undefined
|
||||||
|
if (endpoint.endpoint.path.indexOf("*") !== endpoint.endpoint.path.lastIndexOf("*")) {
|
||||||
|
throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` })
|
||||||
|
}
|
||||||
|
if (!endpoint.endpoint.path.endsWith("*")) {
|
||||||
|
throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` })
|
||||||
|
}
|
||||||
|
const name = endpoint.input.some((field) => field.name === "path") ? "wildcard" : "path"
|
||||||
|
return { name, source: "wildcard", optional: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBinarySchema(schema: Schema.Top) {
|
||||||
|
return (resolveHttpApiEncoding(schema.ast)?._tag ?? "Json") === "Uint8Array"
|
||||||
|
}
|
||||||
|
|
||||||
|
function promisePath(path: string, input: ReadonlyArray<InputField>, wildcard?: PromiseInputField) {
|
||||||
const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name))
|
const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name))
|
||||||
const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean)
|
const segments = (wildcard === undefined ? path : path.slice(0, -1))
|
||||||
|
.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g)
|
||||||
|
.filter(Boolean)
|
||||||
const template = segments
|
const template = segments
|
||||||
.map((segment) => {
|
.map((segment) => {
|
||||||
if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`")
|
if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`")
|
||||||
|
|
@ -595,7 +672,7 @@ function promisePath(path: string, input: ReadonlyArray<InputField>) {
|
||||||
return `\${encodeURIComponent(input.${name})}`
|
return `\${encodeURIComponent(input.${name})}`
|
||||||
})
|
})
|
||||||
.join("")
|
.join("")
|
||||||
return `\`${template}\``
|
return `\`${template}${wildcard === undefined ? "" : `\${encodePath(input.${wildcard.name})}`}\``
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueModule(base: string, index: number, modules: ReadonlySet<string>) {
|
function uniqueModule(base: string, index: number, modules: ReadonlySet<string>) {
|
||||||
|
|
|
||||||
|
|
@ -355,20 +355,8 @@ describe("HttpApiCodegen.generate", () => {
|
||||||
).toThrow("Unsupported Promise success encoding: session.text")
|
).toThrow("Unsupported Promise success encoding: session.text")
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
emitPromise(
|
emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*/tail", { success: Schema.String })))),
|
||||||
compileContract(
|
).toThrow("Unsupported Promise path wildcard: /file/*/tail")
|
||||||
api(
|
|
||||||
HttpApiEndpoint.get("binary", "/binary", {
|
|
||||||
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toThrow("Unsupported Promise success encoding: session.binary")
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))),
|
|
||||||
).toThrow("Unsupported Promise path wildcard: /file/*")
|
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
emitPromise(
|
emitPromise(
|
||||||
|
|
@ -443,6 +431,41 @@ describe("HttpApiCodegen.generate", () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("executes an emitted binary wildcard GET through fetch", async () => {
|
||||||
|
const output = emitPromise(
|
||||||
|
compileContract(
|
||||||
|
api(
|
||||||
|
HttpApiEndpoint.get("read", "/file/*", {
|
||||||
|
query: { token: Schema.optional(Schema.String) },
|
||||||
|
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||||
|
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||||
|
let request: Request | undefined
|
||||||
|
const client = generated.OpenCode.make({
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
fetch: async (input: RequestInfo | URL) => {
|
||||||
|
request = input instanceof Request ? input : new Request(input)
|
||||||
|
return new Response(new Uint8Array([1, 2, 3]))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(Array.from(result)).toEqual([1, 2, 3])
|
||||||
|
expect(request?.method).toBe("GET")
|
||||||
|
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("serializes flattened query, header, and JSON payload inputs", async () => {
|
test("serializes flattened query, header, and JSON payload inputs", async () => {
|
||||||
const output = emitPromise(
|
const output = emitPromise(
|
||||||
compileContract(
|
compileContract(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue