mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-20 03:13:32 +00:00
feat(sdk): add HttpApi clients and embedded host (#33445)
This commit is contained in:
parent
c45d1db9a0
commit
cdd67cf30f
59 changed files with 4629 additions and 316 deletions
42
packages/httpapi-codegen/README.md
Normal file
42
packages/httpapi-codegen/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# @opencode-ai/httpapi-codegen
|
||||
|
||||
Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts.
|
||||
|
||||
The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures.
|
||||
|
||||
## Settled rules
|
||||
|
||||
- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`.
|
||||
- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`.
|
||||
- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source.
|
||||
- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`.
|
||||
- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation.
|
||||
- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`.
|
||||
- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically.
|
||||
|
||||
- Flatten path, query, header, and payload fields into one input object.
|
||||
- Reject duplicate field names across input channels.
|
||||
- Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required.
|
||||
- Unwrap exact `{ data: A }` success envelopes.
|
||||
- Map no-content success to `void`.
|
||||
- Preserve other single success values.
|
||||
- Reject ambiguous multiple-success contracts.
|
||||
- Expose streaming success as `Stream`, not `Effect<Stream>`.
|
||||
- Reject schemas whose wire/domain transformation cannot be generated exactly.
|
||||
- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`.
|
||||
- Commit generated source for review; CI regenerates and fails when the worktree changes.
|
||||
- Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator.
|
||||
|
||||
## Boundary
|
||||
|
||||
This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities.
|
||||
|
||||
Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy.
|
||||
|
||||
The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`.
|
||||
|
||||
Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links.
|
||||
|
||||
Generated source starts with one self-contained module per `HttpApiGroup`, plus root client and index modules. Schema dependencies may be duplicated across group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it.
|
||||
|
||||
Codegen preserves group and endpoint identifiers exactly. The composed remote `HttpApi` owns public names such as `session` and `get`; the generator performs no prefix stripping, casing conversion, or public-name annotation mapping.
|
||||
22
packages/httpapi-codegen/package.json
Normal file
22
packages/httpapi-codegen/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/httpapi-codegen",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 5000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
1147
packages/httpapi-codegen/src/index.ts
Normal file
1147
packages/httpapi-codegen/src/index.ts
Normal file
File diff suppressed because one or more lines are too long
28
packages/httpapi-codegen/test/effect.ts
Normal file
28
packages/httpapi-codegen/test/effect.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { test } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import type { Scope } from "effect/Scope"
|
||||
import { TestClock, TestConsole } from "effect/testing"
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
|
||||
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
||||
|
||||
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
|
||||
test(
|
||||
name,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
|
||||
}
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise),
|
||||
options,
|
||||
)
|
||||
|
||||
export const it = { effect }
|
||||
45
packages/httpapi-codegen/test/fixture.ts
Normal file
45
packages/httpapi-codegen/test/fixture.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
|
||||
export class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Api = HttpApi.make("fixture")
|
||||
.add(
|
||||
HttpApiGroup.make("session")
|
||||
.add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", "/session", {
|
||||
query: { archived: Schema.optional(Schema.Boolean) },
|
||||
success: Schema.Array(Schema.String),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
error: Missing.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: HttpApiSchema.NoContent,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiGroup.make("event").add(
|
||||
HttpApiEndpoint.get("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe(
|
||||
HttpApiSchema.status(202),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
|
||||
),
|
||||
)
|
||||
897
packages/httpapi-codegen/test/generate.test.ts
Normal file
897
packages/httpapi-codegen/test/generate.test.ts
Normal file
|
|
@ -0,0 +1,897 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { format } from "prettier"
|
||||
import {
|
||||
compile as compileContract,
|
||||
emitEffect,
|
||||
emitEffectImported,
|
||||
emitPromise,
|
||||
generate,
|
||||
GenerationError,
|
||||
} from "../src"
|
||||
import { it } from "./effect"
|
||||
import { Api as FixtureApi, Missing } from "./fixture"
|
||||
|
||||
function api(endpoint: HttpApiEndpoint.Any) {
|
||||
return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
|
||||
}
|
||||
|
||||
function compile<Id extends string, Groups extends HttpApiGroup.Any>(source: HttpApi.HttpApi<Id, Groups>) {
|
||||
return emitEffect(compileContract(source))
|
||||
}
|
||||
|
||||
describe("HttpApiCodegen.generate", () => {
|
||||
test("compiles one contract for Promise and Effect emitters", () => {
|
||||
const contract = compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const promise = emitPromise(contract)
|
||||
const effect = emitEffect(contract)
|
||||
|
||||
expect(promise.operations).toEqual(effect.operations)
|
||||
expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"])
|
||||
const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
|
||||
expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
|
||||
expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`")
|
||||
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'params: { "sessionID": input["sessionID"] }',
|
||||
)
|
||||
})
|
||||
|
||||
test("emits an Effect client against an imported authoritative API", () => {
|
||||
const output = emitEffectImported(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ module: "@example/api", api: "Api" },
|
||||
)
|
||||
|
||||
expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"])
|
||||
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
|
||||
'import { Api } from "@example/api"',
|
||||
)
|
||||
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
|
||||
"HttpApiClient.ForApi<typeof Api>",
|
||||
)
|
||||
})
|
||||
|
||||
test("projects imported endpoint constants into a generated API", () => {
|
||||
const output = emitEffectImported(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ module: "@example/api", endpoints: { "session.get": "SessionGet" } },
|
||||
)
|
||||
const client = output.files.find((file) => file.path === "client.ts")?.content
|
||||
|
||||
expect(client).toContain('import { SessionGet } from "@example/api"')
|
||||
expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
|
||||
})
|
||||
|
||||
test("imports an authoritative group without reconstructing it", () => {
|
||||
const output = emitEffectImported(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ module: "@example/api", group: "SessionGroup" },
|
||||
)
|
||||
const client = output.files.find((file) => file.path === "client.ts")?.content
|
||||
|
||||
expect(client).toContain('import { SessionGroup } from "@example/api"')
|
||||
expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
|
||||
expect(client).not.toContain("HttpApiGroup")
|
||||
})
|
||||
|
||||
test("separates hosted and consumer group names", () => {
|
||||
const source = HttpApi.make("test").add(
|
||||
HttpApiGroup.make("server.session").add(
|
||||
HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
|
||||
),
|
||||
)
|
||||
const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
|
||||
|
||||
expect(contract.groups[0]?.identifier).toBe("sessions")
|
||||
expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
|
||||
expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
|
||||
})
|
||||
|
||||
test("rejects consumer group name collisions", () => {
|
||||
const source = HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
|
||||
|
||||
expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
|
||||
"Client group name collision: same",
|
||||
)
|
||||
})
|
||||
|
||||
test("uses the unqualified endpoint name for the public client", () => {
|
||||
const contract = compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("session.get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
|
||||
const effect = emitEffectImported(contract, {
|
||||
module: "@example/api",
|
||||
endpoints: { "session.session.get": "SessionGet" },
|
||||
}).files.find((file) => file.path === "client.ts")?.content
|
||||
|
||||
expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
|
||||
expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
|
||||
expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
|
||||
expect(effect).toContain('raw["session.get"]')
|
||||
})
|
||||
|
||||
test("preserves optional keys in Promise error types", () => {
|
||||
class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
|
||||
"OptionalError",
|
||||
{ message: Schema.String, detail: Schema.String.pipe(Schema.optional) },
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
const output = emitPromise(
|
||||
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'readonly "message": string; readonly "detail"?: string | undefined',
|
||||
)
|
||||
})
|
||||
|
||||
test("erases brands from Promise wire types", () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) },
|
||||
success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('readonly "sessionID": string')
|
||||
expect(types).not.toContain("Brand")
|
||||
})
|
||||
|
||||
test("inlines non-recursive references in Promise wire types", () => {
|
||||
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.Struct({ data: Referenced }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
|
||||
)
|
||||
})
|
||||
|
||||
test("emits Effect Json schemas as standalone Promise types", () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.Json,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain("export type JsonValue =")
|
||||
expect(types).toContain("{ readonly [key: string]: JsonValue }")
|
||||
expect(types).not.toContain("Schema.Json")
|
||||
})
|
||||
|
||||
test("emits an optional Promise input when every field is optional", () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("list", "/session", {
|
||||
query: { limit: Schema.optional(Schema.Number) },
|
||||
success: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
|
||||
'"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects Promise transports that are not implemented", () => {
|
||||
expect(() =>
|
||||
emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("text", "/text", {
|
||||
success: Schema.String.pipe(HttpApiSchema.asText()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toThrow("Unsupported Promise success encoding: session.text")
|
||||
|
||||
expect(() =>
|
||||
emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toThrow("Unsupported Promise stream: session.events")
|
||||
})
|
||||
|
||||
test("executes an emitted Promise GET through fetch", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
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 Response.json({ data: "hello" })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("https://example.com/session/a%2Fb")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("maps an emitted no-content response to undefined", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: HttpApiSchema.NoContent,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
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()}`)
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async () => new Response(null, { status: 204 }),
|
||||
})
|
||||
|
||||
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes flattened query, header, and JSON payload inputs", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
query: { resume: Schema.optional(Schema.Boolean) },
|
||||
headers: { traceID: Schema.String },
|
||||
payload: Schema.Struct({ prompt: Schema.String }),
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
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, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: "admitted" })
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
|
||||
).toBe("admitted")
|
||||
expect(request?.url).toBe("https://example.com/session/session?resume=true")
|
||||
expect(request?.headers.get("traceID")).toBe("trace")
|
||||
expect(await request?.json()).toEqual({ prompt: "hello" })
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects with declared tagged errors and exports a type guard", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
error: Missing.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
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()}`)
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
|
||||
})
|
||||
|
||||
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
|
||||
expect(error).toEqual({ _tag: "Missing", message: "gone" })
|
||||
expect(generated.isMissing(error)).toBeTrue()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("subscribe", "/event", {
|
||||
query: { after: Schema.optional(Schema.Number) },
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
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 requests = 0
|
||||
let url: string | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
requests++
|
||||
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
const encoder = new TextEncoder()
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"type":"ready"}\r'))
|
||||
controller.enqueue(encoder.encode("\n\r\n"))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
const events = client.session.subscribe({ after: 2 })
|
||||
|
||||
expect(requests).toBe(0)
|
||||
const received = []
|
||||
for await (const event of events) received.push(event)
|
||||
expect(received).toEqual([{ type: "ready" }])
|
||||
expect(requests).toBe(1)
|
||||
expect(url).toBe("https://example.com/event?after=2")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves public group and endpoint identifiers exactly", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test").add(
|
||||
HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
|
||||
})
|
||||
|
||||
test("emits one client module per HttpApi group", () => {
|
||||
const source = HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
|
||||
|
||||
const output = compile(source)
|
||||
|
||||
expect(output.files.map((file) => file.path)).toEqual([
|
||||
"session.ts",
|
||||
"tool.ts",
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("emits syntactically valid TypeScript modules", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const transpiler = new Bun.Transpiler({ loader: "ts" })
|
||||
|
||||
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
|
||||
})
|
||||
|
||||
it.effect("keeps the strict generated-consumer fixture current", () =>
|
||||
Effect.gen(function* () {
|
||||
const output = compile(FixtureApi)
|
||||
const actual = yield* Effect.promise(() =>
|
||||
Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
|
||||
)
|
||||
expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
|
||||
output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
|
||||
)
|
||||
yield* Effect.forEach(output.files, (file) =>
|
||||
Effect.tryPromise(() =>
|
||||
Promise.all([
|
||||
Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
|
||||
format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
|
||||
]),
|
||||
).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("flattens transport input channels into one domain input", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
query: { resume: Schema.String },
|
||||
headers: { traceID: Schema.String },
|
||||
payload: Schema.Struct({ prompt: Schema.String }),
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.input).toEqual([
|
||||
{ name: "sessionID", source: "params" },
|
||||
{ name: "resume", source: "query" },
|
||||
{ name: "traceID", source: "headers" },
|
||||
{ name: "prompt", source: "payload" },
|
||||
])
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'params: { "sessionID": input["sessionID"] }',
|
||||
)
|
||||
})
|
||||
|
||||
test("uses no argument when an operation has no input fields", () => {
|
||||
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
|
||||
|
||||
expect(output.operations[0]?.inputMode).toBe("none")
|
||||
})
|
||||
|
||||
test("uses an optional object when every input field is optional", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("list", "/session", {
|
||||
query: { limit: Schema.optional(Schema.String) },
|
||||
success: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.inputMode).toBe("optional")
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
|
||||
})
|
||||
|
||||
test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("list", "/session", {
|
||||
query: { archived: Schema.optional(Schema.Boolean) },
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
|
||||
})
|
||||
|
||||
test("uses a required object when any input field is required", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
query: { includeArchived: Schema.optional(Schema.String) },
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.inputMode).toBe("required")
|
||||
})
|
||||
|
||||
test("rejects colliding input names across transport channels", () => {
|
||||
expect(() =>
|
||||
compile(
|
||||
api(
|
||||
HttpApiEndpoint.post("prompt", "/session/:id", {
|
||||
params: { id: Schema.String },
|
||||
payload: Schema.Struct({ id: Schema.String }),
|
||||
success: Schema.Void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toThrow("Input field collision: id")
|
||||
})
|
||||
|
||||
test("rejects multiple payload alternatives until selection semantics are explicit", () => {
|
||||
expect(() =>
|
||||
compile(
|
||||
api(
|
||||
HttpApiEndpoint.post("prompt", "/session", {
|
||||
payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toThrow("Multiple payload schemas: session.prompt")
|
||||
})
|
||||
|
||||
test("unwraps an exact data success envelope", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.success).toBe("value")
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
"Effect.map((value) => value.data)",
|
||||
)
|
||||
})
|
||||
|
||||
test("maps no-content success to void", () => {
|
||||
const output = compile(
|
||||
api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.success).toBe("void")
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
|
||||
})
|
||||
|
||||
test("preserves non-default empty response statuses", () => {
|
||||
const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
|
||||
})
|
||||
|
||||
test("returns a non-envelope success unchanged", () => {
|
||||
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
|
||||
|
||||
expect(output.operations[0]?.success).toBe("value")
|
||||
})
|
||||
|
||||
test("rejects multiple success shapes until their public semantics are explicit", () => {
|
||||
expect(() =>
|
||||
compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: [Schema.String, Schema.Number],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toThrow("Multiple success schemas: session.get")
|
||||
})
|
||||
|
||||
test("models an SSE success as a direct stream", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.success).toBe("stream")
|
||||
})
|
||||
|
||||
test("preserves annotated stream response statuses", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
".pipe(HttpApiSchema.status(202))",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects schemas whose semantics cannot be emitted exactly", () => {
|
||||
const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
|
||||
|
||||
expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
|
||||
"Unportable schema: session.get.success",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
|
||||
const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "yes"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
query: { archived: QueryBoolean },
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toThrow("Effect schema requires authoritative import: session.get")
|
||||
})
|
||||
|
||||
test("rejects custom validation checks without portable metadata", () => {
|
||||
const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
|
||||
|
||||
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
|
||||
"Unportable schema: session.get.success",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects spoofed and aborted validation checks", () => {
|
||||
const Spoofed = Schema.Number.check(
|
||||
Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
|
||||
)
|
||||
const Aborted = Schema.Number.check(Schema.isFinite().abort())
|
||||
|
||||
expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
|
||||
"Unportable schema: session.spoofed.success",
|
||||
)
|
||||
expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
|
||||
"Unportable schema: session.aborted.success",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
|
||||
const JsonNumber = Schema.toCodecJson(Schema.Number)
|
||||
const link = JsonNumber.ast.encoding?.[0]
|
||||
if (link === undefined) throw new Error("Expected JSON number encoding")
|
||||
// This helper is present at runtime but omitted from the public declaration surface.
|
||||
const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
|
||||
if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
|
||||
const ast: unknown = replaceEncoding(JsonNumber.ast, [
|
||||
new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
|
||||
])
|
||||
if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
|
||||
const Altered = Schema.make(ast)
|
||||
|
||||
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
|
||||
"Effect schema requires authoritative import: session.get",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects lexical generation and annotation values", () => {
|
||||
const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
|
||||
generation: { runtime: "LocalOnly", Type: "string" },
|
||||
})
|
||||
const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
|
||||
custom: () => "local",
|
||||
})
|
||||
|
||||
expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
|
||||
"Unportable schema: session.generated.success",
|
||||
)
|
||||
expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
|
||||
"Unportable schema: session.annotated.success",
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves errors from server-only middleware", () => {
|
||||
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
|
||||
class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
|
||||
error: Unauthorized,
|
||||
}) {}
|
||||
|
||||
const output = compile(
|
||||
api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
|
||||
)
|
||||
|
||||
expect(output.operations[0]).toBeDefined()
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves tagged error response statuses", () => {
|
||||
class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.String,
|
||||
error: Missing.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
|
||||
)
|
||||
})
|
||||
|
||||
test("supports every HttpApi method through the generic constructor", () => {
|
||||
const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
|
||||
})
|
||||
|
||||
test("uses safe unique module paths without changing public group identifiers", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
|
||||
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
|
||||
})
|
||||
|
||||
test("reserves support module names case-insensitively", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
|
||||
})
|
||||
|
||||
test("keeps searching when a reserved-name fallback is also occupied", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
|
||||
})
|
||||
|
||||
test("rejects collisions in the flattened client namespace", () => {
|
||||
expect(() =>
|
||||
compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
|
||||
.add(
|
||||
HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toThrow("Client name collision: status")
|
||||
})
|
||||
|
||||
test("emits a usable raw type for top-level groups", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test").add(
|
||||
HttpApiGroup.make("health", { topLevel: true }).add(
|
||||
HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
|
||||
})
|
||||
|
||||
it.effect("reports compiler failures in the generate Effect", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* generate(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/url", {
|
||||
success: Schema.declare((input): input is URL => input instanceof URL),
|
||||
}),
|
||||
),
|
||||
{
|
||||
directory: "/generated",
|
||||
},
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(GenerationError)
|
||||
if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
|
||||
}).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
|
||||
)
|
||||
|
||||
test("rejects required client middleware without an adapter", () => {
|
||||
class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
|
||||
requiredForClient: true,
|
||||
}) {}
|
||||
|
||||
expect(() =>
|
||||
compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
|
||||
).toThrow("Client middleware requires adapter: SignedRequest")
|
||||
})
|
||||
|
||||
test("maps transport and decode failures to one stable client error", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.errors).toContain("ClientError")
|
||||
expect(output.operations[0]?.errors).not.toContain("HttpClientError")
|
||||
expect(output.operations[0]?.errors).not.toContain("SchemaError")
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
"new ClientError({ cause: error })",
|
||||
)
|
||||
})
|
||||
})
|
||||
28
packages/httpapi-codegen/test/generated-consumer.ts
Normal file
28
packages/httpapi-codegen/test/generated-consumer.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Effect, Stream } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { ClientError, OpenCode } from "./generated"
|
||||
import { Missing } from "./fixture"
|
||||
|
||||
export const program = OpenCode.make().pipe(
|
||||
Effect.map((client) => {
|
||||
const health = client.session.health()
|
||||
const list = client.session.list()
|
||||
const filtered = client.session.list({ archived: true })
|
||||
const get = client.session.get({ sessionID: "session" })
|
||||
const interrupt = client.session.interrupt({ sessionID: "session" })
|
||||
const status = client.status()
|
||||
const subscribe = client.event.subscribe()
|
||||
|
||||
const _health: Effect.Effect<string, ClientError> = health
|
||||
const _list: Effect.Effect<ReadonlyArray<string>, ClientError> = list
|
||||
const _filtered: Effect.Effect<ReadonlyArray<string>, ClientError> = filtered
|
||||
const _get: Effect.Effect<string, Missing | ClientError> = get
|
||||
const _interrupt: Effect.Effect<void, ClientError> = interrupt
|
||||
const _status: Effect.Effect<string, ClientError> = status
|
||||
const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe
|
||||
|
||||
return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe }
|
||||
}),
|
||||
)
|
||||
|
||||
const _requiresHttpClient: Effect.Effect<unknown, never, HttpClient.HttpClient> = program
|
||||
5
packages/httpapi-codegen/test/generated/client-error.ts
Normal file
5
packages/httpapi-codegen/test/generated/client-error.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
16
packages/httpapi-codegen/test/generated/client.ts
Normal file
16
packages/httpapi-codegen/test/generated/client.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { adaptGroup0, Group0 } from "./session"
|
||||
import { adaptGroup1, Group1 } from "./event"
|
||||
import { adaptGroup2, Group2 } from "./system"
|
||||
|
||||
const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2)
|
||||
const adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({
|
||||
session: adaptGroup0(raw["session"]),
|
||||
event: adaptGroup1(raw["event"]),
|
||||
...adaptGroup2({ status: raw["status"] }),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
||||
39
packages/httpapi-codegen/test/generated/event.ts
Normal file
39
packages/httpapi-codegen/test/generated/event.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
|
||||
|
||||
const Endpoint0SuccessError = Schema.Never
|
||||
|
||||
export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
|
||||
HttpApiEndpoint.make("GET")("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({
|
||||
data: Endpoint0SuccessData,
|
||||
error: Endpoint0SuccessError,
|
||||
contentType: "text/event-stream",
|
||||
}).pipe(HttpApiSchema.status(202)),
|
||||
}),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof Group1, "event", never, never>
|
||||
|
||||
const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint0 = (raw: RawGroup) => () =>
|
||||
Stream.unwrap(
|
||||
raw["subscribe"]({}).pipe(
|
||||
Effect.mapError(mapEndpoint0Error),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
|
||||
),
|
||||
)
|
||||
|
||||
export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })
|
||||
2
packages/httpapi-codegen/test/generated/index.ts
Normal file
2
packages/httpapi-codegen/test/generated/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { ClientError } from "./client-error"
|
||||
export * as OpenCode from "./client"
|
||||
96
packages/httpapi-codegen/test/generated/session.ts
Normal file
96
packages/httpapi-codegen/test/generated/session.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Endpoint0Success = Schema.String
|
||||
|
||||
const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
|
||||
|
||||
const Endpoint1Success = Schema.Array(Schema.String)
|
||||
|
||||
const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const Endpoint2Success = Schema.Struct({ data: Schema.String })
|
||||
|
||||
class Endpoint2Error0Class extends Schema.TaggedErrorClass<Endpoint2Error0Class>("Missing")("Missing", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
|
||||
|
||||
const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
|
||||
|
||||
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
|
||||
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
|
||||
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
|
||||
.add(
|
||||
HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
|
||||
params: Endpoint2Params,
|
||||
success: Endpoint2Success,
|
||||
error: Endpoint2Error0,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
|
||||
params: Endpoint3Params,
|
||||
success: Endpoint3Success,
|
||||
}),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof Group0, "session", never, never>
|
||||
|
||||
const Endpoint0DeclaredError = Schema.Never
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
|
||||
|
||||
type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
|
||||
const Endpoint1DeclaredError = Schema.Never
|
||||
const mapEndpoint1Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint1DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
|
||||
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
|
||||
|
||||
type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
|
||||
const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
|
||||
const mapEndpoint2Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint2DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
|
||||
raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapEndpoint2Error),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
|
||||
const Endpoint3DeclaredError = Schema.Never
|
||||
const mapEndpoint3Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint3DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
|
||||
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
|
||||
|
||||
export const adaptGroup0 = (raw: RawGroup) => ({
|
||||
health: Endpoint0(raw),
|
||||
list: Endpoint1(raw),
|
||||
get: Endpoint2(raw),
|
||||
interrupt: Endpoint3(raw),
|
||||
})
|
||||
25
packages/httpapi-codegen/test/generated/system.ts
Normal file
25
packages/httpapi-codegen/test/generated/system.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Endpoint0Success = Schema.String
|
||||
|
||||
export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client<typeof Group2>
|
||||
|
||||
const Endpoint0DeclaredError = Schema.Never
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
|
||||
|
||||
export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })
|
||||
160
packages/httpapi-codegen/test/write.test.ts
Normal file
160
packages/httpapi-codegen/test/write.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { write, type Output } from "../src"
|
||||
import { it } from "./effect"
|
||||
|
||||
describe("HttpApiCodegen.write", () => {
|
||||
it.effect("writes compiled files beneath the output directory", () => {
|
||||
const writes: Array<{ readonly path: string; readonly content: string }> = []
|
||||
const output: Output = {
|
||||
operations: [],
|
||||
files: [{ path: "session.ts", content: "export const session = {}" }],
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* write(output, "/generated")
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ path: "/generated/session.ts", content: "export const session = {}\n" },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
exists: () => Effect.succeed(false),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: (path, content) => {
|
||||
writes.push({ path, content })
|
||||
return Effect.void
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("removes only stale files owned by the previous manifest", () => {
|
||||
const removed: Array<string> = []
|
||||
return write(
|
||||
{
|
||||
operations: [],
|
||||
files: [{ path: "session.ts", content: "" }],
|
||||
},
|
||||
"/generated",
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")),
|
||||
makeDirectory: () => Effect.void,
|
||||
readFileString: () => Effect.succeed('["old.ts", "session.ts"]'),
|
||||
remove: (path) => {
|
||||
removed.push(path)
|
||||
return Effect.void
|
||||
},
|
||||
writeFileString: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("rejects unsafe and duplicate output paths before writing", () => {
|
||||
const writes: Array<string> = []
|
||||
return Effect.gen(function* () {
|
||||
const error = yield* write(
|
||||
{
|
||||
operations: [],
|
||||
files: [
|
||||
{ path: "../outside.ts", content: "" },
|
||||
{ path: "client.ts", content: "" },
|
||||
{ path: "CLIENT.ts", content: "" },
|
||||
],
|
||||
},
|
||||
"/generated",
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error._tag).toBe("GenerationError")
|
||||
expect(writes).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
writeFileString: (path) => {
|
||||
writes.push(path)
|
||||
return Effect.void
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("rejects case-insensitive duplicate output paths", () => {
|
||||
const writes: Array<string> = []
|
||||
return Effect.gen(function* () {
|
||||
const error = yield* write(
|
||||
{
|
||||
operations: [],
|
||||
files: [
|
||||
{ path: "client.ts", content: "" },
|
||||
{ path: "CLIENT.ts", content: "" },
|
||||
],
|
||||
},
|
||||
"/generated",
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error._tag).toBe("GenerationError")
|
||||
expect(error.reason).toBe("Duplicate output path: CLIENT.ts")
|
||||
expect(writes).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
writeFileString: (path) => {
|
||||
writes.push(path)
|
||||
return Effect.void
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("reserves the private manifest path", () =>
|
||||
write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))),
|
||||
Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects existing symbolic-link output targets", () =>
|
||||
write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))),
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
exists: (path) => Effect.succeed(path.endsWith("session.ts")),
|
||||
makeDirectory: () => Effect.void,
|
||||
stat: () =>
|
||||
Effect.succeed({
|
||||
type: "SymbolicLink",
|
||||
mtime: Option.none(),
|
||||
atime: Option.none(),
|
||||
birthtime: Option.none(),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: 0,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
8
packages/httpapi-codegen/tsconfig.json
Normal file
8
packages/httpapi-codegen/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue