Skip to main content

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}