From b220fd3ffa87c36ad6aab3c7483d4a0095d7c9b8 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 10 May 2026 12:04:42 +0200 Subject: [PATCH 1/2] Fix REST query client serialization --- crates/rest/ras-rest-macro/src/client.rs | 79 ++++++++++++++------ crates/rest/ras-rest-macro/src/lib.rs | 4 +- crates/rest/ras-rest-macro/tests/e2e.rs | 94 ++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 23 deletions(-) diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index cccdb43..33c5360 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -1,18 +1,42 @@ use crate::{EndpointDefinition, HttpMethod, ServiceDefinition}; use quote::quote; -use syn::Type; - -/// True if `ty` is syntactically `Option<...>`. Matches the bare `Option` -/// segment as well as fully-qualified forms like `std::option::Option` / -/// `core::option::Option` — anything whose last path segment is `Option`. -fn is_option_type(ty: &Type) -> bool { - if let Type::Path(type_path) = ty - && let Some(last) = type_path.path.segments.last() - { - return last.ident == "Option"; +use syn::{GenericArgument, PathArguments, Type}; + +/// Returns the inner type for syntactic wrappers like `Option` or `Vec`. +/// Matches bare and fully-qualified forms by checking the final path segment. +fn generic_inner_type<'a>(ty: &'a Type, wrapper: &str) -> Option<&'a Type> { + let Type::Path(type_path) = ty else { + return None; + }; + + let last = type_path.path.segments.last()?; + if last.ident != wrapper { + return None; } - false + let PathArguments::AngleBracketed(args) = &last.arguments else { + return None; + }; + + args.args.iter().find_map(|arg| { + if let GenericArgument::Type(inner) = arg { + Some(inner) + } else { + None + } + }) +} + +fn option_inner_type(ty: &Type) -> Option<&Type> { + generic_inner_type(ty, "Option") +} + +fn vec_inner_type(ty: &Type) -> Option<&Type> { + generic_inner_type(ty, "Vec") +} + +fn option_vec_inner_type(ty: &Type) -> Option<&Type> { + option_inner_type(ty).and_then(vec_inner_type) } /// Generate client code for REST service @@ -234,32 +258,43 @@ fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_ma } // Build query-string handling. Required params are always serialized; - // `Option` params are skipped when `None`. Values are converted with - // `ToString` and url-encoded by reqwest's `.query()` helper. + // `Option` params are skipped when `None`. Values are serialized by + // reqwest's serde-backed `.query()` helper so enum serde renames and other + // query wire formats stay aligned with server-side extraction. let query_handling = if endpoint.query_params.is_empty() { quote! {} } else { - let pushes = endpoint.query_params.iter().map(|qp| { + let query_serializers = endpoint.query_params.iter().map(|qp| { let param_name = &qp.name; let param_str = qp.name.to_string(); - if is_option_type(&qp.param_type) { + if option_vec_inner_type(&qp.param_type).is_some() { + quote! { + if let Some(__values) = &#param_name { + for __item in __values { + request_builder = request_builder.query(&[(#param_str, __item)]); + } + } + } + } else if vec_inner_type(&qp.param_type).is_some() { + quote! { + for __item in &#param_name { + request_builder = request_builder.query(&[(#param_str, __item)]); + } + } + } else if option_inner_type(&qp.param_type).is_some() { quote! { if let Some(__v) = &#param_name { - __query_pairs.push((#param_str, __v.to_string())); + request_builder = request_builder.query(&[(#param_str, __v)]); } } } else { quote! { - __query_pairs.push((#param_str, #param_name.to_string())); + request_builder = request_builder.query(&[(#param_str, &#param_name)]); } } }); quote! { - let mut __query_pairs: Vec<(&'static str, String)> = Vec::new(); - #(#pushes)* - if !__query_pairs.is_empty() { - request_builder = request_builder.query(&__query_pairs); - } + #(#query_serializers)* } }; diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index 1e03081..bea986e 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -680,7 +680,9 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)] +enum SortOrder { + #[serde(rename = "asc")] + Asc, + #[serde(rename = "desc")] + Desc, +} + rest_service!({ service_name: Demo, base_path: "/api", @@ -35,6 +43,8 @@ rest_service!({ GET WITH_PERMISSIONS(["user"]) items/{id: u32}() -> Item, POST WITH_PERMISSIONS(["admin"]) items(CreateItem) -> Item, GET UNAUTHORIZED search ? q: String & limit: Option & exact: bool () -> ItemsResponse, + GET UNAUTHORIZED filter ? tags: Vec & optional_tags: Option> () -> ItemsResponse, + GET UNAUTHORIZED sorted ? order: SortOrder () -> ItemsResponse, POST WITH_PERMISSIONS(["admin"]) items/batch ? notify: bool (CreateItem) -> Item, GET WITH_PERMISSIONS(["user"]) items/{id: u32}/related ? tag: Option () -> ItemsResponse, ] @@ -89,6 +99,49 @@ impl DemoTrait for DemoImpl { Ok(RestResponse::ok(ItemsResponse { items })) } + async fn get_filter( + &self, + tags: Vec, + optional_tags: Option>, + ) -> RestResult { + let mut items: Vec = tags + .into_iter() + .enumerate() + .map(|(idx, tag)| Item { + id: idx as u32, + name: format!("tag:{tag}"), + }) + .collect(); + + let offset = items.len(); + items.extend( + optional_tags + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(idx, tag)| Item { + id: (offset + idx) as u32, + name: format!("optional:{tag}"), + }), + ); + + Ok(RestResponse::ok(ItemsResponse { items })) + } + + async fn get_sorted(&self, order: SortOrder) -> RestResult { + let label = match order { + SortOrder::Asc => "asc", + SortOrder::Desc => "desc", + }; + + Ok(RestResponse::ok(ItemsResponse { + items: vec![Item { + id: 0, + name: format!("order:{label}"), + }], + })) + } + async fn post_items_batch( &self, _user: &AuthenticatedUser, @@ -220,6 +273,47 @@ async fn query_params_required_and_optional_serialize_correctly() { assert_eq!(resp.items[0].name, "fuzzy:zz-0"); } +#[tokio::test] +async fn vec_query_params_serialize_as_repeated_keys() { + let server = spawn_http(router()); + let base = server.server_address().unwrap().to_string(); + + let resp = client(&base) + .get_filter( + vec!["red".to_string(), "blue".to_string()], + Some(vec!["featured".to_string()]), + ) + .await + .expect("filter with repeated keys"); + let names: Vec<_> = resp.items.into_iter().map(|item| item.name).collect(); + assert_eq!(names, vec!["tag:red", "tag:blue", "optional:featured"]); + + let resp = client(&base) + .get_filter(vec!["solo".to_string()], None) + .await + .expect("filter without optional tags"); + let names: Vec<_> = resp.items.into_iter().map(|item| item.name).collect(); + assert_eq!(names, vec!["tag:solo"]); +} + +#[tokio::test] +async fn enum_query_params_use_serde_renames_without_display() { + let server = spawn_http(router()); + let base = server.server_address().unwrap().to_string(); + + let resp = client(&base) + .get_sorted(SortOrder::Asc) + .await + .expect("sort asc"); + assert_eq!(resp.items[0].name, "order:asc"); + + let resp = client(&base) + .get_sorted(SortOrder::Desc) + .await + .expect("sort desc"); + assert_eq!(resp.items[0].name, "order:desc"); +} + #[tokio::test] async fn query_params_with_body_and_auth() { let server = spawn_http(router()); From 87bd645600df25adcacd500ef2d4b8aa4f93fe33 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 10 May 2026 12:13:45 +0200 Subject: [PATCH 2/2] Bump REST macro version and changelog --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- crates/rest/ras-rest-macro/Cargo.toml | 2 +- documentation/ras-rest-macro.md | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75dd378..0db9149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed - 2026-05-10 +- `ras-rest-macro`: Generated REST clients now serialize query parameters through reqwest's serde-backed query path, support repeated-key `Vec` and `Option>` query params, and honor serde-renamed enum values without requiring `Display`. Fixes #3. + +### Changed - 2026-05-10 +- Bumped `ras-rest-macro` from `0.1.1` to `0.2.0` because generated client query params now use serde serialization instead of `Display`/`ToString`. + ### Added - 2026-05-09 - Established repository versioning and changelog policy in `VERSIONING.md`. - Added doc-comment support for generated API documentation: diff --git a/Cargo.lock b/Cargo.lock index 6d9f191..a0c96f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3458,7 +3458,7 @@ dependencies = [ [[package]] name = "ras-rest-macro" -version = "0.1.1" +version = "0.2.0" dependencies = [ "async-trait", "axum", diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index ed4dd3a..b00eada 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-rest-macro" -version = "0.1.1" +version = "0.2.0" edition = "2024" description = "Procedural macro for type-safe REST APIs with auth integration and OpenAPI document generation" license = "MIT OR Apache-2.0" diff --git a/documentation/ras-rest-macro.md b/documentation/ras-rest-macro.md index e8c291d..3dea6a2 100644 --- a/documentation/ras-rest-macro.md +++ b/documentation/ras-rest-macro.md @@ -31,7 +31,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -ras-rest-macro = "0.1.0" +ras-rest-macro = "0.2.0" ras-rest-core = "0.1.0" ras-auth-core = "0.1.0" # For authentication serde = { version = "1.0", features = ["derive"] } @@ -683,4 +683,4 @@ The `ras-rest-macro` provides a comprehensive solution for building type-safe RE - Built-in authentication and authorization - Performance monitoring and usage tracking -This approach eliminates the need for manual client maintenance and ensures your API clients are always in sync with your server implementation. The shift from WASM to OpenAPI-based TypeScript generation provides significant benefits in bundle size (95% reduction), developer experience, and debugging capabilities. \ No newline at end of file +This approach eliminates the need for manual client maintenance and ensures your API clients are always in sync with your server implementation. The shift from WASM to OpenAPI-based TypeScript generation provides significant benefits in bundle size (95% reduction), developer experience, and debugging capabilities.