From a473312c3354ae9543a380eb54fc5828b5297071 Mon Sep 17 00:00:00 2001 From: Eduardo Burgos Date: Sat, 5 Sep 2026 23:56:31 -0400 Subject: [PATCH] decode streamed bodies in the typescript client A streamed operation answers a record of content range and byte stream - undefined at the declared status, the header text at 206, read before the stream is ever named - with header_out composing onto it exactly as bytes composes. The seam response carries a ReadableStream field only where a service declares a stream, the way the request already gates its parts list. The result type stopped naming StreamedAnswer, an identifier no TypeScript surface defines, and renders the same fixed record instead. The readme's coverage paragraph now states the closed gaps truthfully. --- README.md | 4 +- src/features/service_schema/http_client.rs | 206 +++++++++++++----- src/features/service_schema/result.rs | 44 +++- src/features/service_schema/tests.rs | 78 +++++++ .../service_schema/tests/http_client_tests.rs | 99 ++++++++- 5 files changed, 376 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ded699b..f8c21bf 100644 --- a/README.md +++ b/README.md @@ -2201,9 +2201,9 @@ impl http_rest_client::Transport for HttpLoop<'_, H> { A client adapter is the mirror: a small hand-written `Transport` implementation over a real HTTP stack (reqwest in Rust; a service-agnostic `fetch` helper in TypeScript; a service-agnostic `send` implementation in Dart), living with whichever codebase calls the service, doing exactly one job -- carry the plain-terms request across a real connection and hand the plain-terms response back. Timeouts, cancellation, retries, connection handling, mutual TLS and authentication are that adapter's own concerns, never the generated seam's, exactly as `amqp_rpc`'s own adapter split already works: the generator emits the seam, the application implements it against its own libraries, and a workspace that wants to share one adapter across services keeps it in its own crate under its own name -- never a `tixschema`-branded runtime dependency. -**The TypeScript and Dart clients.** `Schema::ts_http_client()` publishes the `http_rest` half beside `ts_client()`'s AMQP-shaped one: a service-agnostic `{Service}HttpTransport` seam (`send(request): Promise`, both the request and the response carrying `method`/`path`/`query`/`headers`/`body` as plain strings, plus `parts` where the service declares a multipart operation), the `{Service}HttpClient` interface, and `create{Service}HttpClient(transport)`. It needs the `zod` feature exactly as `ts_client()` and `ts_service()` do -- outbound validation before a byte goes out is what a `safeParse` against the message's own `$Schema` gives it, and a build without Zod cannot write that check truthfully, so it publishes none of the three rather than one without it. `Schema::dart_http_client()` is the Dart sibling -- the same seam and per-operation client, over the `dart` feature's own generated types and JSON codec rather than Zod, needing no separate outbound check because a Dart message is a real class with `required` constructor parameters and cannot be built malformed in the first place. Where TypeScript answers a reply with an `{ ok, value | error }` union, Dart throws: a reply method answers `Future` directly and throws `{Service}HttpError` (the declared error, or a fault behind `isServiceFault`), and a one-way method answers `Future` and throws the fault-only `{Service}HttpRefusal` -- Dart's own idiom for a `Future`, mirroring exactly how its own one-way AMQP methods already throw. +**The TypeScript and Dart clients.** `Schema::ts_http_client()` publishes the `http_rest` half beside `ts_client()`'s AMQP-shaped one: a service-agnostic `{Service}HttpTransport` seam (`send(request): Promise`, both the request and the response carrying `method`/`path`/`query`/`headers`/`body` as plain strings, plus `parts` on the request where the service declares a multipart operation and `bodyStream: ReadableStream` on the response where the service declares a streamed operation -- the platform's own stream type, never a naming of `fetch`), the `{Service}HttpClient` interface, and `create{Service}HttpClient(transport)`. It needs the `zod` feature exactly as `ts_client()` and `ts_service()` do -- outbound validation before a byte goes out is what a `safeParse` against the message's own `$Schema` gives it, and a build without Zod cannot write that check truthfully, so it publishes none of the three rather than one without it. `Schema::dart_http_client()` is the Dart sibling -- the same seam and per-operation client, over the `dart` feature's own generated types and JSON codec rather than Zod, needing no separate outbound check because a Dart message is a real class with `required` constructor parameters and cannot be built malformed in the first place. Where TypeScript answers a reply with an `{ ok, value | error }` union, Dart throws: a reply method answers `Future` directly and throws `{Service}HttpError` (the declared error, or a fault behind `isServiceFault`), and a one-way method answers `Future` and throws the fault-only `{Service}HttpRefusal` -- Dart's own idiom for a `Future`, mirroring exactly how its own one-way AMQP methods already throw. -Both language backends cover `body = "json"` and `body = "bytes"` in full. Neither covers `body = "stream"`: only the Rust dispatcher and the Rust client understand `StreamedAnswer`, so a streamed operation's generated TypeScript or Dart client method is not part of what this version writes correctly -- keep a streamed operation's callers in Rust, or hand-write that one call against the plain-terms transport seam directly, until a later version teaches these two backends the same seam. `body = "multipart"` is covered on the TypeScript side (the client builds `parts` from the message's own fields and the declared `part` bindings) but not on the Dart side, which always JSON-encodes the whole message as the body regardless of the declared body kind -- a multipart operation reached through `dart_http_client()` is the same open gap as a streamed one. +Both language backends cover `body = "json"`, `body = "bytes"`, `body = "stream"` and `body = "multipart"` in full. A streamed operation's TypeScript client answers `{ contentRange: string | undefined; body: ReadableStream }` -- `contentRange` left `undefined` at the operation's own `ok_status`, read back off the response ahead of naming the body and set to the range text at `206` -- off the seam's own `bodyStream` field; its Dart client answers the same pairing as a `({String? contentRange, Stream> body})` record off `bodyStream` there too. A declared `header_out` composes onto either answer exactly as it does for `json` and `bytes`. A multipart operation's TypeScript client builds `parts` from the message's own fields and the declared `part` bindings; its Dart client builds the same list, the file handles crossing as `dynamic` through the same path an unknown type already renders by. ## Field Validation (`model_schema_prop`) diff --git a/src/features/service_schema/http_client.rs b/src/features/service_schema/http_client.rs index 006a48f..3970a49 100644 --- a/src/features/service_schema/http_client.rs +++ b/src/features/service_schema/http_client.rs @@ -36,6 +36,15 @@ //! fixed fault statuses (400 validation, 404 unmatched route, 500 panic) all become the same //! [`crate::service_schema::support`]-published fault type every other surface answers faults //! through. +//! +//! # `body = "stream"` reads two success statuses, not one +//! +//! `reply_decode_stmt` peels `BodyKind::Stream` off first into its own status ladder (`206` reads +//! `content-range` back before the body is ever named; the declared `ok_status` leaves it +//! `undefined`; either way the answer carries `response.bodyStream` — the seam's own +//! `ReadableStream`, present only where the service declares a streamed operation) — +//! mirroring the Rust and Dart clients' own split. What is left (`success_decode_block`) only ever +//! sees `Bytes`, `Json` and `Multipart`. use super::fault; use super::message; @@ -45,15 +54,16 @@ use crate::rename_rule::RenameRule; use crate::service_schema::parse::{ BodyKind, DEFAULT_BINDING_ERROR_STATUS, HttpShape, OperationDef, OperationInputs, OperationOutcome, PathSegment, ScalarKind, ServiceDef, is_unit_type, option_inner, scalar_kind, - service_declares_multipart, tuple_elements, vec_inner, wire_key, + service_declares_a_stream, service_declares_multipart, tuple_elements, vec_inner, wire_key, }; use core::fmt::Write as _; use syn::Type; pub fn emit(service: &ServiceDef) -> Vec { + let has_stream = service_declares_a_stream(service); let has_multipart = service_declares_multipart(service); let mut published = vec![ - transport_type(&service.ident.to_string(), has_multipart), + transport_type(&service.ident.to_string(), has_stream, has_multipart), client_type(service), ]; published.extend(fault_helpers(service)); @@ -68,13 +78,22 @@ pub fn emit(service: &ServiceDef) -> Vec { /// The one seam type a `{service}` client sends through: a request record in, a response record /// out, nothing service-specific in either and nothing here naming the library that finally /// carries the call. The request record carries `parts` only where the service declares a -/// multipart operation - every other body kind still carries its content as `body`. -fn transport_type(service: &str, has_multipart: bool) -> String { +/// multipart operation - every other body kind still carries its content as `body`. The response +/// record carries `bodyStream` only where the service declares a streamed operation - a real +/// implementation can fill it a chunk at a time rather than buffering the whole answer first, while +/// `body` keeps answering eagerly for every other operation. `ReadableStream` is the platform's own +/// type, not a naming of `fetch`: the seam stays library-agnostic either way. +fn transport_type(service: &str, has_stream: bool, has_multipart: bool) -> String { let parts_field = if has_multipart { "\n parts: ReadonlyArray;" } else { "" }; + let stream_field = if has_stream { + "\n bodyStream: ReadableStream;" + } else { + "" + }; format!( "/**\n \ * What binds a `{service}` client to a real HTTP stack.\n \ @@ -95,7 +114,7 @@ fn transport_type(service: &str, has_multipart: bool) -> String { }}): Promise<{{\n \ status: number;\n \ headers: ReadonlyArray;\n \ - body: string;\n \ + body: string;{stream_field}\n \ }}>;\n\ }};" ) @@ -535,6 +554,9 @@ fn reply_decode_stmt( error: &Type, success: &Type, ) -> String { + if matches!(shape.body_kind, BodyKind::Stream) { + return stream_reply_decode_stmt(prefix, shape, wire, error, success); + } let ok_status = shape.ok_status; let error_condition = error_condition_expr(shape); let error_ty = get_field_def("error", error, "").typescript_typename(); @@ -584,7 +606,9 @@ fn reply_decode_stmt( /// The statements that read a success answer off the response, once its status has already /// matched: nothing at all for a unit reply, the body alone for an ordinary reply, the response /// bytes and their content type for a `body = "bytes"` operation, or the body plus every -/// `header_out` element read back from the response's own headers. +/// `header_out` element read back from the response's own headers. `body = "stream"` never reaches +/// here — [`reply_decode_stmt`] answers it through [`stream_reply_decode_stmt`] instead, since a +/// streamed answer has two success statuses (`200` and `206`), not one. fn success_decode_block(prefix: &str, wire: &str, shape: &HttpShape, success: &Type) -> String { if matches!(shape.body_kind, BodyKind::Bytes) { return bytes_success_decode_block(prefix, wire, shape, success); @@ -629,41 +653,8 @@ fn success_decode_block(prefix: &str, wire: &str, shape: &HttpShape, success: &T }};\n \ }}\n" ); - let mut header_idents = Vec::new(); - for (index, (name, element_ty)) in shape - .header_out - .iter() - .zip(elements.iter().skip(1)) - .enumerate() - { - let raw_ident = format!("rawHeaderOut{index}"); - let ident = format!("headerOut{index}"); - let element_typename = get_field_def("value", element_ty, "").typescript_typename(); - let _ = write!( - stmt, - " const {raw_ident} = response.headers.find(\n \ - ([name]) => name.toLowerCase() === \"{name}\",\n \ - );\n \ - if ({raw_ident} === undefined) {{\n \ - return {{\n \ - ok: false,\n \ - error: {{\n \ - isServiceFault: true,\n \ - fault: {prefix}HttpUndeserializablePayload(\n \ - \"{wire}\",\n \ - \"a declared response header was missing\",\n \ - ),\n \ - }},\n \ - }};\n \ - }}\n" - ); - let value_expr = header_out_value_expr(element_ty, &format!("{raw_ident}[1]")); - let _ = writeln!( - stmt, - " const {ident} = {value_expr} as {element_typename};" - ); - header_idents.push(ident); - } + let (header_stmts, header_idents) = header_out_read_stmts(prefix, wire, shape, &elements, 1); + stmt.push_str(&header_stmts); let _ = writeln!( stmt, " return {{ ok: true, value: [value, {}] }};", @@ -694,11 +685,131 @@ fn bytes_success_decode_block( return stmt; } let elements: Vec<&Type> = tuple_elements(success).into_iter().flatten().collect(); - let mut header_idents = Vec::new(); + let (header_stmts, header_idents) = header_out_read_stmts(prefix, wire, shape, &elements, 2); + stmt.push_str(&header_stmts); + let _ = writeln!( + stmt, + " return {{ ok: true, value: [response.body, contentType, {}] }};", + header_idents.join(", ") + ); + stmt +} + +/// A `body = "stream"` operation's own decode: `206` answers the streamed record with its own +/// `contentRange` read back off the response ahead of the body; the declared `ok_status` answers +/// the same record with `contentRange` left `undefined`; everything else falls through the same +/// declared-error, fixed-fault and unexpected-status ladder every other kind answers through — +/// mirrors the Rust and Dart clients' own stream decode. +fn stream_reply_decode_stmt( + prefix: &str, + shape: &HttpShape, + wire: &str, + error: &Type, + success: &Type, +) -> String { + let ok_status = shape.ok_status; + let error_condition = error_condition_expr(shape); + let error_ty = get_field_def("error", error, "").typescript_typename(); + let partial = stream_success_arm(prefix, wire, shape, success, true); + let full = stream_success_arm(prefix, wire, shape, success, false); + format!( + " const status = response.status;\n \ + if (status === 206) {{\n\ +{partial} \ + }}\n \ + if (status === {ok_status}) {{\n\ +{full} \ + }}\n \ + if ({error_condition}) {{\n \ + let declared: {error_ty};\n \ + try {{\n \ + declared = JSON.parse(response.body) as {error_ty};\n \ + }} catch (rejected) {{\n \ + return {{\n \ + ok: false,\n \ + error: {{\n \ + isServiceFault: true,\n \ + fault: {prefix}HttpUndeserializablePayload(\"{wire}\", String(rejected)),\n \ + }},\n \ + }};\n \ + }}\n \ + return {{ ok: false, error: declared }};\n \ + }}\n \ + if (status === 400 || status === 404 || status === 500) {{\n \ + return {{\n \ + ok: false,\n \ + error: {{\n \ + isServiceFault: true,\n \ + fault: {prefix}HttpFaultFromBody(\"{wire}\", response.body),\n \ + }},\n \ + }};\n \ + }}\n \ + return {{\n \ + ok: false,\n \ + error: {{\n \ + isServiceFault: true,\n \ + fault: {prefix}HttpUndeserializablePayload(\n \ + \"{wire}\",\n \ + `an unexpected status (${{status}}) answered`,\n \ + ),\n \ + }},\n \ + }};\n" + ) +} + +/// One status arm of [`stream_reply_decode_stmt`]: `contentRange` read back off the response for a +/// `206` partial answer, left `undefined` for the declared `ok_status`'s whole-body answer — both +/// paired with `response.bodyStream`, the seam's own lazily-pulled source — then every declared +/// `header_out` element read back exactly as the bytes and JSON paths do. +fn stream_success_arm( + prefix: &str, + wire: &str, + shape: &HttpShape, + success: &Type, + partial: bool, +) -> String { + let mut stmt = if partial { + " const contentRange =\n \ + response.headers.find(\n \ + ([name]) => name.toLowerCase() === \"content-range\",\n \ + )?.[1] ?? \"\";\n" + .to_owned() + } else { + " const contentRange: string | undefined = undefined;\n".to_owned() + }; + stmt.push_str(" const answer = { contentRange, body: response.bodyStream };\n"); + if shape.header_out.is_empty() { + stmt.push_str(" return { ok: true, value: answer };\n"); + return stmt; + } + let elements: Vec<&Type> = tuple_elements(success).into_iter().flatten().collect(); + let (header_stmts, header_idents) = header_out_read_stmts(prefix, wire, shape, &elements, 1); + stmt.push_str(&header_stmts); + let _ = writeln!( + stmt, + " return {{ ok: true, value: [answer, {}] }};", + header_idents.join(", ") + ); + stmt +} + +/// Reads every declared `header_out` element back off the response headers, in declaration order, +/// skipping `body_elements` positions in `elements` to reach the first header slot — 1 for the +/// ordinary JSON and streamed shapes (the value or answer alone), 2 for `body = "bytes"` (the bytes +/// and their content type). Shared by every success-decoding arm so the copies cannot drift. +fn header_out_read_stmts( + prefix: &str, + wire: &str, + shape: &HttpShape, + elements: &[&Type], + body_elements: usize, +) -> (String, Vec) { + let mut stmt = String::new(); + let mut idents = Vec::new(); for (index, (name, element_ty)) in shape .header_out .iter() - .zip(elements.iter().skip(2)) + .zip(elements.iter().skip(body_elements)) .enumerate() { let raw_ident = format!("rawHeaderOut{index}"); @@ -727,14 +838,9 @@ fn bytes_success_decode_block( stmt, " const {ident} = {value_expr} as {element_typename};" ); - header_idents.push(ident); + idents.push(ident); } - let _ = writeln!( - stmt, - " return {{ ok: true, value: [response.body, contentType, {}] }};", - header_idents.join(", ") - ); - stmt + (stmt, idents) } /// The expression that reads a `header_out` element's declared type back off `raw` — a `string` diff --git a/src/features/service_schema/result.rs b/src/features/service_schema/result.rs index 6f2a1a2..4a8ccdc 100644 --- a/src/features/service_schema/result.rs +++ b/src/features/service_schema/result.rs @@ -17,10 +17,25 @@ //! Both names carry the service: `UsageServiceGetBalanceResult`, and the fault it can hold is //! `UsageServiceFault`. Two services declaring a `get_balance` each would otherwise publish one //! `GetBalanceResult` twice into the one flat file a bundle is. +//! +//! A `body = "stream"` operation's own success is declared in Rust as `StreamedAnswer`, a type with +//! no `#[model_schema()]` of its own — the ordinary `value` rendering would publish that bare name +//! as a TypeScript reference nothing declares. [`stream_success_ts_type`] stands in for it instead, +//! mirroring the Rust and Dart clients' own streamed record. use crate::field_type::get_field_def; use crate::rename_rule::RenameRule; -use crate::service_schema::parse::{OperationDef, OperationOutcome, ServiceDef}; +use crate::service_schema::parse::{ + BodyKind, HttpShape, OperationDef, OperationOutcome, ServiceDef, tuple_elements, +}; +use syn::Type; + +/// The TypeScript record a `body = "stream"` operation's own success answers with: a `contentRange` +/// left `undefined` at the operation's own `ok_status`, set to the range text at `206`, paired with +/// the body as the platform's own `ReadableStream` — mirrors the Rust client's own +/// `StreamedAnswer::Full`/`Partial` and the Dart client's own streamed record. +const STREAMED_ANSWER_TS_TYPE: &str = + "{ contentRange: string | undefined; body: ReadableStream }"; pub fn emit(service: &ServiceDef) -> Vec { let named = service.ident.to_string(); @@ -48,7 +63,12 @@ fn result_type(service: &str, operation: &OperationDef) -> Option { return None; }; let published = result_name(service, operation)?; - let value = get_field_def("value", success, "").typescript_typename(); + let shape = HttpShape::of(operation); + let value = if matches!(shape.body_kind, BodyKind::Stream) { + stream_success_ts_type(&shape, success) + } else { + get_field_def("value", success, "").typescript_typename() + }; let failure = get_field_def("error", error, "").typescript_typename(); let called = &operation.ts_name; Some(format!( @@ -62,3 +82,23 @@ fn result_type(service: &str, operation: &OperationDef) -> Option { | {{ ok: false; error: {failure} | {{ isServiceFault: true; fault: {service}Fault }} }};" )) } + +/// [`STREAMED_ANSWER_TS_TYPE`], wrapped in a tuple with one more element per declared `header_out` +/// entry — mirrors the JSON and bytes paths' own composition, and the Dart client's own +/// `stream_success_dart_type`. `success` is read only for the header types after the first slot; +/// the first slot is always the fixed streamed record; `StreamedAnswer` carries no +/// `#[model_schema()]` to resolve a TypeScript type from. +fn stream_success_ts_type(shape: &HttpShape, success: &Type) -> String { + if shape.header_out.is_empty() { + return STREAMED_ANSWER_TS_TYPE.to_owned(); + } + let elements: Vec<&Type> = tuple_elements(success).into_iter().flatten().collect(); + let mut parts = vec![STREAMED_ANSWER_TS_TYPE.to_owned()]; + parts.extend( + elements + .iter() + .skip(1) + .map(|ty| get_field_def("value", ty, "").typescript_typename()), + ); + format!("[{}]", parts.join(", ")) +} diff --git a/src/features/service_schema/tests.rs b/src/features/service_schema/tests.rs index f20141f..dad7afe 100644 --- a/src/features/service_schema/tests.rs +++ b/src/features/service_schema/tests.rs @@ -185,6 +185,39 @@ const BYTES_HTTP_SERVICE: &str = " } "; +/// A service declaring two `body = \"stream\"` operations: one answering the bare streamed answer, +/// one composing a declared `header_out` onto it. Zod-gated mirror of `DART_STREAM_HTTP_SERVICE`, +/// since a build can carry `zod` without `dart`. +#[cfg(feature = "zod")] +const STREAM_HTTP_SERVICE: &str = " + pub trait ContentClientService { + #[service_schema_op(http( + method = \"GET\", + path = \"/files/{file_id}\", + body = \"stream\", + error_status(NotFound = 404), + ))] + async fn get_file( + &self, + ctx: &Ctx, + file_id: String, + ) -> Result; + + #[service_schema_op(http( + method = \"GET\", + path = \"/files/{file_id}/tagged\", + body = \"stream\", + header_out(\"x-checksum\"), + error_status(NotFound = 404), + ))] + async fn get_tagged_file( + &self, + ctx: &Ctx, + file_id: String, + ) -> Result<(StreamedAnswer, String), ContentError>; + } +"; + /// A service declaring one `body = \"multipart\"` operation: a path placeholder, two scalar /// `Generated` fields (one required, one optional) and a `part` binding for the file itself. #[cfg(feature = "zod")] @@ -440,6 +473,51 @@ fn the_result_joins_the_two_declared_arms_and_adds_nothing_to_either() { ); } +/// `StreamedAnswer` carries no `#[model_schema()]` of its own, so a bare `value: StreamedAnswer` +/// would publish a TypeScript reference nothing declares. The result type stands in the fixed +/// streamed record instead. +#[cfg(feature = "zod")] +#[test] +fn the_result_answers_the_streamed_record_rather_than_the_undescribable_rust_type() { + let published = result::emit(&parsed(STREAM_HTTP_SERVICE)); + let found = published + .iter() + .find(|ts| ts.contains("export type ContentClientServiceGetFileResult =")); + assert!(found.is_some(), "got: {published:?}"); + let result = found.unwrap(); + assert!( + result.contains( + "| { ok: true; value: { contentRange: string | undefined; body: \ + ReadableStream } }" + ), + "got: {result}" + ); + assert!( + !result.contains("StreamedAnswer"), + "the Rust-only seam type never leaks into the published TypeScript. Got: {result}" + ); +} + +/// A declared `header_out` wraps the streamed record in a tuple, exactly as the JSON and bytes +/// paths compose theirs. +#[cfg(feature = "zod")] +#[test] +fn the_result_composes_header_out_onto_the_streamed_record_in_a_tuple() { + let published = result::emit(&parsed(STREAM_HTTP_SERVICE)); + let found = published + .iter() + .find(|ts| ts.contains("export type ContentClientServiceGetTaggedFileResult =")); + assert!(found.is_some(), "got: {published:?}"); + let result = found.unwrap(); + assert!( + result.contains( + "| { ok: true; value: [{ contentRange: string | undefined; body: \ + ReadableStream }, string] }" + ), + "got: {result}" + ); +} + #[test] fn the_result_takes_its_name_from_the_service_and_the_operation() { let published = result::emit(&parsed(MIXED_SERVICE)); diff --git a/src/features/service_schema/tests/http_client_tests.rs b/src/features/service_schema/tests/http_client_tests.rs index 4981bc3..c91b56e 100644 --- a/src/features/service_schema/tests/http_client_tests.rs +++ b/src/features/service_schema/tests/http_client_tests.rs @@ -5,7 +5,10 @@ //! success, a declared error or a fault. No TypeScript toolchain is reachable here, so none of them //! type-checks the bundle. -use super::{BYTES_HTTP_SERVICE, MIXED_HTTP_SERVICE, MULTIPART_HTTP_SERVICE, http_client_of}; +use super::{ + BYTES_HTTP_SERVICE, MIXED_HTTP_SERVICE, MULTIPART_HTTP_SERVICE, STREAM_HTTP_SERVICE, + http_client_of, +}; #[test] fn exactly_one_seam_type_is_emitted_and_it_names_no_http_library() { @@ -366,6 +369,100 @@ fn a_multipart_method_builds_one_text_part_per_field_and_one_file_part_per_bindi ); } +#[test] +fn a_stream_operation_answers_a_content_range_and_body_record_at_200_and_206() { + let written = http_client_of(STREAM_HTTP_SERVICE); + let method = method_body(&written, "getFile"); + assert!( + method.contains("if (status === 206) {") + && method.contains( + "const contentRange =\n \ + response.headers.find(\n \ + ([name]) => name.toLowerCase() === \"content-range\",\n \ + )?.[1] ?? \"\";" + ) + && method.contains("const answer = { contentRange, body: response.bodyStream };") + && method.contains("return { ok: true, value: answer };"), + "a `206` answers the record with `contentRange` read back off the response. Got: {method}" + ); + assert!( + method.contains("if (status === 200) {") + && method.contains("const contentRange: string | undefined = undefined;"), + "the declared `ok_status` answers the same record with `contentRange` left `undefined`. \ + Got: {method}" + ); +} + +#[test] +fn a_206_answer_reads_content_range_ahead_of_naming_the_body() { + let written = http_client_of(STREAM_HTTP_SERVICE); + let method = method_body(&written, "getFile"); + let read_at = method.find("name.toLowerCase() === \"content-range\""); + let consumed_at = method.find("response.bodyStream"); + assert!(read_at.is_some() && consumed_at.is_some(), "got: {method}"); + assert!( + read_at.unwrap() < consumed_at.unwrap(), + "`content-range` is read back off the response before the stream body is ever named, \ + mirroring the Rust client's own `into_body` (which consumes the response and so must run \ + last). Got: {method}" + ); +} + +#[test] +fn a_stream_operation_with_header_out_composes_the_answer_and_the_header() { + let written = http_client_of(STREAM_HTTP_SERVICE); + let method = method_body(&written, "getTaggedFile"); + assert!( + method.contains( + "const rawHeaderOut0 = response.headers.find(\n \ + ([name]) => name.toLowerCase() === \"x-checksum\",\n \ + );" + ), + "the declared header is read back the same way the JSON and bytes paths read one. \ + Got: {method}" + ); + assert!( + method.contains("const headerOut0 = rawHeaderOut0[1] as string;"), + "got: {method}" + ); + assert_eq!( + method + .matches("return { ok: true, value: [answer, headerOut0] };") + .count(), + 2, + "the header composes onto the answer in both the `206` and `200` arms. Got: {method}" + ); +} + +#[test] +fn the_seam_carries_a_body_stream_field_only_where_a_service_declares_one() { + let plain = http_client_of(MIXED_HTTP_SERVICE); + assert!( + !plain.contains("bodyStream"), + "a service with no streamed operation carries no `bodyStream` field. Got: {plain}" + ); + let streamed = http_client_of(STREAM_HTTP_SERVICE); + assert!( + streamed.contains( + "}): Promise<{\n \ + status: number;\n \ + headers: ReadonlyArray;\n \ + body: string;\n \ + bodyStream: ReadableStream;\n \ + }>;" + ), + "a service with a streamed operation carries `bodyStream` on the seam's own response \ + type. Got: {streamed}" + ); + for named in ["fetch", "axios", "Fetch", "Axios"] { + assert!( + !streamed.contains(named), + "the seam still names no HTTP library once it carries a stream - `ReadableStream` is \ + the platform's own type. Got: {streamed}" + ); + } +} + /// One method's body, read out of the factory's own object literal by the call it is declared /// under. fn method_body(written: &str, call: &str) -> String {