api_bones/response.rs
1//! Generic API response envelope types.
2//!
3//! [`ApiResponse<T>`] wraps any payload with consistent metadata so all API
4//! endpoints share the same top-level shape.
5//!
6//! ```json
7//! {
8//! "data": { "id": "abc", "name": "Foo" },
9//! "meta": {
10//! "request_id": "req-123",
11//! "timestamp": "2026-04-06T19:00:00Z",
12//! "version": "1.4.0"
13//! },
14//! "links": [{ "rel": "self", "href": "/resources/abc" }]
15//! }
16//! ```
17//!
18//! # Builder example
19//!
20//! ```rust
21//! use api_bones::response::{ApiResponse, ResponseMeta};
22//!
23//! let response: ApiResponse<&str> = ApiResponse::builder("hello world")
24//! .meta(ResponseMeta::new().request_id("req-001").version("1.0"))
25//! .build();
26//!
27//! assert_eq!(response.data, "hello world");
28//! ```
29
30#[cfg(all(not(feature = "std"), feature = "alloc"))]
31use alloc::string::String;
32#[cfg(feature = "serde")]
33use serde::{Deserialize, Serialize};
34
35use crate::common::Timestamp;
36use crate::links::Links;
37
38// ---------------------------------------------------------------------------
39// ResponseMeta
40// ---------------------------------------------------------------------------
41
42/// Metadata attached to every [`ApiResponse`].
43///
44/// All fields are optional to keep construction ergonomic. Consumers that do
45/// not need a field simply omit it; it will be skipped in serialization.
46#[derive(Debug, Clone, PartialEq, Default)]
47#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
49#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
50pub struct ResponseMeta {
51 /// Unique identifier for the originating HTTP request.
52 ///
53 /// Useful for correlating logs and distributed traces.
54 #[cfg_attr(
55 feature = "serde",
56 serde(default, skip_serializing_if = "Option::is_none")
57 )]
58 pub request_id: Option<String>,
59
60 /// Server-side timestamp when the response was generated (RFC 3339).
61 #[cfg_attr(
62 feature = "serde",
63 serde(default, skip_serializing_if = "Option::is_none")
64 )]
65 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>, format = DateTime))]
66 pub timestamp: Option<Timestamp>,
67
68 /// API or service version string.
69 #[cfg_attr(
70 feature = "serde",
71 serde(default, skip_serializing_if = "Option::is_none")
72 )]
73 pub version: Option<String>,
74}
75
76impl ResponseMeta {
77 /// Create an empty `ResponseMeta`.
78 ///
79 /// # Examples
80 ///
81 /// ```rust
82 /// use api_bones::response::ResponseMeta;
83 ///
84 /// let meta = ResponseMeta::new();
85 /// assert!(meta.request_id.is_none());
86 /// assert!(meta.version.is_none());
87 /// ```
88 #[must_use]
89 pub fn new() -> Self {
90 Self::default()
91 }
92
93 /// Set the `request_id` field (builder-style).
94 ///
95 /// # Examples
96 ///
97 /// ```rust
98 /// use api_bones::response::ResponseMeta;
99 ///
100 /// let meta = ResponseMeta::new().request_id("req-001");
101 /// assert_eq!(meta.request_id.as_deref(), Some("req-001"));
102 /// ```
103 #[must_use]
104 pub fn request_id(mut self, id: impl Into<String>) -> Self {
105 self.request_id = Some(id.into());
106 self
107 }
108
109 /// Set the `timestamp` field (builder-style).
110 #[must_use]
111 pub fn timestamp(mut self, ts: Timestamp) -> Self {
112 self.timestamp = Some(ts);
113 self
114 }
115
116 /// Set the `version` field (builder-style).
117 ///
118 /// # Examples
119 ///
120 /// ```rust
121 /// use api_bones::response::ResponseMeta;
122 ///
123 /// let meta = ResponseMeta::new().version("1.4.0");
124 /// assert_eq!(meta.version.as_deref(), Some("1.4.0"));
125 /// ```
126 #[must_use]
127 pub fn version(mut self, v: impl Into<String>) -> Self {
128 self.version = Some(v.into());
129 self
130 }
131}
132
133// ---------------------------------------------------------------------------
134// arbitrary / proptest impls for ResponseMeta
135// ---------------------------------------------------------------------------
136
137// ResponseMeta contains `Option<Timestamp>` where Timestamp is chrono::DateTime<Utc>
138// (when the `chrono` feature is enabled). Since chrono does not implement
139// arbitrary::Arbitrary or proptest::arbitrary::Arbitrary, we provide hand-rolled
140// impls for both feature combinations.
141
142#[cfg(all(feature = "arbitrary", not(feature = "chrono")))]
143impl<'a> arbitrary::Arbitrary<'a> for ResponseMeta {
144 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
145 use arbitrary::Arbitrary;
146 Ok(Self {
147 request_id: Arbitrary::arbitrary(u)?,
148 timestamp: Arbitrary::arbitrary(u)?,
149 version: Arbitrary::arbitrary(u)?,
150 })
151 }
152}
153
154#[cfg(all(feature = "arbitrary", feature = "chrono"))]
155impl<'a> arbitrary::Arbitrary<'a> for ResponseMeta {
156 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
157 use arbitrary::Arbitrary;
158 use chrono::TimeZone as _;
159 let timestamp = if bool::arbitrary(u)? {
160 // Clamp to a valid Unix timestamp range (year 1970–3000)
161 let secs = u.int_in_range(0i64..=32_503_680_000i64)?;
162 chrono::Utc.timestamp_opt(secs, 0).single()
163 } else {
164 None
165 };
166 Ok(Self {
167 request_id: Arbitrary::arbitrary(u)?,
168 timestamp,
169 version: Arbitrary::arbitrary(u)?,
170 })
171 }
172}
173
174#[cfg(all(feature = "proptest", not(feature = "chrono")))]
175impl proptest::arbitrary::Arbitrary for ResponseMeta {
176 type Parameters = ();
177 type Strategy = proptest::strategy::BoxedStrategy<Self>;
178
179 fn arbitrary_with((): ()) -> Self::Strategy {
180 use proptest::prelude::*;
181 (
182 proptest::option::of(any::<String>()),
183 proptest::option::of(any::<String>()),
184 proptest::option::of(any::<String>()),
185 )
186 .prop_map(|(request_id, timestamp, version)| Self {
187 request_id,
188 timestamp,
189 version,
190 })
191 .boxed()
192 }
193}
194
195#[cfg(all(feature = "proptest", feature = "chrono"))]
196impl proptest::arbitrary::Arbitrary for ResponseMeta {
197 type Parameters = ();
198 type Strategy = proptest::strategy::BoxedStrategy<Self>;
199
200 fn arbitrary_with((): ()) -> Self::Strategy {
201 use chrono::TimeZone as _;
202 use proptest::prelude::*;
203 (
204 proptest::option::of(any::<String>()),
205 proptest::option::of(0i64..=32_503_680_000i64),
206 proptest::option::of(any::<String>()),
207 )
208 .prop_map(|(request_id, ts_secs, version)| Self {
209 request_id,
210 timestamp: ts_secs.and_then(|s| chrono::Utc.timestamp_opt(s, 0).single()),
211 version,
212 })
213 .boxed()
214 }
215}
216
217// ---------------------------------------------------------------------------
218// ApiResponse
219// ---------------------------------------------------------------------------
220
221/// Generic API response envelope.
222///
223/// Wraps any payload `T` with consistent metadata so all endpoints share the
224/// same top-level JSON shape. Use [`ApiResponse::builder`] for ergonomic
225/// construction.
226///
227/// # Composing with `PaginatedResponse`
228///
229/// ```rust
230/// use api_bones::pagination::{PaginatedResponse, PaginationParams};
231/// use api_bones::response::ApiResponse;
232///
233/// let params = PaginationParams::default();
234/// let page = PaginatedResponse::new(vec![1i32, 2, 3], 10, ¶ms);
235/// let response = ApiResponse::builder(page).build();
236/// assert_eq!(response.data.total_count, 10);
237/// ```
238#[derive(Debug, Clone, PartialEq)]
239#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
240#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
242#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
243#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
244pub struct ApiResponse<T> {
245 /// The primary payload.
246 pub data: T,
247
248 /// Request-level metadata.
249 pub meta: ResponseMeta,
250
251 /// Optional hypermedia links.
252 #[cfg_attr(
253 feature = "serde",
254 serde(default, skip_serializing_if = "Option::is_none")
255 )]
256 pub links: Option<Links>,
257}
258
259// ---------------------------------------------------------------------------
260// ApiResponseBuilder
261// ---------------------------------------------------------------------------
262
263/// Builder for [`ApiResponse`].
264///
265/// Obtain one via [`ApiResponse::builder`].
266///
267/// # Design note — simple builder, not typestate
268///
269/// [`HealthCheckBuilder`](crate::health::HealthCheckBuilder) and
270/// [`ReadinessResponseBuilder`](crate::health::ReadinessResponseBuilder) use a
271/// typestate pattern because they have *multiple required fields* that must all
272/// be provided before the type is valid to construct. `ApiResponseBuilder` is
273/// different: the only required field is `data`, which is supplied at
274/// construction time via [`ApiResponse::builder`]. Everything else (`meta`,
275/// `links`) is optional and has a sensible default, so there are no remaining
276/// required fields for the typestate machinery to enforce. A plain builder is
277/// therefore appropriate here.
278pub struct ApiResponseBuilder<T> {
279 data: T,
280 meta: ResponseMeta,
281 links: Option<Links>,
282}
283
284impl<T> ApiResponseBuilder<T> {
285 /// Set the `meta` field.
286 ///
287 /// # Examples
288 ///
289 /// ```rust
290 /// use api_bones::response::{ApiResponse, ResponseMeta};
291 ///
292 /// let response: ApiResponse<&str> = ApiResponse::builder("hi")
293 /// .meta(ResponseMeta::new().request_id("req-1"))
294 /// .build();
295 /// assert_eq!(response.meta.request_id.as_deref(), Some("req-1"));
296 /// ```
297 #[must_use]
298 pub fn meta(mut self, meta: ResponseMeta) -> Self {
299 self.meta = meta;
300 self
301 }
302
303 /// Set the `links` field.
304 ///
305 /// # Examples
306 ///
307 /// ```rust
308 /// use api_bones::response::ApiResponse;
309 /// use api_bones::links::{Link, Links};
310 ///
311 /// let response: ApiResponse<&str> = ApiResponse::builder("hi")
312 /// .links(Links::new().push(Link::self_link("/items/1")))
313 /// .build();
314 /// assert!(response.links.is_some());
315 /// ```
316 #[must_use]
317 pub fn links(mut self, links: Links) -> Self {
318 self.links = Some(links);
319 self
320 }
321
322 /// Consume the builder and produce an [`ApiResponse`].
323 ///
324 /// # Examples
325 ///
326 /// ```rust
327 /// use api_bones::response::ApiResponse;
328 ///
329 /// let response: ApiResponse<i32> = ApiResponse::builder(42).build();
330 /// assert_eq!(response.data, 42);
331 /// assert!(response.links.is_none());
332 /// ```
333 #[must_use]
334 pub fn build(self) -> ApiResponse<T> {
335 ApiResponse {
336 data: self.data,
337 meta: self.meta,
338 links: self.links,
339 }
340 }
341}
342
343impl<T> ApiResponse<T> {
344 /// Begin building an [`ApiResponse`] with the given `data` payload.
345 #[must_use]
346 pub fn builder(data: T) -> ApiResponseBuilder<T> {
347 ApiResponseBuilder {
348 data,
349 meta: ResponseMeta::default(),
350 links: None,
351 }
352 }
353}
354
355// ---------------------------------------------------------------------------
356// Tests
357// ---------------------------------------------------------------------------
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 // -----------------------------------------------------------------------
364 // ResponseMeta construction
365 // -----------------------------------------------------------------------
366
367 #[test]
368 fn response_meta_new_is_empty() {
369 let m = ResponseMeta::new();
370 assert!(m.request_id.is_none());
371 assert!(m.timestamp.is_none());
372 assert!(m.version.is_none());
373 }
374
375 #[test]
376 fn response_meta_builder_chain() {
377 let m = ResponseMeta::new().request_id("req-001").version("1.4.0");
378 assert_eq!(m.request_id.as_deref(), Some("req-001"));
379 assert_eq!(m.version.as_deref(), Some("1.4.0"));
380 }
381
382 #[cfg(feature = "chrono")]
383 #[test]
384 fn response_meta_timestamp_builder() {
385 use chrono::Utc;
386 let ts = Utc::now();
387 let m = ResponseMeta::new().timestamp(ts);
388 assert!(m.timestamp.is_some());
389 }
390
391 // -----------------------------------------------------------------------
392 // ApiResponse construction
393 // -----------------------------------------------------------------------
394
395 #[test]
396 fn api_response_builder_minimal() {
397 let r: ApiResponse<i32> = ApiResponse::builder(42).build();
398 assert_eq!(r.data, 42);
399 assert!(r.links.is_none());
400 assert!(r.meta.request_id.is_none());
401 }
402
403 #[test]
404 fn api_response_builder_with_meta_and_links() {
405 use crate::links::{Link, Links};
406 let meta = ResponseMeta::new().request_id("r1").version("2.0");
407 let links = Links::new().push(Link::self_link("/items/1"));
408 let r: ApiResponse<&str> = ApiResponse::builder("payload")
409 .meta(meta)
410 .links(links)
411 .build();
412 assert_eq!(r.data, "payload");
413 assert_eq!(r.meta.request_id.as_deref(), Some("r1"));
414 assert_eq!(r.meta.version.as_deref(), Some("2.0"));
415 assert_eq!(
416 r.links
417 .as_ref()
418 .unwrap()
419 .find("self")
420 .map(|l| l.href.as_str()),
421 Some("/items/1")
422 );
423 }
424
425 #[test]
426 fn api_response_composes_with_paginated_response() {
427 use crate::pagination::{PaginatedResponse, PaginationParams};
428 let params = PaginationParams::default();
429 let page = PaginatedResponse::new(vec![1i32, 2, 3], 10, ¶ms);
430 let r = ApiResponse::builder(page).build();
431 assert_eq!(r.data.total_count, 10);
432 }
433
434 // -----------------------------------------------------------------------
435 // Serde round-trips
436 // -----------------------------------------------------------------------
437
438 #[cfg(feature = "serde")]
439 #[test]
440 fn api_response_serde_round_trip_minimal() {
441 let r: ApiResponse<i32> = ApiResponse::builder(99).build();
442 let json = serde_json::to_value(&r).unwrap();
443 // links omitted when None
444 assert!(json.get("links").is_none());
445 assert_eq!(json["data"], 99);
446 let back: ApiResponse<i32> = serde_json::from_value(json).unwrap();
447 assert_eq!(back, r);
448 }
449
450 #[cfg(feature = "serde")]
451 #[test]
452 fn api_response_serde_round_trip_full() {
453 use crate::links::{Link, Links};
454 let meta = ResponseMeta::new().request_id("abc").version("1.0");
455 let links = Links::new().push(Link::self_link("/x"));
456 let r: ApiResponse<String> = ApiResponse::builder("hello".to_string())
457 .meta(meta)
458 .links(links)
459 .build();
460 let json = serde_json::to_value(&r).unwrap();
461 assert_eq!(json["data"], "hello");
462 assert_eq!(json["meta"]["request_id"], "abc");
463 // links::Links serializes as a transparent array
464 assert!(json["links"].is_array());
465 let back: ApiResponse<String> = serde_json::from_value(json).unwrap();
466 assert_eq!(back, r);
467 }
468
469 #[cfg(feature = "serde")]
470 #[test]
471 fn response_meta_omits_none_fields() {
472 let m = ResponseMeta::new().request_id("id1");
473 let json = serde_json::to_value(&m).unwrap();
474 assert!(json.get("timestamp").is_none());
475 assert!(json.get("version").is_none());
476 assert_eq!(json["request_id"], "id1");
477 }
478}