Skip to content

Commit db9049c

Browse files
authored
Fix generated client transport shape (#137)
* fix generated client transport shape * test(runtime): align Pydantic serialization tests with new transport shape PR #137 unifies request body construction through the new ClientRequest DTO: bodies are JSON-encoded to bytes once and passed to httpx as `content=<bytes>` regardless of whether the input was a Pydantic model or a plain dict. The two json_data tests in test_pydantic_serialization were still asserting the old `json={...}` kwarg shape, so they failed in CI. Update both tests (sync + async) to verify the new contract: - `json` kwarg is never present - `content` round-trips back to the expected dict via json.loads - Content-Type: application/json is set on the request headers * address review feedback: simpler transport DTOs Per @avkonst's review on PR #137, plus a couple of related cleanups: - Drop the `Client` prefix from transport DTO names across all three languages: `Client{Request,Headers,Response}` -> `{Request,Headers,Response}`. The transport interfaces stay `Client` / `AsyncClient` (they are clients); only the data shapes lose the prefix. - Drop the `method` field from `Request` (TS, Rust, Python) and from the runtime _make_request / _make_sse_request signatures and the generated client method bodies. Every reflectapi endpoint is POST by design, so transports hardcode it; carrying "POST" through every call and DTO was dead surface. - TS SSE switch back to native `new TextDecoderStream()`. The custom `__text_decoder_stream` was a TS-variance workaround, not a runtime improvement. A single `as unknown as ReadableWritablePair<string, Uint8Array>` cast bridges the BufferSource/Uint8Array variance mismatch in lib.dom. - TS `__read_response_body` now defers to the platform via `new (globalThis as any).Response(body).text()` instead of the manual reader+decoder loop. One line, well-tested, no chunk-boundary debate. Reviewed and rejected: - The Codex P2 "avoid serializing absent body as null" — false positive. The empty-body path produces `b""` and the `if request.body:` guard at the dispatch site already skips the content kwarg. Carved out as a follow-up: - Andrey's `?? "{}"` fallback question: stop sending a body for endpoints with no input type. Touches the wire protocol on both client and server, so it's its own PR.
1 parent 07cba1a commit db9049c

175 files changed

Lines changed: 764 additions & 638 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

reflectapi-demo/clients/python/generated.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,6 @@ async def check(
514514

515515
params: dict[str, Any] = {}
516516
return await self._client._make_request(
517-
"POST",
518517
path,
519518
params=params if params else None,
520519
response_model=None,
@@ -541,7 +540,6 @@ def cdc_events(
541540

542541
params: dict[str, Any] = {}
543542
return self._client._make_sse_request(
544-
"POST",
545543
path,
546544
params=params if params else None,
547545
headers_model=headers,
@@ -566,7 +564,6 @@ async def create(
566564

567565
params: dict[str, Any] = {}
568566
return await self._client._make_request(
569-
"POST",
570567
path,
571568
params=params if params else None,
572569
json_model=data,
@@ -602,7 +599,6 @@ async def delete(
602599

603600
params: dict[str, Any] = {}
604601
return await self._client._make_request(
605-
"POST",
606602
path,
607603
params=params if params else None,
608604
json_model=data,
@@ -624,7 +620,6 @@ async def get_first(
624620

625621
params: dict[str, Any] = {}
626622
return await self._client._make_request(
627-
"POST",
628623
path,
629624
params=params if params else None,
630625
headers_model=headers,
@@ -651,7 +646,6 @@ async def list(
651646

652647
params: dict[str, Any] = {}
653648
return await self._client._make_request(
654-
"POST",
655649
path,
656650
params=params if params else None,
657651
json_model=data,
@@ -677,7 +671,6 @@ async def remove(
677671

678672
params: dict[str, Any] = {}
679673
return await self._client._make_request(
680-
"POST",
681674
path,
682675
params=params if params else None,
683676
json_model=data,
@@ -703,7 +696,6 @@ async def update(
703696

704697
params: dict[str, Any] = {}
705698
return await self._client._make_request(
706-
"POST",
707699
path,
708700
params=params if params else None,
709701
json_model=data,
@@ -746,7 +738,6 @@ def check(
746738

747739
params: dict[str, Any] = {}
748740
return self._client._make_request(
749-
"POST",
750741
path,
751742
params=params if params else None,
752743
response_model=None,
@@ -773,7 +764,6 @@ def cdc_events(
773764

774765
params: dict[str, Any] = {}
775766
return self._client._make_sse_request(
776-
"POST",
777767
path,
778768
params=params if params else None,
779769
headers_model=headers,
@@ -798,7 +788,6 @@ def create(
798788

799789
params: dict[str, Any] = {}
800790
return self._client._make_request(
801-
"POST",
802791
path,
803792
params=params if params else None,
804793
json_model=data,
@@ -834,7 +823,6 @@ def delete(
834823

835824
params: dict[str, Any] = {}
836825
return self._client._make_request(
837-
"POST",
838826
path,
839827
params=params if params else None,
840828
json_model=data,
@@ -856,7 +844,6 @@ def get_first(
856844

857845
params: dict[str, Any] = {}
858846
return self._client._make_request(
859-
"POST",
860847
path,
861848
params=params if params else None,
862849
headers_model=headers,
@@ -883,7 +870,6 @@ def list(
883870

884871
params: dict[str, Any] = {}
885872
return self._client._make_request(
886-
"POST",
887873
path,
888874
params=params if params else None,
889875
json_model=data,
@@ -909,7 +895,6 @@ def remove(
909895

910896
params: dict[str, Any] = {}
911897
return self._client._make_request(
912-
"POST",
913898
path,
914899
params=params if params else None,
915900
json_model=data,
@@ -935,7 +920,6 @@ def update(
935920

936921
params: dict[str, Any] = {}
937922
return self._client._make_request(
938-
"POST",
939923
path,
940924
params=params if params else None,
941925
json_model=data,

reflectapi-demo/clients/typescript/generated.ts

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,33 @@ export interface RequestOptions {
1212
signal?: AbortSignal;
1313
}
1414

15+
// Transport DTOs. Method is intentionally absent: every reflectapi
16+
// endpoint is POST by design, so transports hardcode it; if that ever
17+
// changes it's a wire-protocol break and clients regenerate.
18+
export interface Request {
19+
path: string;
20+
headers: Record<string, string>;
21+
body: Uint8Array;
22+
signal?: AbortSignal;
23+
}
24+
25+
export interface Headers {
26+
get(name: string): string | null;
27+
}
28+
29+
export interface Response {
30+
status: number;
31+
headers: Headers;
32+
body: ReadableStream<Uint8Array> | null;
33+
}
34+
1535
export interface Client {
16-
request(
17-
path: string,
18-
body: string,
19-
headers: Record<string, string>,
20-
options?: RequestOptions,
21-
): Promise<Response>;
36+
request(request: Request): Promise<Response>;
2237
}
2338

24-
export type NullToEmptyObject<T> = T extends null ? {} : T;
39+
type IsAny<T> = 0 extends 1 & T ? true : false;
40+
export type NullToEmptyObject<T> =
41+
IsAny<T> extends true ? unknown : T extends null ? {} : T;
2542

2643
export type AsyncResult<T, E> = Promise<Result<T, Err<E>>>;
2744

@@ -190,9 +207,14 @@ export function __request<I, H, O, E>(
190207
}
191208
}
192209
return client
193-
.request(path, JSON.stringify(input), hdrs, options)
210+
.request({
211+
path,
212+
headers: hdrs,
213+
body: new TextEncoder().encode(JSON.stringify(input) ?? "{}"),
214+
signal: options?.signal,
215+
})
194216
.then(async (response) => {
195-
const response_body = await response.text();
217+
const response_body = await __read_response_body(response);
196218
if (response.status >= 200 && response.status < 300) {
197219
try {
198220
return new Result<O, Err<E>>({ ok: JSON.parse(response_body) as O });
@@ -245,22 +267,37 @@ export async function __stream_request<I, H, O, E>(
245267
}
246268
}
247269
try {
248-
const response = await client.request(
270+
const response = await client.request({
249271
path,
250-
JSON.stringify(input),
251-
hdrs,
252-
options,
253-
);
272+
headers: hdrs,
273+
body: new TextEncoder().encode(JSON.stringify(input) ?? "{}"),
274+
signal: options?.signal,
275+
});
254276
if (response.status >= 200 && response.status < 300) {
277+
const contentType = response.headers.get("content-type") || "";
278+
if (!contentType.toLowerCase().includes("text/event-stream")) {
279+
return new Result<AsyncIterable<O>, Err<E>>({
280+
err: new Err({
281+
other_err: `expected text/event-stream response, got ${contentType || "missing content-type"}`,
282+
}),
283+
});
284+
}
285+
if (!response.body || typeof response.body.pipeThrough !== "function") {
286+
return new Result<AsyncIterable<O>, Err<E>>({
287+
err: new Err({
288+
other_err: "expected response body to be a WHATWG ReadableStream",
289+
}),
290+
});
291+
}
255292
const stream = __sse_to_async_iterable<O>(response, options);
256293
return new Result<AsyncIterable<O>, Err<E>>({ ok: stream });
257294
} else if (response.status >= 500) {
258-
const body = await response.text();
295+
const body = await __read_response_body(response);
259296
return new Result<AsyncIterable<O>, Err<E>>({
260297
err: new Err({ other_err: `[${response.status}] ${body}` }),
261298
});
262299
} else {
263-
const body = await response.text();
300+
const body = await __read_response_body(response);
264301
try {
265302
return new Result<AsyncIterable<O>, Err<E>>({
266303
err: new Err({ application_err: JSON.parse(body) as E }),
@@ -284,16 +321,31 @@ async function* __sse_to_async_iterable<O>(
284321
): AsyncIterable<O> {
285322
const body = response.body;
286323
if (!body) return;
324+
// Native TextDecoderStream handles cross-chunk multi-byte sequences.
325+
// The cast bridges a TS variance mismatch: TextDecoderStream is typed
326+
// `TransformStream<BufferSource, string>` while pipeThrough demands
327+
// exact `Uint8Array` writable. The runtime behaviour is correct.
287328
const reader = body
288-
.pipeThrough(new TextDecoderStream())
329+
.pipeThrough(
330+
new TextDecoderStream() as unknown as ReadableWritablePair<
331+
string,
332+
Uint8Array
333+
>,
334+
)
289335
.pipeThrough(new __EventSourceParserStream())
290336
.getReader();
291337
try {
292338
while (true) {
293339
if (options?.signal?.aborted) break;
294340
const { done, value } = await reader.read();
295341
if (done) break;
296-
yield JSON.parse(value.data) as O;
342+
try {
343+
yield JSON.parse(value.data) as O;
344+
} catch (e) {
345+
throw new Error(
346+
`SSE parse error: ${e instanceof Error ? e.message : String(e)} (raw: ${value.data})`,
347+
);
348+
}
297349
}
298350
} catch (e) {
299351
if (!options?.signal?.aborted) throw e;
@@ -302,20 +354,22 @@ async function* __sse_to_async_iterable<O>(
302354
}
303355
}
304356

357+
async function __read_response_body(response: Response): Promise<string> {
358+
if (!response.body) return "";
359+
// Hand decoding to the platform; new (global) Response wraps any
360+
// ReadableStream<Uint8Array> and exposes the well-tested .text() path.
361+
return await new (globalThis as any).Response(response.body).text();
362+
}
363+
305364
class ClientInstance {
306365
constructor(private base: string) {}
307366

308-
public request(
309-
path: string,
310-
body: string,
311-
headers: Record<string, string>,
312-
options?: RequestOptions,
313-
): Promise<Response> {
314-
return (globalThis as any).fetch(`${this.base}${path}`, {
367+
public request(request: Request): Promise<Response> {
368+
return (globalThis as any).fetch(`${this.base}${request.path}`, {
315369
method: "POST",
316-
headers: headers,
317-
body: body,
318-
signal: options?.signal,
370+
headers: request.headers,
371+
body: request.body,
372+
signal: request.signal,
319373
});
320374
}
321375
}

reflectapi-demo/clients/typescript/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
"node-fetch": "2"
1515
},
1616
"devDependencies": {
17-
"@types/node": "20.11.30",
17+
"@types/node": "^24.10.1",
1818
"@types/node-fetch": "2",
19-
"typescript": "5.4.3",
19+
"typescript": "^5.9.3",
2020
"tslib": "2.6.2"
2121
}
2222
}

0 commit comments

Comments
 (0)