feat(sdk): add Effect HttpApi codegen
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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<Stream>`.
|
||||
- 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.
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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<InputField>
|
||||
readonly inputMode: "none" | "optional" | "required"
|
||||
readonly success: "value" | "void" | "stream"
|
||||
readonly errors: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type Output = {
|
||||
readonly operations: ReadonlyArray<Operation>
|
||||
readonly files: ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly content: string
|
||||
}>
|
||||
}
|
||||
|
||||
export class GenerationError extends Schema.TaggedErrorClass<GenerationError>()("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<Schema.Top>
|
||||
readonly operation: Operation
|
||||
readonly input: ReadonlyArray<InputField & { readonly optional: boolean }>
|
||||
readonly unwrapData: boolean
|
||||
readonly errors: ReadonlyArray<Schema.Top>
|
||||
readonly successes: ReadonlyArray<Schema.Top>
|
||||
}
|
||||
|
||||
type Group = {
|
||||
readonly identifier: string
|
||||
readonly module: string
|
||||
readonly endpoints: ReadonlyArray<Endpoint>
|
||||
}
|
||||
|
||||
type Slot = {
|
||||
readonly name: string
|
||||
readonly schema: Schema.Top
|
||||
}
|
||||
|
||||
const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
|
||||
const resolveHttpApiEncoding = SchemaAST.resolveAt<unknown>("~httpApiEncoding")
|
||||
const Manifest = Schema.fromJsonString(Schema.Array(Schema.String))
|
||||
const manifestName = ".httpapi-codegen.json"
|
||||
|
||||
export function compile<Id extends string, Groups extends HttpApiGroup.Any>(api: HttpApi.HttpApi<Id, Groups>): Output {
|
||||
const endpoints: Array<Endpoint> = []
|
||||
const portable = new Map<SchemaAST.AST, boolean>()
|
||||
|
||||
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<string>()
|
||||
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<readonly [string, Schema.Top]> = [
|
||||
...(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<string>()
|
||||
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>()("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<string>) {
|
||||
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<void, GenerationError | PlatformError.PlatformError, FileSystem.FileSystem> {
|
||||
return Effect.gen(function* () {
|
||||
const paths = new Set<string>()
|
||||
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<Id extends string, Groups extends HttpApiGroup.Any>(
|
||||
api: HttpApi.HttpApi<Id, Groups>,
|
||||
options: { readonly directory: string },
|
||||
): Effect.Effect<void, GenerationError | PlatformError.PlatformError, FileSystem.FileSystem> {
|
||||
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<readonly [string, Schema.Top]> {
|
||||
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<SchemaAST.AST, boolean>) {
|
||||
const visiting = new Set<SchemaAST.AST>()
|
||||
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<SchemaAST.AST>): 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<HttpApiSchema.StreamSchema, { readonly _tag: "StreamSse" }>) {
|
||||
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<Slot> = []
|
||||
const adapters: Array<string> = []
|
||||
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<typeof Group${groupIndex}>`
|
||||
: `HttpApiClient.Client.Group<typeof Group${groupIndex}, ${JSON.stringify(group.identifier)}, never, never>`
|
||||
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<Slot>) {
|
||||
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<Group>) {
|
||||
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`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import type { Scope } from "effect/Scope"
|
||||
import { TestClock, TestConsole } from "effect/testing"
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
|
||||
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
||||
|
||||
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
|
||||
test(
|
||||
name,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
|
||||
}
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise),
|
||||
options,
|
||||
)
|
||||
|
||||
export const it = { effect }
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
|
||||
export class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Api = HttpApi.make("fixture")
|
||||
.add(
|
||||
HttpApiGroup.make("session")
|
||||
.add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", "/session", {
|
||||
query: { archived: Schema.optional(Schema.Boolean) },
|
||||
success: Schema.Array(Schema.String),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
error: Missing.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
|
||||
params: { sessionID: Schema.String },
|
||||
success: HttpApiSchema.NoContent,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiGroup.make("event").add(
|
||||
HttpApiEndpoint.get("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe(
|
||||
HttpApiSchema.status(202),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
|
||||
),
|
||||
)
|
||||
@@ -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>()("Unauthorized", {}) {}
|
||||
class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
|
||||
error: Unauthorized,
|
||||
}) {}
|
||||
|
||||
const output = compile(
|
||||
api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
|
||||
)
|
||||
|
||||
expect(output.operations[0]).toBeDefined()
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves tagged error response statuses", () => {
|
||||
class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.String,
|
||||
error: Missing.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
|
||||
)
|
||||
})
|
||||
|
||||
test("supports every HttpApi method through the generic constructor", () => {
|
||||
const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
|
||||
})
|
||||
|
||||
test("uses safe unique module paths without changing public group identifiers", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
|
||||
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
|
||||
})
|
||||
|
||||
test("reserves support module names case-insensitively", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
|
||||
})
|
||||
|
||||
test("keeps searching when a reserved-name fallback is also occupied", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
|
||||
})
|
||||
|
||||
test("rejects collisions in the flattened client namespace", () => {
|
||||
expect(() =>
|
||||
compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
|
||||
.add(
|
||||
HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toThrow("Client name collision: status")
|
||||
})
|
||||
|
||||
test("emits a usable raw type for top-level groups", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test").add(
|
||||
HttpApiGroup.make("health", { topLevel: true }).add(
|
||||
HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
|
||||
})
|
||||
|
||||
it.effect("reports compiler failures in the generate Effect", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* generate(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/url", {
|
||||
success: Schema.declare((input): input is URL => input instanceof URL),
|
||||
}),
|
||||
),
|
||||
{
|
||||
directory: "/generated",
|
||||
},
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(GenerationError)
|
||||
if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
|
||||
}).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
|
||||
)
|
||||
|
||||
test("rejects required client middleware without an adapter", () => {
|
||||
class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
|
||||
requiredForClient: true,
|
||||
}) {}
|
||||
|
||||
expect(() =>
|
||||
compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
|
||||
).toThrow("Client middleware requires adapter: SignedRequest")
|
||||
})
|
||||
|
||||
test("maps transport and decode failures to one stable client error", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.operations[0]?.errors).toContain("ClientError")
|
||||
expect(output.operations[0]?.errors).not.toContain("HttpClientError")
|
||||
expect(output.operations[0]?.errors).not.toContain("SchemaError")
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
"new ClientError({ cause: error })",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string, ClientError> = health
|
||||
const _list: Effect.Effect<ReadonlyArray<string>, ClientError> = list
|
||||
const _filtered: Effect.Effect<ReadonlyArray<string>, ClientError> = filtered
|
||||
const _get: Effect.Effect<string, Missing | ClientError> = get
|
||||
const _interrupt: Effect.Effect<void, ClientError> = interrupt
|
||||
const _status: Effect.Effect<string, ClientError> = status
|
||||
const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe
|
||||
|
||||
return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe }
|
||||
}),
|
||||
)
|
||||
|
||||
const _requiresHttpClient: Effect.Effect<unknown, never, HttpClient.HttpClient> = program
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
@@ -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"] }),
|
||||
})),
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
|
||||
|
||||
const Endpoint0SuccessError = Schema.Never
|
||||
|
||||
export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
|
||||
HttpApiEndpoint.make("GET")("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({
|
||||
data: Endpoint0SuccessData,
|
||||
error: Endpoint0SuccessError,
|
||||
contentType: "text/event-stream",
|
||||
}).pipe(HttpApiSchema.status(202)),
|
||||
}),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof Group1, "event", never, never>
|
||||
|
||||
const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint0 = (raw: RawGroup) => () =>
|
||||
Stream.unwrap(
|
||||
raw["subscribe"]({}).pipe(
|
||||
Effect.mapError(mapEndpoint0Error),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
|
||||
),
|
||||
)
|
||||
|
||||
export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ClientError } from "./client-error"
|
||||
export { make } from "./client"
|
||||
@@ -0,0 +1,96 @@
|
||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Endpoint0Success = Schema.String
|
||||
|
||||
const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
|
||||
|
||||
const Endpoint1Success = Schema.Array(Schema.String)
|
||||
|
||||
const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const Endpoint2Success = Schema.Struct({ data: Schema.String })
|
||||
|
||||
class Endpoint2Error0Class extends Schema.TaggedErrorClass<Endpoint2Error0Class>("Missing")("Missing", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
|
||||
|
||||
const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
|
||||
|
||||
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
|
||||
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
|
||||
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
|
||||
.add(
|
||||
HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
|
||||
params: Endpoint2Params,
|
||||
success: Endpoint2Success,
|
||||
error: Endpoint2Error0,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
|
||||
params: Endpoint3Params,
|
||||
success: Endpoint3Success,
|
||||
}),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof Group0, "session", never, never>
|
||||
|
||||
const Endpoint0DeclaredError = Schema.Never
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
|
||||
|
||||
type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
|
||||
const Endpoint1DeclaredError = Schema.Never
|
||||
const mapEndpoint1Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint1DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
|
||||
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
|
||||
|
||||
type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
|
||||
const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
|
||||
const mapEndpoint2Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint2DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
|
||||
raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapEndpoint2Error),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
|
||||
const Endpoint3DeclaredError = Schema.Never
|
||||
const mapEndpoint3Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint3DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
|
||||
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
|
||||
|
||||
export const adaptGroup0 = (raw: RawGroup) => ({
|
||||
health: Endpoint0(raw),
|
||||
list: Endpoint1(raw),
|
||||
get: Endpoint2(raw),
|
||||
interrupt: Endpoint3(raw),
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Endpoint0Success = Schema.String
|
||||
|
||||
export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client<typeof Group2>
|
||||
|
||||
const Endpoint0DeclaredError = Schema.Never
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
|
||||
|
||||
export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { write, type Output } from "../src"
|
||||
import { it } from "./effect"
|
||||
|
||||
describe("HttpApiCodegen.write", () => {
|
||||
it.effect("writes compiled files beneath the output directory", () => {
|
||||
const writes: Array<{ readonly path: string; readonly content: string }> = []
|
||||
const output: Output = {
|
||||
operations: [],
|
||||
files: [{ path: "session.ts", content: "export const session = {}" }],
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* write(output, "/generated")
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ path: "/generated/session.ts", content: "export const session = {}\n" },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
exists: () => Effect.succeed(false),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: (path, content) => {
|
||||
writes.push({ path, content })
|
||||
return Effect.void
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("removes only stale files owned by the previous manifest", () => {
|
||||
const removed: Array<string> = []
|
||||
return write(
|
||||
{
|
||||
operations: [],
|
||||
files: [{ path: "session.ts", content: "" }],
|
||||
},
|
||||
"/generated",
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")),
|
||||
makeDirectory: () => Effect.void,
|
||||
readFileString: () => Effect.succeed('["old.ts", "session.ts"]'),
|
||||
remove: (path) => {
|
||||
removed.push(path)
|
||||
return Effect.void
|
||||
},
|
||||
writeFileString: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("rejects unsafe and duplicate output paths before writing", () => {
|
||||
const writes: Array<string> = []
|
||||
return Effect.gen(function* () {
|
||||
const error = yield* write(
|
||||
{
|
||||
operations: [],
|
||||
files: [
|
||||
{ path: "../outside.ts", content: "" },
|
||||
{ path: "client.ts", content: "" },
|
||||
{ path: "CLIENT.ts", content: "" },
|
||||
],
|
||||
},
|
||||
"/generated",
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error._tag).toBe("GenerationError")
|
||||
expect(writes).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
writeFileString: (path) => {
|
||||
writes.push(path)
|
||||
return Effect.void
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("rejects case-insensitive duplicate output paths", () => {
|
||||
const writes: Array<string> = []
|
||||
return Effect.gen(function* () {
|
||||
const error = yield* write(
|
||||
{
|
||||
operations: [],
|
||||
files: [
|
||||
{ path: "client.ts", content: "" },
|
||||
{ path: "CLIENT.ts", content: "" },
|
||||
],
|
||||
},
|
||||
"/generated",
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error._tag).toBe("GenerationError")
|
||||
expect(error.reason).toBe("Duplicate output path: CLIENT.ts")
|
||||
expect(writes).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
writeFileString: (path) => {
|
||||
writes.push(path)
|
||||
return Effect.void
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("reserves the private manifest path", () =>
|
||||
write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))),
|
||||
Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects existing symbolic-link output targets", () =>
|
||||
write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))),
|
||||
Effect.provideService(
|
||||
FileSystem.FileSystem,
|
||||
FileSystem.makeNoop({
|
||||
exists: (path) => Effect.succeed(path.endsWith("session.ts")),
|
||||
makeDirectory: () => Effect.void,
|
||||
stat: () =>
|
||||
Effect.succeed({
|
||||
type: "SymbolicLink",
|
||||
mtime: Option.none(),
|
||||
atime: Option.none(),
|
||||
birthtime: Option.none(),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: 0,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user