diff --git a/src/features/service_schema/dart_http_client.rs b/src/features/service_schema/dart_http_client.rs index f8d290d..5ef907a 100644 --- a/src/features/service_schema/dart_http_client.rs +++ b/src/features/service_schema/dart_http_client.rs @@ -36,34 +36,58 @@ //! the equivalent malformed value cannot be constructed in the first place — there is no separate //! check to run. //! -//! # `BodyKind` is matched exhaustively on purpose +//! # `BodyKind` decisions live in one place per surface //! -//! `BodyKind` carries `Json` and `Bytes` today; the streamed kind has no grammar yet -//! ([`crate::service_schema::parse`] — `BodyKind`'s own doc comment), so no operation can reach -//! this module carrying one. Every match on `BodyKind` here is exhaustive rather than defaulted, so -//! the moment a third variant lands, the compiler — not a silently wrong client — is what stops -//! this module until it is taught the new kind. +//! `BodyKind` now carries `Json`, `Bytes`, `Stream` and `Multipart` +//! ([`crate::service_schema::parse`] — `BodyKind`'s own doc comment). `return_type` and +//! `body_build_stmt` match it exhaustively, so a fifth variant is a compiler error there rather +//! than a silently wrong client. `reply_decode_stmt` peels `Stream` off first into its own status +//! ladder (`200` and `206` both answer, everything else refuses), and what is left +//! (`success_decode_block`) only ever sees `Bytes`, `Json` and `Multipart` — `Json` and `Multipart` +//! answering identically, since a multipart operation's own response is ordinary JSON, `header_out` +//! included. +//! +//! # A streamed answer and a multipart request each add one field to the shared seam +//! +//! `body = "stream"` answers a Dart record pairing a nullable `contentRange` with the body as a +//! lazily-pulled `Stream>` — `dart:async`'s own core type, not an HTTP package's, read +//! back off one more field the seam's *response* record carries only where a service declares a +//! streamed operation. `body = "multipart"` builds its request from one more field the seam's +//! *request* record carries only where a service declares one — a `parts` list of name/value pairs, +//! exactly mirroring the TypeScript client's own `parts` field. use crate::features::dart::dart_typename; use crate::field_type::{FieldDefType, get_field_def}; use crate::rename_rule::RenameRule; use crate::service_schema::parse::{ BodyKind, DEFAULT_BINDING_ERROR_STATUS, HttpShape, OperationDef, OperationInputs, - OperationOutcome, PathSegment, ServiceDef, is_unit_type, option_inner, tuple_elements, - vec_inner, wire_key, + OperationOutcome, PathSegment, ServiceDef, is_unit_type, option_inner, + service_declares_a_stream, service_declares_multipart, tuple_elements, vec_inner, wire_key, }; use crate::service_schema::support::fault_fields_typescript_name; use core::fmt::Write as _; use syn::Type; +/// The Dart record a `body = "stream"` operation's own success answers with: a nullable +/// `contentRange` paired with the body as a lazily-pulled `Stream>` — `null` at the +/// operation's own `ok_status`, the range text at `206`. Folds the two into one nullable field +/// rather than a tagged variant, the one shape a Dart record can carry, mirroring the Rust client's +/// own `StreamedAnswer::Full`/`Partial`. +const STREAMED_ANSWER_DART_TYPE: &str = "({String? contentRange, Stream> body})"; + pub fn emit(service: &ServiceDef) -> Vec { let named = service.ident.to_string(); let fn_prefix = RenameRule::CamelCase.apply_to_variant(&named); - let mut published = vec![transport_seam(&named), error_class(&named)]; + let has_stream = service_declares_a_stream(service); + let has_multipart = service_declares_multipart(service); + let mut published = vec![ + transport_seam(&named, has_stream, has_multipart), + error_class(&named), + ]; if has_one_way(service) { published.push(refusal_class(&named)); } - published.push(client_class(service)); + published.push(client_class(service, has_stream, has_multipart)); published.extend(fault_helpers(&named, &fn_prefix)); published } @@ -79,7 +103,38 @@ fn has_one_way(service: &ServiceDef) -> bool { // The seam: an abstract, per-service interface over one structural request/response record pair. // --------------------------------------------------------------------------------------------- -fn transport_seam(named: &str) -> String { +/// The response record `send` answers with: `status`, `headers` and `body` always; a genuinely +/// lazy `Stream>` too, for a service that declares `body = "stream"` — 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 (and may answer empty for the +/// streamed one, exactly as the Rust client's own `IncomingResponse::body` does once its answer +/// rode `bodyStream` instead). +fn response_record_fields(has_stream: bool) -> String { + let stream_field = if has_stream { + ", Stream> bodyStream" + } else { + "" + }; + format!("{{int status, List<(String, String)> headers, List body{stream_field}}}") +} + +/// The request record `send` takes: `method`, `path`, `query`, `headers` and `body` always; +/// `parts` too, for a service that declares `body = "multipart"` — one name/value pair per part, a +/// scalar field's own text or a file part's own undecoded argument, passed through untouched. +fn request_record_fields(has_multipart: bool) -> String { + let parts_field = if has_multipart { + ", List<(String, dynamic)> parts" + } else { + "" + }; + format!( + "{{String method, String path, String query, List<(String, String)> headers, List body{parts_field}}}" + ) +} + +fn transport_seam(named: &str, has_stream: bool, has_multipart: bool) -> String { + let response = response_record_fields(has_stream); + let request = request_record_fields(has_multipart); format!( "/// What binds a `{named}` Dart client to a real HTTP stack.\n\ ///\n\ @@ -87,8 +142,8 @@ fn transport_seam(named: &str) -> String { /// `send` reads the exact same anonymous shape, so one hand-written implementation over\n\ /// any HTTP stack satisfies every service's interface.\n\ abstract class {named}HttpTransport {{\n \ - Future<({{int status, List<(String, String)> headers, List body}})> send(\n \ - ({{String method, String path, String query, List<(String, String)> headers, List body}}) request,\n \ + Future<({response})> send(\n \ + ({request}) request,\n \ );\n\ }}" ) @@ -141,13 +196,13 @@ fn refusal_class(named: &str) -> String { // The client: one class, one constructor, one method per operation. // --------------------------------------------------------------------------------------------- -fn client_class(service: &ServiceDef) -> String { +fn client_class(service: &ServiceDef, has_stream: bool, has_multipart: bool) -> String { let named = service.ident.to_string(); let fn_prefix = RenameRule::CamelCase.apply_to_variant(&named); let methods = service .operations .iter() - .map(|operation| method(&named, &fn_prefix, operation)) + .map(|operation| method(&named, &fn_prefix, operation, has_stream, has_multipart)) .collect::>() .join("\n\n"); format!( @@ -161,13 +216,16 @@ fn client_class(service: &ServiceDef) -> String { } /// The parameter list a method takes: the message first, then one argument per `header_in` -/// binding, in declaration order — the raw Rust identifier, spelled exactly as the rest of this -/// crate's Dart output spells a field, never re-cased. +/// binding, then one per `part` binding, in declaration order — the raw Rust identifier, spelled +/// exactly as the rest of this crate's Dart output spells a field, never re-cased. fn method_params(operation: &OperationDef, shape: &HttpShape) -> String { let mut params = vec![format!("{} req", message_dart_typename(operation))]; for header in &shape.header_in { params.push(format!("{} {}", dart_type_of(&header.ty), header.parameter)); } + for part in &shape.multipart_parts { + params.push(format!("{} {}", dart_type_of(&part.ty), part.parameter)); + } params.join(", ") } @@ -180,22 +238,43 @@ fn method_doc(operation: &OperationDef, shape: &HttpShape) -> String { ) } +/// [`STREAMED_ANSWER_DART_TYPE`], wrapped in a tuple with one more element per declared +/// `header_out` entry — mirrors the bytes and JSON paths' own composition, shifted since the +/// streamed answer itself (not a decoded body) rides in the first slot. +fn stream_success_dart_type(shape: &HttpShape, success: &Type) -> String { + if shape.header_out.is_empty() { + return STREAMED_ANSWER_DART_TYPE.to_owned(); + } + let elements: Vec<&Type> = tuple_elements(success).into_iter().flatten().collect(); + let mut parts = vec![STREAMED_ANSWER_DART_TYPE.to_owned()]; + parts.extend(elements.iter().skip(1).map(|ty| dart_type_of(ty))); + format!("({})", parts.join(", ")) +} + fn return_type(operation: &OperationDef, shape: &HttpShape) -> String { match &operation.outcome { OperationOutcome::OneWay => "Future".to_owned(), - OperationOutcome::Reply { success, .. } => { - if matches!(shape.body_kind, BodyKind::Bytes) { - format!("Future<{}>", dart_type_of(success)) - } else if shape.header_out.is_empty() && is_unit_type(success) { - "Future".to_owned() - } else { - format!("Future<{}>", dart_type_of(success)) + OperationOutcome::Reply { success, .. } => match shape.body_kind { + BodyKind::Bytes => format!("Future<{}>", dart_type_of(success)), + BodyKind::Stream => format!("Future<{}>", stream_success_dart_type(shape, success)), + BodyKind::Json | BodyKind::Multipart => { + if shape.header_out.is_empty() && is_unit_type(success) { + "Future".to_owned() + } else { + format!("Future<{}>", dart_type_of(success)) + } } - } + }, } } -fn method(named: &str, fn_prefix: &str, operation: &OperationDef) -> String { +fn method( + named: &str, + fn_prefix: &str, + operation: &OperationDef, + has_stream: bool, + has_multipart: bool, +) -> String { let shape = HttpShape::of(operation); let wire = &operation.wire_name; let call = &operation.ts_name; @@ -205,14 +284,30 @@ fn method(named: &str, fn_prefix: &str, operation: &OperationDef) -> String { let query_build = query_build_stmt(operation, &shape); let headers_build = header_in_build_stmt(&shape); let body_build = body_build_stmt(&shape); + let parts_build = multipart_parts_build_stmt(operation, &shape, has_multipart); let method_str = shape.method.name(); let (send, decode) = match &operation.outcome { OperationOutcome::OneWay => ( - send_stmt_one_way(named, fn_prefix, wire, method_str), + send_stmt_one_way( + named, + fn_prefix, + wire, + method_str, + has_stream, + has_multipart, + ), one_way_decode_stmt(named, fn_prefix, &shape, wire), ), OperationOutcome::Reply { error, success } => ( - send_stmt_reply(named, fn_prefix, wire, method_str, &dart_type_of(error)), + send_stmt_reply( + named, + fn_prefix, + wire, + method_str, + &dart_type_of(error), + has_stream, + has_multipart, + ), reply_decode_stmt(named, fn_prefix, &shape, wire, error, success), ), }; @@ -223,6 +318,7 @@ fn method(named: &str, fn_prefix: &str, operation: &OperationDef) -> String { {query_build}\ {headers_build}\ {body_build}\ +{parts_build}\ {send}\ {decode}\ }}", @@ -340,32 +436,96 @@ fn header_in_build_stmt(shape: &HttpShape) -> String { } fn body_build_stmt(shape: &HttpShape) -> String { - if shape.method.carries_a_body() { - " final body = utf8.encode(jsonEncode(req.toJson()));\n".to_owned() - } else { - " const body = [];\n".to_owned() + match shape.body_kind { + BodyKind::Multipart => " const body = [];\n".to_owned(), + BodyKind::Bytes | BodyKind::Json | BodyKind::Stream => { + if shape.method.carries_a_body() { + " final body = utf8.encode(jsonEncode(req.toJson()));\n".to_owned() + } else { + " const body = [];\n".to_owned() + } + } } } +/// The `parts` a `body = "multipart"` method sends: one text entry per carried `Generated` field +/// not otherwise placeholder-bound (under its own wire key, rendered through the same +/// [`dart_wire_text`] a header or query value already renders through), then one entry per declared +/// `part` binding (under its own declared name, its value the method's own extra argument, passed +/// through untouched) — mirrors the TypeScript client's own `multipart_parts_build_stmt`. Every +/// other body kind on a service that declares multipart still builds an empty `parts` so the +/// request literal has a value for the field; a service with no multipart operation at all builds +/// nothing. +fn multipart_parts_build_stmt( + operation: &OperationDef, + shape: &HttpShape, + has_multipart: bool, +) -> String { + if !has_multipart { + return String::new(); + } + if !matches!(shape.body_kind, BodyKind::Multipart) { + return " const parts = <(String, dynamic)>[];\n".to_owned(); + } + let placeholders = shape.placeholder_names(); + let mut stmt = String::from(" final parts = <(String, dynamic)>[];\n"); + if let OperationInputs::Generated(fields) = &operation.inputs { + for (field, ty) in fields { + let field_name = field.to_string(); + if placeholders.contains(&field_name) { + continue; + } + let key = wire_key(field); + if let Some(inner) = option_inner(ty) { + let text = dart_wire_text(inner, &format!("req.{field_name}!")); + let _ = write!( + stmt, + " if (req.{field_name} != null) {{\n \ + parts.add(('{key}', {text}));\n \ + }}\n" + ); + } else { + let text = dart_wire_text(ty, &format!("req.{field_name}")); + let _ = writeln!(stmt, " parts.add(('{key}', {text}));"); + } + } + } + for part in &shape.multipart_parts { + let name = &part.name; + let parameter = &part.parameter; + let _ = writeln!(stmt, " parts.add(('{name}', {parameter}));"); + } + stmt +} + // --------------------------------------------------------------------------------------------- // Sending, and decoding the answer by status. // --------------------------------------------------------------------------------------------- -fn send_expr(method_str: &str) -> String { +fn send_expr(method_str: &str, has_multipart: bool) -> String { + let parts_field = if has_multipart { ", parts: parts" } else { "" }; format!( - "await _transport.send((method: '{method_str}', path: path, query: query, headers: headers, body: body))" + "await _transport.send((method: '{method_str}', path: path, query: query, headers: headers, body: body{parts_field}))" ) } -fn send_stmt_one_way(named: &str, fn_prefix: &str, wire: &str, method_str: &str) -> String { +fn send_stmt_one_way( + named: &str, + fn_prefix: &str, + wire: &str, + method_str: &str, + has_stream: bool, + has_multipart: bool, +) -> String { + let response = response_record_fields(has_stream); format!( - " late final ({{int status, List<(String, String)> headers, List body}}) response;\n \ + " late final ({response}) response;\n \ try {{\n \ response = {send};\n \ }} catch (uncarried) {{\n \ throw {named}HttpRefusal(_{fn_prefix}HttpTransportFailure('{wire}', '$uncarried'));\n \ }}\n", - send = send_expr(method_str), + send = send_expr(method_str, has_multipart), ) } @@ -375,15 +535,18 @@ fn send_stmt_reply( wire: &str, method_str: &str, error_ty: &str, + has_stream: bool, + has_multipart: bool, ) -> String { + let response = response_record_fields(has_stream); format!( - " late final ({{int status, List<(String, String)> headers, List body}}) response;\n \ + " late final ({response}) response;\n \ try {{\n \ response = {send};\n \ }} catch (uncarried) {{\n \ throw {named}HttpError<{error_ty}>.fault(_{fn_prefix}HttpTransportFailure('{wire}', '$uncarried'));\n \ }}\n", - send = send_expr(method_str), + send = send_expr(method_str, has_multipart), ) } @@ -422,6 +585,9 @@ fn reply_decode_stmt( error: &Type, success: &Type, ) -> String { + if matches!(shape.body_kind, BodyKind::Stream) { + return stream_reply_decode_stmt(named, fn_prefix, shape, wire, error, success); + } let ok_status = shape.ok_status; let error_condition = error_condition_expr(shape); let error_ty = dart_type_of(error); @@ -449,10 +615,127 @@ fn reply_decode_stmt( ) } +/// A `body = "stream"` operation's own decode: `206` answers the streamed record with its own +/// `contentRange` read back; the declared `ok_status` answers the same record with `contentRange` +/// left `null`; everything else falls through the same declared-error, fixed-fault and +/// unexpected-status ladder every other kind answers through — mirrors the Rust client's own +/// `stream_reply_decode`. +fn stream_reply_decode_stmt( + named: &str, + fn_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 = dart_type_of(error); + let partial = stream_success_arm(named, fn_prefix, wire, shape, &error_ty, success, true); + let full = stream_success_arm(named, fn_prefix, wire, shape, &error_ty, success, false); + format!( + " final status = response.status;\n \ + if (status == 206) {{\n{partial} }}\n \ + if (status == {ok_status}) {{\n{full} }}\n \ + if ({error_condition}) {{\n \ + late final {error_ty} declared;\n \ + try {{\n \ + declared = {error_ty}.fromJson(jsonDecode(utf8.decode(response.body)));\n \ + }} catch (rejected) {{\n \ + throw {named}HttpError<{error_ty}>.fault(\n \ + _{fn_prefix}HttpUndeserializablePayload('{wire}', '$rejected'),\n \ + );\n \ + }}\n \ + throw {named}HttpError<{error_ty}>.declared(declared);\n \ + }}\n \ + if (status == 400 || status == 404 || status == 500) {{\n \ + throw {named}HttpError<{error_ty}>.fault(_{fn_prefix}HttpFaultFromBody('{wire}', response.body));\n \ + }}\n \ + throw {named}HttpError<{error_ty}>.fault(\n \ + _{fn_prefix}HttpUndeserializablePayload('{wire}', 'an unexpected status ($status) answered'),\n \ + );\n" + ) +} + +/// One status arm of [`stream_reply_decode_stmt`]: `contentRange` read back off the response for a +/// `206` partial answer, left `null` for the declared `ok_status`'s whole-body answer — both +/// pairing it 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( + named: &str, + fn_prefix: &str, + wire: &str, + shape: &HttpShape, + error_ty: &str, + success: &Type, + partial: bool, +) -> String { + let mut stmt = if partial { + " final contentRange = _findHeader(response.headers, 'content-range') ?? '';\n" + .to_owned() + } else { + " const String? contentRange = null;\n".to_owned() + }; + stmt.push_str( + " final answer = (contentRange: contentRange, body: response.bodyStream);\n", + ); + if shape.header_out.is_empty() { + stmt.push_str(" return answer;\n"); + return stmt; + } + let elements: Vec<&Type> = tuple_elements(success).into_iter().flatten().collect(); + let (header_stmts, header_idents) = + header_out_read_stmts(named, fn_prefix, wire, shape, error_ty, &elements, 1); + stmt.push_str(&header_stmts); + let _ = writeln!(stmt, " return (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_decode_block` arm so the copies cannot drift. +fn header_out_read_stmts( + named: &str, + fn_prefix: &str, + wire: &str, + shape: &HttpShape, + error_ty: &str, + 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(body_elements)) + .enumerate() + { + let raw_ident = format!("rawHeaderOut{index}"); + let ident = format!("headerOut{index}"); + let decode = dart_header_out_decode(element_ty, &raw_ident); + let _ = write!( + stmt, + " final {raw_ident} = _findHeader(response.headers, '{name}');\n \ + if ({raw_ident} == null) {{\n \ + throw {named}HttpError<{error_ty}>.fault(\n \ + _{fn_prefix}HttpUndeserializablePayload('{wire}', 'a declared response header was missing'),\n \ + );\n \ + }}\n \ + final {ident} = {decode};\n" + ); + idents.push(ident); + } + (stmt, idents) +} + /// What one operation's method returns once its status has already matched `ok_status`: the byte /// list and content type for a `body = \"bytes\"` operation, nothing for a no-payload reply, the /// decoded body alone, or the decoded body plus every `header_out` element read back off the -/// response's own headers. +/// 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( named: &str, fn_prefix: &str, @@ -462,10 +745,10 @@ fn success_decode_block( success: &Type, ) -> String { if matches!(shape.body_kind, BodyKind::Bytes) { - return " final contentType = _findHeader(response.headers, 'content-type') ?? '';\n \ - return (response.body, contentType);\n" - .to_owned(); + return bytes_success_decode_block(named, fn_prefix, wire, shape, error_ty, success); } + // `Json` and `Multipart` both answer ordinary JSON, `header_out` included — a multipart + // operation's own body kind is a request-side concern only. if shape.header_out.is_empty() { if is_unit_type(success) { return " return;\n".to_owned(); @@ -499,32 +782,43 @@ fn success_decode_block( );\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 decode = dart_header_out_decode(element_ty, &raw_ident); - let _ = write!( - stmt, - " final {raw_ident} = _findHeader(response.headers, '{name}');\n \ - if ({raw_ident} == null) {{\n \ - throw {named}HttpError<{error_ty}>.fault(\n \ - _{fn_prefix}HttpUndeserializablePayload('{wire}', 'a declared response header was missing'),\n \ - );\n \ - }}\n \ - final {ident} = {decode};\n" - ); - header_idents.push(ident); - } + let (header_stmts, header_idents) = + header_out_read_stmts(named, fn_prefix, wire, shape, error_ty, &elements, 1); + stmt.push_str(&header_stmts); let _ = writeln!(stmt, " return (value, {});", header_idents.join(", ")); stmt } +/// A `body = "bytes"` operation's own success decode: the raw response body and its content type, +/// then every declared `header_out` element read back off the response's own headers — mirrors the +/// TypeScript client's own `bytes_success_decode_block`, shifted one slot for the content type. +fn bytes_success_decode_block( + named: &str, + fn_prefix: &str, + wire: &str, + shape: &HttpShape, + error_ty: &str, + success: &Type, +) -> String { + let mut stmt = + " final contentType = _findHeader(response.headers, 'content-type') ?? '';\n" + .to_owned(); + if shape.header_out.is_empty() { + stmt.push_str(" return (response.body, contentType);\n"); + return stmt; + } + let elements: Vec<&Type> = tuple_elements(success).into_iter().flatten().collect(); + let (header_stmts, header_idents) = + header_out_read_stmts(named, fn_prefix, wire, shape, error_ty, &elements, 2); + stmt.push_str(&header_stmts); + let _ = writeln!( + stmt, + " return (response.body, contentType, {});", + header_idents.join(", ") + ); + stmt +} + // --------------------------------------------------------------------------------------------- // The fault helpers every method reaches for. // --------------------------------------------------------------------------------------------- diff --git a/src/features/service_schema/tests.rs b/src/features/service_schema/tests.rs index 6adc3cb..f20141f 100644 --- a/src/features/service_schema/tests.rs +++ b/src/features/service_schema/tests.rs @@ -208,6 +208,83 @@ const MULTIPART_HTTP_SERVICE: &str = " } "; +/// A service declaring one `body = "bytes"` operation composing `header_out` onto its own tuple: +/// the bytes, their content type, then the declared header. Dart-gated mirror of +/// `BYTES_HTTP_SERVICE`, since a build can carry `dart` without `zod`. +#[cfg(feature = "dart")] +const DART_BYTES_HEADER_OUT_SERVICE: &str = " + pub trait ThumbnailClientService { + #[service_schema_op(http( + method = \"GET\", + path = \"/documents/{document_id}/thumbnail\", + body = \"bytes\", + header_out(\"x-document-id\"), + error_status(NotFound = 404), + ))] + async fn get_thumbnail( + &self, + ctx: &Ctx, + document_id: String, + ) -> Result<(Vec, String, String), ThumbnailError>; + } +"; + +/// A service declaring two `body = "stream"` operations: one answering the bare streamed answer, +/// one composing a declared `header_out` onto it. +#[cfg(feature = "dart")] +const DART_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. +/// Dart-gated mirror of `MULTIPART_HTTP_SERVICE`. +#[cfg(feature = "dart")] +const DART_MULTIPART_HTTP_SERVICE: &str = " + pub trait UploadClientService { + #[service_schema_op(http( + method = \"POST\", + path = \"/folders/{folder_id}/documents\", + body = \"multipart\", + part(\"file\" = attachment), + error_status(TooLarge = 413), + ))] + async fn upload_document( + &self, + ctx: &Ctx, + folder_id: String, + title: String, + description: Option, + attachment: Box, + ) -> Result; + } +"; + #[cfg(feature = "zod")] fn client_of(source: &str) -> String { client::emit(&parsed(source)).join("\n\n") diff --git a/src/features/service_schema/tests/dart_http_client_tests.rs b/src/features/service_schema/tests/dart_http_client_tests.rs index afeef97..da3b216 100644 --- a/src/features/service_schema/tests/dart_http_client_tests.rs +++ b/src/features/service_schema/tests/dart_http_client_tests.rs @@ -4,7 +4,10 @@ //! tests read structure, the same way `tests/dart_tests/tests.rs` reads the plain `dart` backend's //! own output: a substring that must appear, and a name that must not. -use super::{DART_HTTP_SERVICE, dart_http_client_of}; +use super::{ + DART_BYTES_HEADER_OUT_SERVICE, DART_HTTP_SERVICE, DART_MULTIPART_HTTP_SERVICE, + DART_STREAM_HTTP_SERVICE, dart_http_client_of, +}; /// The body of one method, from its own doc comment through the closing brace of the method /// following it (or the end of the class) — mirrors `http_client_tests`'s own `method_body`. @@ -315,3 +318,157 @@ fn the_client_class_holds_one_constructor_and_every_operation_as_a_method() { ); } } + +#[test] +fn a_bytes_operation_with_header_out_composes_body_content_type_and_the_header() { + let written = dart_http_client_of(DART_BYTES_HEADER_OUT_SERVICE); + assert!( + written.contains("Future<(List, String, String)> getThumbnail(String req) async {"), + "the return type is the bytes-and-content-type pair, with one more slot per declared \ + `header_out` entry. Got: {written}" + ); + let method = method_body(&written, "getThumbnail"); + assert!( + method.contains("final contentType = _findHeader(response.headers, 'content-type') ?? '';") + && method + .contains("final rawHeaderOut0 = _findHeader(response.headers, 'x-document-id');") + && method.contains("if (rawHeaderOut0 == null) {") + && method.contains("final headerOut0 = rawHeaderOut0;") + && method.contains("return (response.body, contentType, headerOut0);"), + "the declared header is read back the same way the JSON and stream paths read one, after \ + the bytes and their content type. Got: {method}" + ); +} + +#[test] +fn a_stream_operation_answers_a_content_range_and_body_record_at_200_and_206() { + let written = dart_http_client_of(DART_STREAM_HTTP_SERVICE); + assert!( + written.contains( + "Future<({String? contentRange, Stream> body})> getFile(String req) async {" + ), + "a bare `StreamedAnswer` renders as a record pairing a nullable `contentRange` with a lazy \ + `Stream>` body. Got: {written}" + ); + let method = method_body(&written, "getFile"); + assert!( + method.contains("if (status == 206) {") + && method.contains( + "final contentRange = _findHeader(response.headers, 'content-range') ?? '';" + ) + && method.contains( + "final answer = (contentRange: contentRange, body: response.bodyStream);" + ) + && method.contains("return answer;"), + "a `206` answers the record with `contentRange` read back off the response. Got: {method}" + ); + assert!( + method.contains("if (status == 200) {") + && method.contains("const String? contentRange = null;"), + "the declared `ok_status` answers the same record with `contentRange` left `null`. \ + Got: {method}" + ); +} + +#[test] +fn a_stream_operation_with_header_out_wraps_the_record_in_a_tuple() { + let written = dart_http_client_of(DART_STREAM_HTTP_SERVICE); + assert!( + written.contains( + "Future<(({String? contentRange, Stream> body}), String)> getTaggedFile(String req) async {" + ), + "a declared `header_out` wraps the streamed record in a tuple, exactly as the bytes and \ + JSON paths compose theirs. Got: {written}" + ); + let method = method_body(&written, "getTaggedFile"); + assert!( + method.contains("final rawHeaderOut0 = _findHeader(response.headers, 'x-checksum');") + && method.contains("final headerOut0 = rawHeaderOut0;") + && method.contains("return (answer, headerOut0);"), + "the header is read back once the record is built, in both the `206` and `200` arms. \ + Got: {method}" + ); +} + +#[test] +fn the_seam_carries_a_lazy_body_stream_only_where_a_service_declares_one() { + let plain = dart_http_client_of(DART_HTTP_SERVICE); + assert!( + !plain.contains("bodyStream"), + "a service with no streamed operation carries no `bodyStream` field. Got: {plain}" + ); + let streamed = dart_http_client_of(DART_STREAM_HTTP_SERVICE); + assert!( + streamed.contains( + "Future<({int status, List<(String, String)> headers, List body, \ + Stream> bodyStream})> send(" + ), + "a service with a streamed operation carries `bodyStream` on the seam's own response \ + record, and no HTTP package is named to spell `Stream`. Got: {streamed}" + ); + for named in [ + "package:http", + "package:dio", + "package:chopper", + "dart:io", + "import '", + ] { + assert!( + !streamed.contains(named), + "the seam still names no HTTP package once it carries a stream. Got: {streamed}" + ); + } +} + +#[test] +fn a_multipart_operation_carries_parts_on_the_seam_and_takes_an_extra_file_argument() { + let written = dart_http_client_of(DART_MULTIPART_HTTP_SERVICE); + assert!( + written.contains( + "({String method, String path, String query, List<(String, String)> headers, \ + List body, List<(String, dynamic)> parts}) request," + ), + "the seam's own request record carries `parts` for a multipart-declaring service. \ + Got: {written}" + ); + assert!( + written.contains( + "Future uploadDocument(UploadDocumentRequest req, dynamic attachment) async {" + ), + "the client method spells the extra file argument beside the message, under its own Rust \ + spelling and Dart's own opaque type. Got: {written}" + ); +} + +#[test] +fn a_multipart_method_builds_one_text_part_per_field_and_one_file_part_per_binding() { + let written = dart_http_client_of(DART_MULTIPART_HTTP_SERVICE); + let method = method_body(&written, "uploadDocument"); + assert!( + method.contains("const body = [];"), + "a multipart method's content rides in `parts`, never `body`. Got: {method}" + ); + assert!( + method.contains("final parts = <(String, dynamic)>[];") + && method.contains("parts.add(('title', '${req.title}'));") + && method.contains( + "if (req.description != null) {\n \ + parts.add(('description', '${req.description!}'));\n \ + }" + ) + && method.contains("parts.add(('file', attachment));"), + "one text part per carried field not otherwise placeholder-bound, then one file part per \ + `part` binding. Got: {method}" + ); + assert!( + !method.contains("parts.add(('folder_id'"), + "the path-bound field is read off the path, not sent as a text part too. Got: {method}" + ); + assert!( + method.contains( + "response = await _transport.send((method: 'POST', path: path, query: query, \ + headers: headers, body: body, parts: parts));" + ), + "`parts` rides beside `body` in the request the seam is handed. Got: {method}" + ); +}