From b241e38cc8e1fbf46b0f1872f3345232b76608f1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 16:48:04 -0400 Subject: [PATCH 1/2] feat(sdk): add Effect HttpApi codegen --- bun.lock | 14 + packages/httpapi-codegen/README.md | 40 + packages/httpapi-codegen/package.json | 19 + packages/httpapi-codegen/src/index.ts | 757 ++++++++++++++++++ packages/httpapi-codegen/test/effect.ts | 28 + packages/httpapi-codegen/test/fixture.ts | 45 ++ .../httpapi-codegen/test/generate.test.ts | 470 +++++++++++ .../test/generated-consumer.ts | 28 + .../test/generated/client-error.ts | 5 + .../httpapi-codegen/test/generated/client.ts | 17 + .../httpapi-codegen/test/generated/event.ts | 39 + .../httpapi-codegen/test/generated/index.ts | 2 + .../httpapi-codegen/test/generated/session.ts | 96 +++ .../httpapi-codegen/test/generated/system.ts | 25 + packages/httpapi-codegen/test/write.test.ts | 160 ++++ packages/httpapi-codegen/tsconfig.json | 8 + 16 files changed, 1753 insertions(+) create mode 100644 packages/httpapi-codegen/README.md create mode 100644 packages/httpapi-codegen/package.json create mode 100644 packages/httpapi-codegen/src/index.ts create mode 100644 packages/httpapi-codegen/test/effect.ts create mode 100644 packages/httpapi-codegen/test/fixture.ts create mode 100644 packages/httpapi-codegen/test/generate.test.ts create mode 100644 packages/httpapi-codegen/test/generated-consumer.ts create mode 100644 packages/httpapi-codegen/test/generated/client-error.ts create mode 100644 packages/httpapi-codegen/test/generated/client.ts create mode 100644 packages/httpapi-codegen/test/generated/event.ts create mode 100644 packages/httpapi-codegen/test/generated/index.ts create mode 100644 packages/httpapi-codegen/test/generated/session.ts create mode 100644 packages/httpapi-codegen/test/generated/system.ts create mode 100644 packages/httpapi-codegen/test/write.test.ts create mode 100644 packages/httpapi-codegen/tsconfig.json diff --git a/bun.lock b/bun.lock index 657424dfef19..ca92b78d5355 100644 --- a/bun.lock +++ b/bun.lock @@ -476,6 +476,18 @@ "effect": "4.0.0-beta.83", }, }, + "packages/httpapi-codegen": { + "name": "@opencode-ai/httpapi-codegen", + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/llm": { "name": "@opencode-ai/llm", "version": "1.17.9", @@ -1800,6 +1812,8 @@ "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md new file mode 100644 index 000000000000..80b6c86d24d7 --- /dev/null +++ b/packages/httpapi-codegen/README.md @@ -0,0 +1,40 @@ +# @opencode-ai/httpapi-codegen + +Build-time source generation for domain-oriented Effect APIs derived 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 + +- 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`. +- Reject schemas whose wire/domain transformation cannot be generated exactly. +- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`. +- Generate only the Effect API initially; Promise runtime ownership, cancellation, and stream adaptation are deferred. +- 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 the remote API derived from `HttpApi`. It does not generate embedded implementations or embedded-only capabilities. The OpenCode integration composes two distinct total objects: + +- A remote object containing the generated HTTP capabilities. +- An embedded object implementing the shared shape against local services and adding embedded-only capabilities. + +The embedded object may be a structural superset of the remote object, but the constructors and concrete result types remain distinct. + +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 public `generate(Api, { directory })` operation is an Effect requiring `FileSystem`. Internally it composes a pure `compile(Api)` phase with `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. diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json new file mode 100644 index 000000000000..907743ec4f30 --- /dev/null +++ b/packages/httpapi-codegen/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/httpapi-codegen", + "private": true, + "type": "module", + "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:" + } +} diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts new file mode 100644 index 000000000000..04f926c8e047 --- /dev/null +++ b/packages/httpapi-codegen/src/index.ts @@ -0,0 +1,757 @@ +import { isAbsolute, join } from "node:path" +import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import type { HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" + +export type InputField = { + readonly name: string + readonly source: "params" | "query" | "headers" | "payload" +} + +export type Operation = { + readonly group: string + readonly name: string + readonly input: ReadonlyArray + readonly inputMode: "none" | "optional" | "required" + readonly success: "value" | "void" | "stream" + readonly errors: ReadonlyArray +} + +export type Output = { + readonly operations: ReadonlyArray + readonly files: ReadonlyArray<{ + readonly path: string + readonly content: string + }> +} + +export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { + reason: Schema.String, +}) { + override get message() { + return this.reason + } +} + +type Endpoint = { + readonly group: string + readonly topLevel: boolean + readonly endpoint: HttpApiEndpoint.AnyWithProps + readonly params: Schema.Top | undefined + readonly query: Schema.Top | undefined + readonly headers: Schema.Top | undefined + readonly payloads: ReadonlyArray + readonly operation: Operation + readonly input: ReadonlyArray + readonly unwrapData: boolean + readonly errors: ReadonlyArray + readonly successes: ReadonlyArray +} + +type Group = { + readonly identifier: string + readonly module: string + readonly endpoints: ReadonlyArray +} + +type Slot = { + readonly name: string + readonly schema: Schema.Top +} + +const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) +const manifestName = ".httpapi-codegen.json" + +export function compile(api: HttpApi.HttpApi): Output { + const endpoints: Array = [] + const portable = new Map() + + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint, errors, group, middleware }) { + const name = `${group.identifier}.${endpoint.name}` + const required = Array.from(middleware).find((item) => item.requiredForClient) + if (required !== undefined) { + throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` }) + } + + const successSchemas = Array.from(endpoint.success) + if (successSchemas.length === 0) successSchemas.push(HttpApiSchema.NoContent) + if (successSchemas.length > 1) throw new GenerationError({ reason: `Multiple success schemas: ${name}` }) + + const params = normalizeTransport(endpoint.params, "params", endpoint, name) + const query = normalizeTransport(endpoint.query, "query", endpoint, name) + const headers = normalizeTransport(endpoint.headers, "headers", endpoint, name) + const sourcePayloads = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas) + if (sourcePayloads.length > 1) { + throw new GenerationError({ reason: `Multiple payload schemas: ${name}` }) + } + const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!) + const success = normalizeTransport(successSchemas[0], "success", endpoint, name)! + const errorSchemas = Array.from(errors.values()).flatMap((schemas) => + schemas.map((schema) => normalizeTransport(schema, "error", endpoint, name)!), + ) + const inputs = [ + ...inputFields(params, "params", name), + ...inputFields(query, "query", name), + ...inputFields(headers, "headers", name), + ...payloads.flatMap((schema) => inputFields(schema, "payload", name)), + ] + const names = new Set() + for (const field of inputs) { + if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` }) + names.add(field.name) + } + + const schemaPaths: Array = [ + ...(params === undefined ? [] : [[`${name}.params`, params] as const]), + ...(query === undefined ? [] : [[`${name}.query`, query] as const]), + ...(headers === undefined ? [] : [[`${name}.headers`, headers] as const]), + ...payloads.map((schema) => [`${name}.payload`, schema] as const), + ...responseSchemas(success, `${name}.success`), + ...errorSchemas.map((schema) => [`${name}.error`, schema] as const), + ] + for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + + endpoints.push({ + group: group.identifier, + topLevel: group.topLevel, + endpoint, + params, + query, + headers, + payloads, + input: inputs, + unwrapData: isDataEnvelope(success), + successes: [success], + errors: errorSchemas, + operation: { + group: group.identifier, + name: endpoint.name, + input: inputs.map(({ name, source }) => ({ name, source })), + inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", + success: isStreamSchema(success) ? "stream" : HttpApiSchema.isNoContent(success.ast) ? "void" : "value", + errors: [ + ...new Set([ + ...errorSchemas.flatMap((schema) => { + const identifier = SchemaAST.resolveIdentifier(schema.ast) + return identifier === undefined ? [] : [identifier] + }), + "ClientError", + ]), + ], + }, + }) + }, + }) + + const modules = new Set(["client", "client-error", "index"]) + const groups = Array.from( + Map.groupBy(endpoints, (endpoint) => endpoint.group), + ([identifier, endpoints], index) => { + const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}` + const module = uniqueModule(base, index, modules) + modules.add(module.toLowerCase()) + return { identifier, module, endpoints } + }, + ) + const publicNames = new Set() + for (const group of groups) { + const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.endpoint.name) : [group.identifier] + for (const name of names) { + if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) + publicNames.add(name) + } + } + return { + operations: endpoints.map((endpoint) => endpoint.operation), + files: [ + ...groups.map((group, index) => ({ + path: `${group.module}.ts`, + content: renderGroup(group, index), + })), + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: renderClient(groups) }, + { path: "index.ts", content: 'export { ClientError } from "./client-error"\nexport { make } from "./client"\n' }, + ], + } +} + +function uniqueModule(base: string, index: number, modules: ReadonlySet) { + if (!modules.has(base.toLowerCase())) return base + const seed = `${base}-${index}` + let suffix = 0 + while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++ + return `${seed}${suffix === 0 ? "" : `-${suffix}`}` +} + +function normalizeTransport( + schema: Schema.Top | undefined, + source: InputField["source"] | "success" | "error", + endpoint: HttpApiEndpoint.AnyWithProps, + operation: string, +) { + if (schema === undefined || isStreamSchema(schema)) return schema + if (!metadataPortable(schema.ast, new Set())) { + throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + } + const decoded = Schema.toType(schema) + if (!isPathInput(endpoint.path)) { + throw new GenerationError({ reason: `Invalid endpoint path: ${operation}` }) + } + const rebuilt = HttpApiEndpoint.make(endpoint.method)(endpoint.name, endpoint.path, { + ...(source === "params" ? { params: decoded } : undefined), + ...(source === "query" ? { query: decoded } : undefined), + ...(source === "headers" ? { headers: decoded } : undefined), + ...(source === "payload" ? { payload: decoded } : undefined), + ...(source === "success" ? { success: decoded } : { success: Schema.String }), + ...(source === "error" ? { error: decoded } : undefined), + }) + const normalized = + source === "params" + ? rebuilt.params + : source === "query" + ? rebuilt.query + : source === "headers" + ? rebuilt.headers + : source === "payload" + ? Array.from(rebuilt.payload.values())[0]?.schemas[0] + : source === "success" + ? Array.from(rebuilt.success)[0] + : Array.from(rebuilt.error)[0] + if (normalized === undefined || !sameEncoding(schema.ast, normalized.ast)) { + throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + } + return decoded +} + +function isPathInput(path: string): path is HttpRouter.PathInput { + return path === "*" || path.startsWith("/") +} + +function sameEncoding(left: SchemaAST.AST, right: SchemaAST.AST): boolean { + if (left._tag !== right._tag || left.encoding?.length !== right.encoding?.length) return false + if ( + left.encoding?.some((link, index) => { + const other = right.encoding?.[index] + return other === undefined || link.transformation !== other.transformation || !sameEncoding(link.to, other.to) + }) + ) + return false + if (!sameChecks(left.checks, right.checks) || !sameContext(left.context, right.context)) return false + if (SchemaAST.isSuspend(left) && SchemaAST.isSuspend(right)) return sameEncoding(left.thunk(), right.thunk()) + if (SchemaAST.isUnion(left) && SchemaAST.isUnion(right)) { + return ( + left.types.length === right.types.length && + left.types.every((ast, index) => sameEncoding(ast, right.types[index])) + ) + } + if (SchemaAST.isArrays(left) && SchemaAST.isArrays(right)) { + return ( + left.elements.length === right.elements.length && + left.rest.length === right.rest.length && + left.elements.every((ast, index) => sameEncoding(ast, right.elements[index])) && + left.rest.every((ast, index) => sameEncoding(ast, right.rest[index])) + ) + } + if (SchemaAST.isObjects(left) && SchemaAST.isObjects(right)) { + return ( + left.propertySignatures.length === right.propertySignatures.length && + left.indexSignatures.length === right.indexSignatures.length && + left.propertySignatures.every((field, index) => sameEncoding(field.type, right.propertySignatures[index].type)) && + left.indexSignatures.every( + (field, index) => + sameEncoding(field.parameter, right.indexSignatures[index].parameter) && + sameEncoding(field.type, right.indexSignatures[index].type), + ) + ) + } + return true +} + +function sameChecks(left: SchemaAST.Checks | undefined, right: SchemaAST.Checks | undefined): boolean { + if (left?.length !== right?.length) return false + if (left === undefined || right === undefined) return true + return left.every((check, index) => { + const other = right[index] + if (other === undefined || check._tag !== other._tag) return false + if (check._tag === "Filter" && other._tag === "Filter") { + return check.run === other.run && check.aborted === other.aborted + } + return check._tag === "FilterGroup" && other._tag === "FilterGroup" && sameChecks(check.checks, other.checks) + }) +} + +function sameContext(left: SchemaAST.Context | undefined, right: SchemaAST.Context | undefined) { + return left?.isOptional === right?.isOptional && left?.isMutable === right?.isMutable +} + +export function write( + output: Output, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + const paths = new Set() + for (const file of output.files) { + if (!isSafeOutputPath(file.path)) yield* new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + const path = file.path.toLowerCase() + if (paths.has(path)) yield* new GenerationError({ reason: `Duplicate output path: ${file.path}` }) + paths.add(path) + } + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(directory, { recursive: true }) + const manifest = join(directory, manifestName) + const previous = (yield* fs.exists(manifest)) + ? yield* fs.readFileString(manifest).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Manifest)), + Effect.mapError(() => new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })), + ) + : [] + if (previous.some((path) => !isSafeOutputPath(path))) { + yield* new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` }) + } + yield* Effect.forEach( + previous.filter((path) => !output.files.some((file) => file.path === path)), + (path) => fs.remove(join(directory, path), { force: true }), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + fs.exists(join(directory, file.path)).pipe( + Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))), + Effect.flatMap((info) => + info?.type === "SymbolicLink" + ? new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + : Effect.void, + ), + ), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + Effect.tryPromise({ + try: () => format(file.content, { parser: "typescript", semi: false, printWidth: 120 }), + catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }), + }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))), + { concurrency: 8, discard: true }, + ) + yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n") + }) +} + +function isSafeOutputPath(path: string) { + return path !== manifestName && !isAbsolute(path) && path !== "." && path !== ".." && !/[\\/]/.test(path) +} + +export function generate( + api: HttpApi.HttpApi, + options: { readonly directory: string }, +): Effect.Effect { + return Effect.try({ + try: () => compile(api), + catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), + }).pipe(Effect.flatMap((output) => write(output, options.directory))) +} + +function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { + if (schema === undefined) return [] + const ast = Schema.toType(schema).ast + if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) + } + return ast.propertySignatures.map((field) => { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) + } + return { + name: field.name, + source, + optional: SchemaAST.isOptional(field.type), + } + }) +} + +function responseSchemas(schema: Schema.Top, path: string): Array { + if (HttpApiSchema.isNoContent(schema.ast)) return [] + if (!isStreamSchema(schema)) return [[path, schema]] + if (schema._tag === "StreamUint8Array") return [] + const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events + const rebuilt = + schema.sseMode === "data" + ? HttpApiSchema.StreamSse({ data: value, error: schema.error, contentType: schema.contentType }) + : HttpApiSchema.StreamSse({ + events: schema.events, + error: schema.error, + contentType: schema.contentType, + }) + if (!sameEncoding(schema.events.ast, rebuilt.events.ast)) { + throw new GenerationError({ reason: `Unportable schema: ${path}.${schema.sseMode}` }) + } + return [ + [`${path}.${schema.sseMode}`, value], + [`${path}.error`, schema.error], + ] +} + +function assertPortable(schema: Schema.Top, path: string, portable: Map) { + const visiting = new Set() + const taggedError = taggedErrorFields(schema) + const visit = (ast: SchemaAST.AST): boolean => { + const cached = portable.get(ast) + if (cached !== undefined) return cached + if (visiting.has(ast)) return true + visiting.add(ast) + const result = visitCurrent(ast) + visiting.delete(ast) + portable.set(ast, result) + return result + } + const visitCurrent = (ast: SchemaAST.AST): boolean => { + if (!annotationsPortable(ast.annotations)) return false + if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false + if (SchemaAST.isDeclaration(ast)) { + return generationPortable(ast.annotations?.generation) && ast.typeParameters.every(visit) + } + if (ast.encoding !== undefined && ast.annotations?.generation === undefined) return false + if (SchemaAST.isSuspend(ast)) return visit(ast.thunk()) + if (SchemaAST.isUnion(ast)) return ast.types.every(visit) + if (SchemaAST.isArrays(ast)) { + return ast.elements.every(visit) && ast.rest.every(visit) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => visit(field.type)) && + ast.indexSignatures.every((index) => visit(index.parameter) && visit(index.type)) + ) + } + if (SchemaAST.isTemplateLiteral(ast)) return ast.parts.every(visit) + return true + } + if (taggedError !== undefined && SchemaAST.isDeclaration(schema.ast)) { + if ( + schema.ast.checks !== undefined || + ("encodingChecks" in schema.ast && !checksPortable(schema.ast.encodingChecks)) || + schema.ast.typeParameters.some((ast) => ast.checks !== undefined) || + !schema.ast.typeParameters.every(visit) + ) { + throw new GenerationError({ reason: `Unportable schema: ${path}` }) + } + return + } + if (!visit(schema.ast)) throw new GenerationError({ reason: `Unportable schema: ${path}` }) +} + +function checksPortable(checks: SchemaAST.Checks | undefined): boolean { + if (checks === undefined) return true + return checks.every((check) => + check._tag === "Filter" + ? !check.aborted && + check.annotations?.meta !== undefined && + typeof check.annotations.arbitrary === "object" && + check.annotations.arbitrary !== null && + "constraint" in check.annotations.arbitrary + : checksPortable(check.checks), + ) +} + +function metadataPortable(ast: SchemaAST.AST, seen: Set): boolean { + if (seen.has(ast)) return true + seen.add(ast) + if (!annotationsPortable(ast.annotations) || !checksPortable(ast.checks)) return false + if ("encodingChecks" in ast && !checksPortable(ast.encodingChecks)) return false + if (ast.encoding?.some((link) => !metadataPortable(link.to, seen))) return false + if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isSuspend(ast)) return metadataPortable(ast.thunk(), seen) + if (SchemaAST.isUnion(ast)) return ast.types.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isArrays(ast)) { + return ( + ast.elements.every((item) => metadataPortable(item, seen)) && + ast.rest.every((item) => metadataPortable(item, seen)) + ) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => metadataPortable(field.type, seen)) && + ast.indexSignatures.every( + (field) => metadataPortable(field.parameter, seen) && metadataPortable(field.type, seen), + ) + ) + } + return true +} + +function generationPortable(generation: unknown): boolean { + if (typeof generation !== "object" || generation === null) return false + const value = generation as { + readonly runtime?: unknown + readonly Type?: unknown + readonly importDeclaration?: unknown + } + if (typeof value.runtime !== "string" || typeof value.Type !== "string") return false + if (value.importDeclaration !== undefined) { + if ( + typeof value.importDeclaration !== "string" || + !/from ["']effect(?:\/[^"']+)?["']$/.test(value.importDeclaration) + ) { + return false + } + } + const namespace = + typeof value.importDeclaration === "string" + ? /import(?: type)? \* as ([A-Za-z_$][\w$]*)/.exec(value.importDeclaration)?.[1] + : undefined + return value.runtime.startsWith("Schema.") || (namespace !== undefined && value.runtime.startsWith(`${namespace}.`)) +} + +function annotationsPortable(annotations: Schema.Annotations.Annotations | undefined) { + if (annotations === undefined) return true + return Object.entries(annotations).every(([key, value]) => { + if ( + ["toCodec", "toCodecJson", "toArbitrary", "toFormatter", "toEquivalence", "~effect/Schema/Class"].includes(key) + ) { + return true + } + if (key === "generation") return generationPortable(value) + return serializable(value) + }) +} + +function serializable(value: unknown): boolean { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true + if (Array.isArray(value)) return value.every(serializable) + if (typeof value !== "object") return false + return Object.values(value).every(serializable) +} + +function taggedErrorFields(schema: Schema.Top) { + if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) { + return undefined + } + const fields = schema.ast.typeParameters[0] + if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined + const tag = fields.propertySignatures.find((field) => field.name === "_tag")?.type + if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined + return { + tag: tag.literal, + identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, + fields: fields.propertySignatures.flatMap((field) => + field.name === "_tag" || typeof field.name !== "string" ? [] : [[field.name, Schema.make(field.type)] as const], + ), + } +} + +function isDataEnvelope(schema: Schema.Top) { + if (isStreamSchema(schema) || HttpApiSchema.isNoContent(schema.ast)) return false + const ast = Schema.toType(schema).ast + return ( + SchemaAST.isObjects(ast) && + ast.indexSignatures.length === 0 && + ast.propertySignatures.length === 1 && + ast.propertySignatures[0]?.name === "data" + ) +} + +function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchema { + return "_tag" in schema && (schema._tag === "StreamSse" || schema._tag === "StreamUint8Array") +} + +function streamDataSchema(schema: Extract) { + const ast = Schema.toType(schema.events).ast + if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const data = ast.propertySignatures.find((field) => field.name === "data")?.type + if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(data) +} + +function renderGroup(group: Group, groupIndex: number) { + const slots: Array = [] + const adapters: Array = [] + const endpointSources = group.endpoints.map( + ( + { + endpoint, + errors, + headers: endpointHeaders, + params: endpointParams, + payloads: endpointPayloads, + query: endpointQuery, + successes, + }, + endpointIndex, + ) => { + const prefix = `Endpoint${endpointIndex}` + const params = addSlot(endpointParams, `${prefix}Params`) + const query = addSlot(endpointQuery, `${prefix}Query`) + const headers = addSlot(endpointHeaders, `${prefix}Headers`) + const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!) + const success = renderSuccess(successes[0], `${prefix}Success`) + const errorSlots = errors.map((schema, index) => addSlot(schema, `${prefix}Error${index}`)!) + const options = [ + params === undefined ? undefined : `params: ${params.name}`, + query === undefined ? undefined : `query: ${query.name}`, + headers === undefined ? undefined : `headers: ${headers.name}`, + payloads.length === 0 + ? undefined + : `payload: ${payloads.length === 1 ? payloads[0].name : `[${payloads.map((slot) => slot.name).join(", ")}]`}`, + `success: ${success.source}`, + errorSlots.length === 0 + ? undefined + : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, + ].filter((option): option is string => option !== undefined) + const operation = group.endpoints[endpointIndex] + if (operation === undefined) { + throw new GenerationError({ reason: `Missing operation: ${group.identifier}.${endpoint.name}` }) + } + const schemaBySource = { params, query, headers, payload: payloads[0] } + const inputType = operation.input + .map((field) => { + const slot = schemaBySource[field.source] + if (slot === undefined) { + throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) + } + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + }) + .join("; ") + const argument = + operation.operation.inputMode === "none" + ? "" + : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const slot = schemaBySource[source] + if (slot === undefined) return [] + const fields = operation.input + .filter((field) => field.source === source) + .map( + (field) => + `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`, + ) + return [`${source}: { ${fields.join(", ")} }`] + }) + .join(", ") + const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])] + const declaredSchema = + declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` + const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + const inputDeclaration = + operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + adapters.push( + `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, + ) + return `HttpApiEndpoint.make(${JSON.stringify(endpoint.method)})(${JSON.stringify(endpoint.name)}, ${JSON.stringify(endpoint.path)}, { ${options.join(", ")} })` + }, + ) + + function addSlot(schema: Schema.Top | undefined, name: string) { + if (schema === undefined) return undefined + const slot = { name, schema } + slots.push(slot) + return slot + } + + function renderSuccess(schema: Schema.Top, name: string) { + if (!isStreamSchema(schema)) return { source: addSlot(schema, name)!.name } + const status = resolveHttpApiStatus(schema.ast) ?? 200 + const annotate = status === 200 ? "" : `.pipe(HttpApiSchema.status(${status}))` + if (schema._tag === "StreamUint8Array") { + return { + source: `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + } + } + const value = addSlot( + schema.sseMode === "data" ? streamDataSchema(schema) : schema.events, + `${name}${schema.sseMode === "data" ? "Data" : "Events"}`, + )! + const error = addSlot(schema.error, `${name}Error`)! + return { + source: `HttpApiSchema.StreamSse({ ${schema.sseMode}: ${value.name}, error: ${error.name}, contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + streamError: error, + } + } + + const declarations = renderSchemas(slots) + const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` + const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) + const methods = group.endpoints + .map((item, index) => `${JSON.stringify(item.endpoint.name)}: Endpoint${index}(raw)`) + .join(", ") + const rawGroup = group.endpoints[0]?.topLevel + ? `HttpApiClient.Client` + : `HttpApiClient.Client.Group` + const usesStream = group.endpoints.some((item) => item.operation.success === "stream") + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n` +} + +function renderSchemas(slots: ReadonlyArray) { + if (slots.length === 0) return "" + const classes = new Map( + slots.flatMap((slot, index) => { + const tagged = taggedErrorFields(slot.schema) + return tagged === undefined ? [] : [[index, tagged] as const] + }), + ) + const expanded = [ + ...slots.map((slot, index) => (classes.has(index) ? { name: slot.name, schema: Schema.Never } : slot)), + ...Array.from(classes.values()).flatMap((tagged, classIndex) => + tagged.fields.map(([name, schema]) => ({ name: `Class${classIndex}${name}`, schema })), + ), + ] + const [first, ...rest] = expanded + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromASTs([first.schema.ast, ...rest.map((slot) => slot.schema.ast)]), + ) + const artifacts = document.artifacts.flatMap((artifact) => { + if (artifact._tag === "Import") return [artifact.importDeclaration] + if (artifact._tag === "Enum") return [artifact.generation.runtime] + return [`const ${artifact.identifier} = ${artifact.generation.runtime}`] + }) + const references = [ + ...document.references.nonRecursives.map(({ $ref, code }) => `const ${$ref} = ${code.runtime}`), + ...Object.entries(document.references.recursives).map( + ([$ref, code]) => `type ${$ref} = ${code.Type}\nconst ${$ref}: Schema.Codec<${$ref}> = ${code.runtime}`, + ), + ] + let fieldIndex = slots.length + const declarations = slots.map((slot, index) => { + const tagged = classes.get(index) + if (tagged === undefined) return `const ${slot.name} = ${document.codes[index].runtime}` + const fields = tagged.fields + .map(([name]) => `${JSON.stringify(name)}: ${document.codes[fieldIndex++].runtime}`) + .join(", ") + const annotations = Object.entries({ + httpApiStatus: resolveHttpApiStatus(slot.schema.ast), + "~httpApiEncoding": resolveHttpApiEncoding(slot.schema.ast), + }).filter((entry) => entry[1] !== undefined) + const annotate = + annotations.length === 0 + ? "" + : `.annotate({ ${annotations.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")} })` + return `class ${slot.name}Class extends Schema.TaggedErrorClass<${slot.name}Class>(${JSON.stringify(tagged.identifier)})(${JSON.stringify(tagged.tag)}, { ${fields} }) {}\nconst ${slot.name} = ${slot.name}Class${annotate}` + }) + return [...artifacts, ...references, ...declarations].join("\n\n") +} + +function renderClient(groups: ReadonlyArray) { + const imports = groups + .map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`) + .join("\n") + const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}` + const fields = groups.flatMap((group, index) => { + if (!group.endpoints[0]?.topLevel) { + return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`] + } + const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }` + return [`...adaptGroup${index}(${raw})`] + }) + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n` +} diff --git a/packages/httpapi-codegen/test/effect.ts b/packages/httpapi-codegen/test/effect.ts new file mode 100644 index 000000000000..3accad3cc936 --- /dev/null +++ b/packages/httpapi-codegen/test/effect.ts @@ -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 = Effect.Effect | (() => Effect.Effect) + +const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +const effect = (name: string, body: Body, options?: Parameters[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 } diff --git a/packages/httpapi-codegen/test/fixture.ts b/packages/httpapi-codegen/test/fixture.ts new file mode 100644 index 000000000000..9fb7fedda101 --- /dev/null +++ b/packages/httpapi-codegen/test/fixture.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" + +export class Missing extends Schema.TaggedErrorClass()("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 }), + ), + ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts new file mode 100644 index 000000000000..0f457a0f3f93 --- /dev/null +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, test } from "bun:test" +import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" +import { compile, generate, GenerationError } from "../src" +import { it } from "./effect" +import { Api as FixtureApi } from "./fixture" + +function api(endpoint: HttpApiEndpoint.Any) { + return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint)) +} + +describe("HttpApiCodegen.generate", () => { + 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("Unportable schema: session.get.query") + }) + + 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( + "Unportable schema: session.get.success", + ) + }) + + 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", {}) {} + class Authorization extends HttpApiMiddleware.Service()("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("Unauthorized")', + ) + }) + + test("preserves tagged error response statuses", () => { + class Missing extends Schema.TaggedErrorClass()("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 + 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", { + 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 })", + ) + }) +}) diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts new file mode 100644 index 000000000000..1eb1d2bc10ef --- /dev/null +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -0,0 +1,28 @@ +import { Effect, Stream } from "effect" +import { HttpClient } from "effect/unstable/http" +import { ClientError, make } from "./generated" +import { Missing } from "./fixture" + +export const program = 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 = health + const _list: Effect.Effect, ClientError> = list + const _filtered: Effect.Effect, ClientError> = filtered + const _get: Effect.Effect = get + const _interrupt: Effect.Effect = interrupt + const _status: Effect.Effect = status + const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe + + return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe } + }), +) + +const _requiresHttpClient: Effect.Effect = program diff --git a/packages/httpapi-codegen/test/generated/client-error.ts b/packages/httpapi-codegen/test/generated/client-error.ts new file mode 100644 index 000000000000..bcc65d9bdd20 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/httpapi-codegen/test/generated/client.ts b/packages/httpapi-codegen/test/generated/client.ts new file mode 100644 index 000000000000..0c3071823a5b --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client.ts @@ -0,0 +1,17 @@ +// 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) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(Api, options).pipe( + Effect.map((raw) => ({ + session: adaptGroup0(raw["session"]), + event: adaptGroup1(raw["event"]), + ...adaptGroup2({ status: raw["status"] }), + })), + ) diff --git a/packages/httpapi-codegen/test/generated/event.ts b/packages/httpapi-codegen/test/generated/event.ts new file mode 100644 index 000000000000..764f2fd1c70b --- /dev/null +++ b/packages/httpapi-codegen/test/generated/event.ts @@ -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 + +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) }) diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts new file mode 100644 index 000000000000..0eb8005be9f0 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export { make } from "./client" diff --git a/packages/httpapi-codegen/test/generated/session.ts b/packages/httpapi-codegen/test/generated/session.ts new file mode 100644 index 000000000000..6a1937c49e24 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/session.ts @@ -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("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 + +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), +}) diff --git a/packages/httpapi-codegen/test/generated/system.ts b/packages/httpapi-codegen/test/generated/system.ts new file mode 100644 index 000000000000..6ba523d2c0e0 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/system.ts @@ -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 + +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) }) diff --git a/packages/httpapi-codegen/test/write.test.ts b/packages/httpapi-codegen/test/write.test.ts new file mode 100644 index 000000000000..f0704abea2b0 --- /dev/null +++ b/packages/httpapi-codegen/test/write.test.ts @@ -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 = [] + 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 = [] + 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 = [] + 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(), + }), + }), + ), + ), + ) +}) diff --git a/packages/httpapi-codegen/tsconfig.json b/packages/httpapi-codegen/tsconfig.json new file mode 100644 index 000000000000..00ef12546856 --- /dev/null +++ b/packages/httpapi-codegen/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} From 66bcddd0595adf4021fba61cfeab4bb733efcabc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 22:10:48 -0400 Subject: [PATCH 2/2] feat(sdk): add Promise client codegen --- CONTEXT.md | 56 ++++ bun.lock | 16 ++ packages/client/README.md | 11 + packages/client/package.json | 28 ++ packages/client/src/effect-embedded.ts | 2 + packages/client/src/effect.ts | 2 + packages/client/src/index.ts | 2 + packages/client/tsconfig.json | 7 + packages/httpapi-codegen/README.md | 20 +- packages/httpapi-codegen/src/index.ts | 254 ++++++++++++++++- .../httpapi-codegen/test/generate.test.ts | 255 +++++++++++++++++- .../test/generated-consumer.ts | 4 +- .../httpapi-codegen/test/generated/index.ts | 2 +- 13 files changed, 631 insertions(+), 28 deletions(-) create mode 100644 packages/client/README.md create mode 100644 packages/client/package.json create mode 100644 packages/client/src/effect-embedded.ts create mode 100644 packages/client/src/effect.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/tsconfig.json diff --git a/CONTEXT.md b/CONTEXT.md index faf8ce9d125f..e0e898f15acf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -55,6 +55,21 @@ Provider-neutral sampling and output controls, partitioned from provider semanti **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. +**OpenCode Client**: +The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers. +_Avoid_: Remote client + +**SDK Contract IR**: +The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter. + +**Embedded OpenCode**: +A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly. +_Avoid_: Local implementation + +**Page**: +A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction. +_Avoid_: Response envelope + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -100,6 +115,47 @@ The host-supplied environment overlay applied by the server when creating a PTY, - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. +- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. +- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. +- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers. +- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property. +- The **OpenCode Client** uses plural consumer-facing capability groups such as `sessions`; internal server identifiers such as `server.session` and `session.get` do not define its public names. +- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server hosts those exact endpoint declarations and code generation consumes them directly. Public endpoints are not duplicated or projected from a separately named internal contract. +- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. +- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. +- Initially, the rich Effect emitter regenerates private executable schemas and `HttpApi` groups from the **SDK Contract IR**, reproducing semantics exactly or rejecting generation. Importing authoritative schemas from a future dependency-leaf public API package remains an alternative emitter strategy, not a prerequisite refactor. +- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. +- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. +- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. +- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division. +- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred. +- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. +- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. +- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. +- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. +- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. +- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. +- Promise and Effect client outputs ship from `@opencode-ai/client` behind isolated root, `/effect`, and `/effect/embedded` exports. The root export has no runtime path to Effect; `/effect` contains only the rich network client and may use Effect as an optional peer dependency; `/effect/embedded` owns the heavy private Core/server dependency closure for the scoped embedded host. The package remains private until the authoritative public `HttpApi` is composed and packaging of embedded dependencies is settled. +- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. +- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. +- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. +- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. +- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. +- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. +- `sessions.list(...)` returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. +- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. +- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. +- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. +- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. +- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? +- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior. +- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics. +- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics. +- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session. +- A future Layer constructor may provide **Embedded OpenCode** using the same scoped creation path; it does not define a second implementation. - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. diff --git a/bun.lock b/bun.lock index ca92b78d5355..0535c45cf6fa 100644 --- a/bun.lock +++ b/bun.lock @@ -109,6 +109,20 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/client": { + "name": "@opencode-ai/client", + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.83", + }, + "optionalPeers": [ + "effect", + ], + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.9", @@ -1786,6 +1800,8 @@ "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 000000000000..d5a6353e7c03 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,11 @@ +# @opencode-ai/client + +Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`. + +## Entrypoints + +- `@opencode-ai/client`: zero-Effect Promise client using `fetch`. +- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. +- `@opencode-ai/client/effect/embedded`: scoped embedded OpenCode host backed by Core and the in-memory HTTP router. + +The entry modules are intentionally empty until the authoritative public `HttpApi` is composed. Do not generate clients from the current internal server API. diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 000000000000..4f304b0e1945 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/client", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./effect": "./src/effect.ts", + "./effect/embedded": "./src/effect-embedded.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "peerDependencies": { + "effect": "4.0.0-beta.83" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/client/src/effect-embedded.ts b/packages/client/src/effect-embedded.ts new file mode 100644 index 000000000000..614e4f6a151d --- /dev/null +++ b/packages/client/src/effect-embedded.ts @@ -0,0 +1,2 @@ +// Embedded Effect host target. Intentionally empty until the public HttpApi is available. +export {} diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts new file mode 100644 index 000000000000..8ddd14133281 --- /dev/null +++ b/packages/client/src/effect.ts @@ -0,0 +1,2 @@ +// Generated Effect network client target. Intentionally empty until the public HttpApi is available. +export {} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 000000000000..daf5eac75be1 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,2 @@ +// Generated Promise client target. Intentionally empty until the public HttpApi is available. +export {} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 000000000000..8e4f68a035f2 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"] + } +} diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md index 80b6c86d24d7..14a4dd3932af 100644 --- a/packages/httpapi-codegen/README.md +++ b/packages/httpapi-codegen/README.md @@ -1,11 +1,19 @@ # @opencode-ai/httpapi-codegen -Build-time source generation for domain-oriented Effect APIs derived from `HttpApi` and Effect Schema contracts. +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. @@ -16,22 +24,16 @@ The package is private while its API is explored. Its tests are the executable s - Expose streaming success as `Stream`, not `Effect`. - Reject schemas whose wire/domain transformation cannot be generated exactly. - Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`. -- Generate only the Effect API initially; Promise runtime ownership, cancellation, and stream adaptation are deferred. - 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 the remote API derived from `HttpApi`. It does not generate embedded implementations or embedded-only capabilities. The OpenCode integration composes two distinct total objects: - -- A remote object containing the generated HTTP capabilities. -- An embedded object implementing the shared shape against local services and adding embedded-only capabilities. - -The embedded object may be a structural superset of the remote object, but the constructors and concrete result types remain distinct. +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 public `generate(Api, { directory })` operation is an Effect requiring `FileSystem`. Internally it composes a pure `compile(Api)` phase with `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`. +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. diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 04f926c8e047..d691b1882874 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -1,6 +1,6 @@ import { isAbsolute, join } from "node:path" import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" -import type { HttpRouter } from "effect/unstable/http" +import { HttpMethod, type HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { format } from "prettier" @@ -26,6 +26,10 @@ export type Output = { }> } +export type Contract = { + readonly groups: ReadonlyArray +} + export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { reason: Schema.String, }) { @@ -34,7 +38,7 @@ export class GenerationError extends Schema.TaggedErrorClass()( } } -type Endpoint = { +export type Endpoint = { readonly group: string readonly topLevel: boolean readonly endpoint: HttpApiEndpoint.AnyWithProps @@ -49,7 +53,7 @@ type Endpoint = { readonly successes: ReadonlyArray } -type Group = { +export type Group = { readonly identifier: string readonly module: string readonly endpoints: ReadonlyArray @@ -61,11 +65,13 @@ type Slot = { } const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") -const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) const manifestName = ".httpapi-codegen.json" -export function compile(api: HttpApi.HttpApi): Output { +export function compile( + api: HttpApi.HttpApi, +): Contract { const endpoints: Array = [] const portable = new Map() @@ -167,23 +173,243 @@ export function compile(api: } } return { - operations: endpoints.map((endpoint) => endpoint.operation), + groups, + } +} + +export function emitEffect(contract: Contract): Output { + return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } +} + +export function emitPromise(contract: Contract): Output { + const groups = contract.groups + for (const group of groups) { + for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) + } + return { + operations: operations(groups), files: [ - ...groups.map((group, index) => ({ - path: `${group.module}.ts`, - content: renderGroup(group, index), - })), + { path: "types.ts", content: renderPromiseTypes(groups) }, { path: "client-error.ts", + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { path: "client.ts", content: renderPromiseClient(groups) }, + { + path: "index.ts", content: - 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\n', }, - { path: "client.ts", content: renderClient(groups) }, - { path: "index.ts", content: 'export { ClientError } from "./client-error"\nexport { make } from "./client"\n' }, ], } } +function assertPromiseEndpoint(endpoint: Endpoint) { + const name = `${endpoint.group}.${endpoint.endpoint.name}` + const payload = endpoint.payloads[0] + const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast) + if ( + payload !== undefined && + (payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` }) + } + const success = endpoint.successes[0] + if (isStreamSchema(success)) { + if ( + success._tag !== "StreamSse" || + success.sseMode !== "data" || + !SchemaAST.isNever(Schema.toType(success.error).ast) + ) { + throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) + } + } else if ( + !HttpApiSchema.isNoContent(success.ast) && + (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } + for (const error of endpoint.errors) { + if (taggedErrorFields(error) === undefined) { + throw new GenerationError({ reason: `Promise error must be tagged: ${name}` }) + } + if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") { + throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` }) + } + } +} + +function operations(groups: ReadonlyArray) { + return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) +} + +function renderEffectFiles(groups: ReadonlyArray): Output["files"] { + return [ + ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: renderClient(groups) }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderPromiseTypes(groups: ReadonlyArray) { + const types = new Map() + const typeOf = (schema: Schema.Top) => { + const encoded = Schema.toEncoded(schema) + const cached = types.get(encoded.ast) + if (cached !== undefined) return cached + const type = structuralType(encoded) + types.set(encoded.ast, type) + return type + } + const errors = new Map( + groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => + endpoint.errors.flatMap((schema) => { + const tagged = taggedErrorFields(schema) + return tagged === undefined ? [] : [[tagged.tag, tagged] as const] + }), + ), + ), + ) + const errorTypes = Array.from(errors.values()).map((error) => { + const fields = error.fields + .map(([name, schema]) => `readonly ${JSON.stringify(name)}: ${typeOf(schema)}`) + .join("; ") + return `export type ${error.identifier} = { readonly _tag: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && "_tag" in value && value._tag === ${JSON.stringify(error.tag)}` + }) + const operations = groups + .flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + const schemas = { + params: endpoint.params, + query: endpoint.query, + headers: endpoint.headers, + payload: endpoint.payloads[0], + } + const input = endpoint.input + .map((field) => { + const schema = schemas[field.source] + if (schema === undefined) + throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]` + }) + .join("; ") + const successSchema = endpoint.successes[0] + const success = typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamDataSchema(successSchema) + : successSchema.events + : successSchema, + ) + return [ + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, + ] + }), + ) + .join("\n\n") + return [...errorTypes, operations].filter(Boolean).join("\n\n") +} + +function renderPromiseClient(groups: ReadonlyArray) { + const imports = groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + }), + ) + const fields = groups.map((group) => { + const methods = group.endpoints.map((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + const argument = + endpoint.operation.inputMode === "none" + ? "requestOptions?: RequestOptions" + : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input) + const access = (name: string) => `input${endpoint.operation.inputMode === "optional" ? "?." : "."}${name}` + const part = (source: InputField["source"]) => { + const inputs = endpoint.input.filter((field) => field.source === source) + return inputs.length === 0 + ? undefined + : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` + } + const parts = [ + endpoint.query === undefined ? undefined : `query: ${part("query")}`, + endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`, + endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, + ].filter((value): value is string => value !== undefined) + const declaredStatuses = [ + ...new Set( + endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined), + ), + ] + 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"} }` + if (endpoint.operation.success === "stream") { + const success = endpoint.successes[0] + if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { + throw new GenerationError({ + reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return `${JSON.stringify(endpoint.endpoint.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + } + const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" + return `${JSON.stringify(endpoint.endpoint.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + }) + if (group.endpoints[0]?.topLevel) return methods.join(", ") + return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` + }) + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` +} + +function promiseTypePrefix(group: string, endpoint: string) { + return `${identifierPart(group)}${identifierPart(endpoint)}` +} + +function identifierPart(value: string) { + return value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join("") +} + +function structuralType(schema: Schema.Top) { + const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) + if ( + document.artifacts.length > 0 || + document.references.nonRecursives.length > 0 || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + return document.codes[0].Type +} + +function promisePath(path: string, input: ReadonlyArray) { + 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 template = segments + .map((segment) => { + if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") + const name = segment.slice(1) + if (!fields.has(name)) throw new GenerationError({ reason: `Missing path parameter: ${name}` }) + return `\${encodeURIComponent(input.${name})}` + }) + .join("") + return `\`${template}\`` +} + function uniqueModule(base: string, index: number, modules: ReadonlySet) { if (!modules.has(base.toLowerCase())) return base const seed = `${base}-${index}` @@ -357,7 +583,7 @@ export function generate( options: { readonly directory: string }, ): Effect.Effect { return Effect.try({ - try: () => compile(api), + try: () => emitEffect(compile(api)), catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), }).pipe(Effect.flatMap((output) => write(output, options.directory))) } diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 0f457a0f3f93..f0ed07090521 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -1,16 +1,267 @@ 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, generate, GenerationError } from "../src" +import { compile as compileContract, emitEffect, emitPromise, generate, GenerationError } from "../src" import { it } from "./effect" -import { Api as FixtureApi } from "./fixture" +import { Api as FixtureApi, Missing } from "./fixture" function api(endpoint: HttpApiEndpoint.Any) { return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint)) } +function compile(source: HttpApi.HttpApi) { + 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 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( diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts index 1eb1d2bc10ef..448db01be823 100644 --- a/packages/httpapi-codegen/test/generated-consumer.ts +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -1,9 +1,9 @@ import { Effect, Stream } from "effect" import { HttpClient } from "effect/unstable/http" -import { ClientError, make } from "./generated" +import { ClientError, OpenCode } from "./generated" import { Missing } from "./fixture" -export const program = make().pipe( +export const program = OpenCode.make().pipe( Effect.map((client) => { const health = client.session.health() const list = client.session.list() diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts index 0eb8005be9f0..bc0dbc9fa4df 100644 --- a/packages/httpapi-codegen/test/generated/index.ts +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -1,2 +1,2 @@ export { ClientError } from "./client-error" -export { make } from "./client" +export * as OpenCode from "./client"