toolkit_contract/query.rs
1//! Query-parameter contract shared by the generated client, the generated
2//! server route, and the `OpenAPI` spec.
3//!
4//! # Why this module exists
5//!
6//! The three consumers of a query parameter used to disagree. The client
7//! flattened the parameter to JSON and hand-rolled a query string; the server
8//! decoded with `serde_urlencoded` via `axum::extract::Query`; the spec
9//! described whatever the macro could infer from the Rust type at expansion
10//! time. Nothing reconciled them, so several shapes compiled cleanly and then
11//! failed on every request:
12//!
13//! * a scalar parameter (`count: u64`) produced `Query<u64>`, and
14//! `serde_urlencoded`'s top-level deserializer only yields a map — a
15//! guaranteed 400 against a spec that advertised the parameter as valid;
16//! * a `Vec<T>` field went out as repeated keys, which `serde_urlencoded`
17//! cannot collect into a sequence;
18//! * a nested struct lost its outer key entirely during flattening.
19//!
20//! # The contract now
21//!
22//! A query parameter is a struct deriving [`macro@crate::QueryParams`]. Both
23//! ends encode and decode it with `serde_html_form`, which round-trips repeated
24//! keys as sequences, and the derive emits the parameter list the `OpenAPI` spec
25//! is built from — so the spec is generated from the same declaration the wire
26//! format is.
27//!
28//! Each field's leaf type must implement [`QueryScalar`]. That bound is what
29//! rejects nesting: a query string is a flat list of key/value pairs, and
30//! neither `serde_html_form` nor any other single-level codec can represent a
31//! sub-object unambiguously.
32
33use serde::Serialize;
34
35/// A type that can appear as a query-parameter value.
36///
37/// Implemented for the primitives; implement it for a domain newtype or a
38/// unit-only enum that serializes as a plain string. The bound exists to keep
39/// nested structs out of query parameters — see the module docs.
40#[diagnostic::on_unimplemented(
41 message = "`{Self}` cannot be a query-parameter field",
42 label = "not a query scalar",
43 note = "a query string is a flat list of key/value pairs, so a query struct's fields must be \
44 scalars, `Option<scalar>`, or `Vec<scalar>` — nested structs cannot be represented",
45 note = "for a unit-only enum or a newtype that serializes as a string, add \
46 `impl toolkit_contract::query::QueryScalar for {Self} {{}}`",
47 note = "for a genuinely nested shape, mark the method `#[server_manual]` and handle the query \
48 string by hand"
49)]
50pub trait QueryScalar {
51 /// The `OpenAPI` primitive type this value renders as.
52 const OPENAPI_TYPE: &'static str = "string";
53}
54
55macro_rules! impl_query_scalar {
56 ($ty:ty => $openapi:literal) => {
57 impl QueryScalar for $ty {
58 const OPENAPI_TYPE: &'static str = $openapi;
59 }
60 };
61}
62
63impl_query_scalar!(String => "string");
64impl_query_scalar!(str => "string");
65impl_query_scalar!(bool => "boolean");
66impl_query_scalar!(f32 => "number");
67impl_query_scalar!(f64 => "number");
68impl_query_scalar!(i8 => "integer");
69impl_query_scalar!(i16 => "integer");
70impl_query_scalar!(i32 => "integer");
71impl_query_scalar!(i64 => "integer");
72impl_query_scalar!(i128 => "integer");
73impl_query_scalar!(isize => "integer");
74impl_query_scalar!(u8 => "integer");
75impl_query_scalar!(u16 => "integer");
76impl_query_scalar!(u32 => "integer");
77impl_query_scalar!(u64 => "integer");
78impl_query_scalar!(u128 => "integer");
79impl_query_scalar!(usize => "integer");
80
81/// One query parameter as it should appear in the `OpenAPI` document.
82///
83/// Emitted by `#[derive(QueryParams)]` and consumed by the generated server
84/// route, so the spec cannot drift from what the client actually sends.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct QueryParamSpec {
87 /// Wire name of the parameter.
88 pub name: &'static str,
89 /// `OpenAPI` type of a single value (the *item* type when `array`).
90 pub openapi_type: &'static str,
91 /// `false` for `Option<..>` fields.
92 pub required: bool,
93 /// `true` for `Vec<..>` fields, which encode as repeated keys.
94 pub array: bool,
95}
96
97/// A struct usable as a REST query parameter.
98///
99/// Derive it with `#[derive(toolkit_contract::QueryParams)]`; the derive also
100/// checks each field's leaf type against [`QueryScalar`].
101pub trait QueryParams {
102 /// The parameters this struct contributes to an operation's `OpenAPI` spec.
103 fn openapi_params() -> &'static [QueryParamSpec];
104}
105
106/// Serialize a query struct into a query string (no leading `?`).
107///
108/// Returns `None` when the result is empty, so the caller can skip the `?`
109/// entirely. `Vec` fields become repeated keys and `None` fields are omitted.
110///
111/// # Errors
112/// Returns the underlying `serde_html_form` error if `value` cannot be
113/// represented as a flat key/value list.
114pub fn to_query_string<T: Serialize>(
115 value: &T,
116) -> Result<Option<String>, serde_html_form::ser::Error> {
117 let encoded = serde_html_form::to_string(value)?;
118 if encoded.is_empty() {
119 Ok(None)
120 } else {
121 Ok(Some(encoded))
122 }
123}
124
125#[cfg(feature = "rest-server")]
126pub use extract::QueryParamsExtractor;
127
128#[cfg(feature = "rest-server")]
129mod extract {
130 use axum::extract::FromRequestParts;
131 use axum::http::request::Parts;
132 use axum::response::{IntoResponse, Response};
133 use serde::de::DeserializeOwned;
134 use toolkit_canonical_errors::resource_error;
135
136 /// GTS-typed error scope for query-string decoding on generated routes.
137 #[resource_error(gts_id!("cf.core.contract.query.v1~"))]
138 pub struct QueryError;
139
140 /// Axum extractor for a query struct, decoded with the same
141 /// `serde_html_form` codec the generated client encodes with.
142 ///
143 /// Replaces `axum::extract::Query`, which decodes via `serde_urlencoded`
144 /// and therefore cannot collect repeated keys into a `Vec`. It also
145 /// rejects with a canonical error, so a malformed query string comes back
146 /// as the same RFC 9457 Problem envelope as every other error from a
147 /// generated route instead of axum's plain-text 400.
148 pub struct QueryParamsExtractor<T>(pub T);
149
150 impl<T, S> FromRequestParts<S> for QueryParamsExtractor<T>
151 where
152 T: DeserializeOwned,
153 S: Send + Sync,
154 {
155 type Rejection = Response;
156
157 async fn from_request_parts(
158 parts: &mut Parts,
159 _state: &S,
160 ) -> Result<Self, Self::Rejection> {
161 let raw = parts.uri.query().unwrap_or_default();
162 match serde_html_form::from_str::<T>(raw) {
163 Ok(value) => Ok(Self(value)),
164 Err(e) => Err(QueryError::invalid_argument()
165 .with_field_violation("query", format!("{e}"), "INVALID_QUERY_STRING")
166 .create()
167 .into_response()),
168 }
169 }
170 }
171}
172
173#[cfg(test)]
174#[cfg_attr(coverage_nightly, coverage(off))]
175mod tests {
176 use super::*;
177 use serde::Deserialize;
178
179 #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
180 struct Filter {
181 #[serde(skip_serializing_if = "Option::is_none")]
182 status: Option<String>,
183 // `#[serde(default)]` is mandatory on a repeated field: an empty `Vec`
184 // emits no key at all, so without it the round trip fails with
185 // `missing field`. `#[derive(QueryParams)]` enforces this.
186 #[serde(default)]
187 tags: Vec<String>,
188 limit: u32,
189 }
190
191 /// The property that matters: whatever the client writes, the server reads
192 /// back unchanged. Each case below is a shape that silently produced a 400
193 /// under the old split codec.
194 fn round_trip(value: &Filter) -> Filter {
195 let encoded = to_query_string(value).unwrap().unwrap_or_default();
196 serde_html_form::from_str(&encoded).unwrap()
197 }
198
199 #[test]
200 fn round_trips_a_vec_field_as_repeated_keys() {
201 let value = Filter {
202 status: Some("paid".to_owned()),
203 tags: vec!["a".to_owned(), "b".to_owned()],
204 limit: 10,
205 };
206 let encoded = to_query_string(&value).unwrap().unwrap();
207 assert!(
208 encoded.contains("tags=a") && encoded.contains("tags=b"),
209 "expected repeated keys, got: {encoded}"
210 );
211 assert_eq!(round_trip(&value), value);
212 }
213
214 #[test]
215 fn round_trips_an_empty_vec() {
216 // An empty `Vec` emits no key at all, so the field must still
217 // deserialize — `serde_html_form` yields an empty sequence.
218 let value = Filter {
219 status: None,
220 tags: Vec::new(),
221 limit: 0,
222 };
223 assert_eq!(round_trip(&value), value);
224 }
225
226 #[test]
227 fn omits_a_none_option() {
228 let value = Filter {
229 status: None,
230 tags: vec!["x".to_owned()],
231 limit: 1,
232 };
233 let encoded = to_query_string(&value).unwrap().unwrap();
234 assert!(
235 !encoded.contains("status"),
236 "None must not appear on the wire, got: {encoded}"
237 );
238 assert_eq!(round_trip(&value), value);
239 }
240
241 #[test]
242 fn empty_struct_yields_no_query_string() {
243 #[derive(Serialize)]
244 struct Empty;
245 assert_eq!(to_query_string(&Empty {}).unwrap(), None);
246 }
247}