feat(client): support opaque payload schemas (#37773)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
parent
fe9a936867
commit
bef6cfbffe
6 changed files with 255 additions and 32 deletions
|
|
@ -27,6 +27,18 @@ export const Api = HttpApi.make("fixture")
|
|||
params: { sessionID: Schema.String },
|
||||
success: HttpApiSchema.NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("configure", "/session/:sessionID/configure", {
|
||||
params: { sessionID: Schema.String },
|
||||
query: { dryRun: Schema.optional(Schema.Boolean) },
|
||||
headers: { traceID: Schema.String },
|
||||
payload: Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
|
||||
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
|
||||
]),
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
|
|
|
|||
|
|
@ -773,6 +773,42 @@ describe("HttpApiCodegen.generate", () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("serializes an opaque union payload as the direct JSON body", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.post("configure", "/session", {
|
||||
payload: Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
|
||||
Schema.Struct({ type: Schema.Literal("remote"), url: 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()}`)
|
||||
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 new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
|
||||
|
||||
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes explicit null query values", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
|
|
@ -971,6 +1007,74 @@ describe("HttpApiCodegen.generate", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("uses one opaque field for non-struct payloads across emitters", () => {
|
||||
const source = api(
|
||||
HttpApiEndpoint.post("configure", "/session/configure", {
|
||||
payload: Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
|
||||
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
|
||||
]),
|
||||
success: Schema.String,
|
||||
}),
|
||||
)
|
||||
const contract = compileContract(source)
|
||||
const effect = emitEffect(contract)
|
||||
const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
|
||||
const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" })
|
||||
const promise = emitPromise(contract)
|
||||
|
||||
expect(effect.operations[0]).toMatchObject({
|
||||
input: [{ name: "payload", source: "payload" }],
|
||||
inputMode: "required",
|
||||
})
|
||||
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain('payload: input["payload"]')
|
||||
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain('payload: input["payload"]')
|
||||
expect(shape.files[0]?.content).toContain('Endpoint0_0Request["payload"]')
|
||||
expect(promise.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'readonly "payload": { readonly "type": "local", readonly "command": ReadonlyArray<string> } | { readonly "type": "remote", readonly "url": string }',
|
||||
)
|
||||
expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain('body: input["payload"]')
|
||||
})
|
||||
|
||||
test("routes arrays, primitives, and index-signature records through the opaque payload path", () => {
|
||||
for (const payload of [Schema.Array(Schema.String), Schema.String, Schema.Record(Schema.String, Schema.Number)]) {
|
||||
expect(
|
||||
compileContract(api(HttpApiEndpoint.post("set", "/session", { payload, success: HttpApiSchema.NoContent })))
|
||||
.groups[0]?.endpoints[0]?.operation.input,
|
||||
).toEqual([{ name: "payload", source: "payload" }])
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects an opaque payload field that collides with another input channel", () => {
|
||||
expect(() =>
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.post("configure", "/session", {
|
||||
query: { payload: Schema.String },
|
||||
payload: Schema.Union([Schema.String, Schema.Number]),
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toThrow("Opaque payload field collision: session.configure.payload conflicts with query.payload")
|
||||
})
|
||||
|
||||
test("preserves required empty struct payloads in imported Effect adapters", () => {
|
||||
const contract = compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.post("empty", "/session", {
|
||||
payload: Schema.Struct({}),
|
||||
success: Schema.String,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const effect = emitEffectImported(contract, { module: "@example/api", api: "Api" })
|
||||
const promise = emitPromise(contract)
|
||||
|
||||
expect(effect.files.find((file) => file.path === "client.ts")?.content).toContain("payload: { }")
|
||||
expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain("body: { }")
|
||||
})
|
||||
|
||||
test("uses no argument when an operation has no input fields", () => {
|
||||
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export const program = OpenCode.make().pipe(
|
|||
const filtered = client.session.list({ archived: true })
|
||||
const get = client.session.get({ sessionID: "session" })
|
||||
const interrupt = client.session.interrupt({ sessionID: "session" })
|
||||
const configure = client.session.configure({
|
||||
sessionID: "session",
|
||||
dryRun: true,
|
||||
traceID: "trace",
|
||||
payload: { type: "local", command: ["opencode"] },
|
||||
})
|
||||
const status = client.status()
|
||||
const subscribe = client.event.subscribe()
|
||||
|
||||
|
|
@ -18,10 +24,11 @@ export const program = OpenCode.make().pipe(
|
|||
const _filtered: Effect.Effect<ReadonlyArray<string>, ClientError> = filtered
|
||||
const _get: Effect.Effect<string, Missing | ClientError> = get
|
||||
const _interrupt: Effect.Effect<void, ClientError> = interrupt
|
||||
const _configure: Effect.Effect<string, ClientError> = configure
|
||||
const _status: Effect.Effect<string, ClientError> = status
|
||||
const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe
|
||||
|
||||
return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe }
|
||||
return { _health, _list, _filtered, _get, _interrupt, _configure, _status, _subscribe }
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,19 @@ const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
|
|||
|
||||
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
|
||||
|
||||
const Endpoint4Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const Endpoint4Query = Schema.Struct({ dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
|
||||
|
||||
const Endpoint4Headers = Schema.Struct({ traceID: Schema.String })
|
||||
|
||||
const Endpoint4Payload0 = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
|
||||
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
|
||||
])
|
||||
|
||||
const Endpoint4Success = Schema.String
|
||||
|
||||
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 }))
|
||||
|
|
@ -40,6 +53,15 @@ export const Group0 = HttpApiGroup.make("session", { topLevel: false })
|
|||
success: Endpoint3Success,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.make("POST")("configure", "/session/:sessionID/configure", {
|
||||
params: Endpoint4Params,
|
||||
query: Endpoint4Query,
|
||||
headers: Endpoint4Headers,
|
||||
payload: Endpoint4Payload0,
|
||||
success: Endpoint4Success,
|
||||
}),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof Group0, never, never>
|
||||
|
||||
|
|
@ -88,9 +110,32 @@ const mapEndpoint3Error = (error: unknown) =>
|
|||
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
|
||||
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
|
||||
|
||||
type Endpoint4Request = Parameters<RawGroup["configure"]>[0]
|
||||
type Endpoint4Input = {
|
||||
readonly sessionID: (typeof Endpoint4Params.Type)["sessionID"]
|
||||
readonly dryRun?: (typeof Endpoint4Query.Type)["dryRun"]
|
||||
readonly traceID: (typeof Endpoint4Headers.Type)["traceID"]
|
||||
readonly payload: typeof Endpoint4Payload0.Type
|
||||
}
|
||||
const Endpoint4DeclaredError = Schema.Never
|
||||
const mapEndpoint4Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(Endpoint4DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const Endpoint4 = (raw: RawGroup) => (input: Endpoint4Input) =>
|
||||
raw["configure"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { dryRun: input["dryRun"] },
|
||||
headers: { traceID: input["traceID"] },
|
||||
payload: input["payload"],
|
||||
} as Endpoint4Request).pipe(Effect.mapError(mapEndpoint4Error))
|
||||
|
||||
export const adaptGroup0 = (raw: RawGroup) => ({
|
||||
health: Endpoint0(raw),
|
||||
list: Endpoint1(raw),
|
||||
get: Endpoint2(raw),
|
||||
interrupt: Endpoint3(raw),
|
||||
configure: Endpoint4(raw),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue