Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` and `Option<Vec<T>>` 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:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/rest/ras-rest-macro/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
79 changes: 57 additions & 22 deletions crates/rest/ras-rest-macro/src/client.rs
Original file line number Diff line number Diff line change
@@ -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<T>` /
/// `core::option::Option<T>` — 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<T>` or `Vec<T>`.
/// 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
Expand Down Expand Up @@ -234,32 +258,43 @@ fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_ma
}

// Build query-string handling. Required params are always serialized;
// `Option<T>` params are skipped when `None`. Values are converted with
// `ToString` and url-encoded by reqwest's `.query()` helper.
// `Option<T>` 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)*
}
};

Expand Down
4 changes: 3 additions & 1 deletion crates/rest/ras-rest-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,9 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result<proc_mac

#[cfg(feature = "server")]
mod query_params {
use serde::Deserialize;
#[allow(unused_imports)]
use super::*;

#(#query_structs)*
}

Expand Down
94 changes: 94 additions & 0 deletions crates/rest/ras-rest-macro/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ struct ItemsResponse {
items: Vec<Item>,
}

#[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",
Expand All @@ -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<u32> & exact: bool () -> ItemsResponse,
GET UNAUTHORIZED filter ? tags: Vec<String> & optional_tags: Option<Vec<String>> () -> 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<String> () -> ItemsResponse,
]
Expand Down Expand Up @@ -89,6 +99,49 @@ impl DemoTrait for DemoImpl {
Ok(RestResponse::ok(ItemsResponse { items }))
}

async fn get_filter(
&self,
tags: Vec<String>,
optional_tags: Option<Vec<String>>,
) -> RestResult<ItemsResponse> {
let mut items: Vec<Item> = 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<ItemsResponse> {
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,
Expand Down Expand Up @@ -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());
Expand Down
4 changes: 2 additions & 2 deletions documentation/ras-rest-macro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down Expand Up @@ -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.
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.
Loading