Parcourir la source

feat(cli): add API request command

Dax Raad il y a 2 mois
Parent
commit
784eea3911

+ 23 - 0
packages/cli/src/commands/commands.ts

@@ -6,6 +6,29 @@ declare const OPENCODE_CLI_NAME: string | undefined
 export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
   description: "OpenCode 2.0 preview command line interface",
   commands: [
+    Spec.make("api", {
+      description: "Make a request to the running server",
+      params: {
+        request: Argument.string("operation | method path").pipe(
+          Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
+          Argument.variadic({ min: 1, max: 2 }),
+        ),
+        data: Flag.string("data").pipe(
+          Flag.withAlias("d"),
+          Flag.withDescription("Request body"),
+          Flag.optional,
+        ),
+        header: Flag.string("header").pipe(
+          Flag.withAlias("H"),
+          Flag.withDescription("Request header in name:value form"),
+          Flag.atMost(100),
+        ),
+        param: Flag.keyValuePair("param").pipe(
+          Flag.withDescription("OpenAPI path or query parameter"),
+          Flag.optional,
+        ),
+      },
+    }),
     Spec.make("debug", {
       description: "Debugging and troubleshooting tools",
       commands: [Spec.make("agents", { description: "List all agents" })],

+ 35 - 0
packages/cli/src/commands/handlers/api.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { rawRequest, resolveOperation } from "./api"
+
+describe("api request resolution", () => {
+  test("resolves an operation ID with path and query parameters", () => {
+    expect(
+      resolveOperation(
+        {
+          paths: {
+            "/api/session/{sessionID}": {
+              get: { operationId: "v2.session.get" },
+            },
+          },
+        },
+        "v2.session.get",
+        { sessionID: "ses/a", workspace: "work" },
+      ),
+    ).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" })
+  })
+
+  test("rejects a missing path parameter", () => {
+    expect(() =>
+      resolveOperation(
+        { paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } },
+        "v2.session.get",
+        {},
+      ),
+    ).toThrow("Missing path parameter: sessionID")
+  })
+
+  test("resolves curl-like method and path input", () => {
+    expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" })
+    expect(rawRequest(["v2.session.list"])).toBeUndefined()
+  })
+})

+ 85 - 0
packages/cli/src/commands/handlers/api.ts

@@ -0,0 +1,85 @@
+import { EOL } from "node:os"
+import { Effect, Option } from "effect"
+import { Commands } from "../commands"
+import { Runtime } from "../../framework/runtime"
+import { Daemon } from "../../services/daemon"
+
+const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
+
+type Operation = {
+  operationId?: string
+}
+
+type OpenApi = {
+  paths?: Record<string, Record<string, Operation>>
+}
+
+export default Runtime.handler(
+  Commands.commands.api,
+  Effect.fn("cli.api")(function* (input) {
+    const daemon = yield* Daemon.Service
+    const transport = yield* daemon.transport()
+    const params = Option.getOrElse(input.param, () => ({}))
+    const request = yield* resolveRequest(transport, input.request, params)
+    const headers = new Headers(transport.headers)
+    for (const header of input.header) {
+      const index = header.indexOf(":")
+      if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
+      headers.set(header.slice(0, index).trim(), header.slice(index + 1).trim())
+    }
+    const body = Option.getOrUndefined(input.data)
+    if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
+
+    const response = yield* Effect.tryPromise(() =>
+      fetch(new URL(request.path, transport.url), {
+        method: request.method,
+        headers,
+        body,
+      }),
+    )
+    const output = yield* Effect.promise(() => response.text())
+    if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
+  }),
+)
+
+export function resolveOperation(spec: OpenApi, operationID: string, params: Record<string, string>) {
+  for (const [path, operations] of Object.entries(spec.paths ?? {})) {
+    for (const [method, operation] of Object.entries(operations)) {
+      if (!methods.has(method) || operation.operationId !== operationID) continue
+      return { method: method.toUpperCase(), path: interpolate(path, params) }
+    }
+  }
+  throw new Error(`Operation not found: ${operationID}`)
+}
+
+export function rawRequest(input: readonly string[]) {
+  if (input.length !== 2 || !methods.has(input[0].toLowerCase()) || !input[1].startsWith("/")) return
+  return { method: input[0].toUpperCase(), path: input[1] }
+}
+
+function resolveRequest(
+  transport: { url: string; headers: RequestInit["headers"] },
+  input: readonly string[],
+  params: Record<string, string>,
+) {
+  const raw = rawRequest(input)
+  if (raw) return Effect.succeed(raw)
+  if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
+  return Effect.tryPromise(async () => {
+    const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
+    if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
+    return resolveOperation((await response.json()) as OpenApi, input[0], params)
+  })
+}
+
+function interpolate(path: string, params: Record<string, string>) {
+  const used = new Set<string>()
+  const pathname = path.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
+    const value = params[name]
+    if (value === undefined) throw new Error(`Missing path parameter: ${name}`)
+    used.add(name)
+    return encodeURIComponent(value)
+  })
+  const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString()
+  return query ? `${pathname}?${query}` : pathname
+}

+ 1 - 0
packages/cli/src/index.ts

@@ -9,6 +9,7 @@ import { Daemon } from "./services/daemon"
 
 const Handlers = Runtime.handlers(Commands, {
   $: () => import("./commands/handlers/default"),
+  api: () => import("./commands/handlers/api"),
   debug: {
     agents: () => import("./commands/handlers/debug/agents"),
   },