#[cfg(all(not(feature = "std"), feature = "alloc"))]
use alloc::string::String;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::common::Timestamp;
use crate::links::Links;
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ResponseMeta {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub request_id: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
#[cfg_attr(feature = "utoipa", schema(value_type = Option<String>, format = DateTime))]
pub timestamp: Option<Timestamp>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub version: Option<String>,
}
impl ResponseMeta {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn request_id(mut self, id: impl Into<String>) -> Self {
self.request_id = Some(id.into());
self
}
#[must_use]
pub fn timestamp(mut self, ts: Timestamp) -> Self {
self.timestamp = Some(ts);
self
}
#[must_use]
pub fn version(mut self, v: impl Into<String>) -> Self {
self.version = Some(v.into());
self
}
}
#[cfg(all(feature = "arbitrary", not(feature = "chrono")))]
impl<'a> arbitrary::Arbitrary<'a> for ResponseMeta {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
use arbitrary::Arbitrary;
Ok(Self {
request_id: Arbitrary::arbitrary(u)?,
timestamp: Arbitrary::arbitrary(u)?,
version: Arbitrary::arbitrary(u)?,
})
}
}
#[cfg(all(feature = "arbitrary", feature = "chrono"))]
impl<'a> arbitrary::Arbitrary<'a> for ResponseMeta {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
use arbitrary::Arbitrary;
use chrono::TimeZone as _;
let timestamp = if bool::arbitrary(u)? {
let secs = u.int_in_range(0i64..=32_503_680_000i64)?;
chrono::Utc.timestamp_opt(secs, 0).single()
} else {
None
};
Ok(Self {
request_id: Arbitrary::arbitrary(u)?,
timestamp,
version: Arbitrary::arbitrary(u)?,
})
}
}
#[cfg(all(feature = "proptest", not(feature = "chrono")))]
impl proptest::arbitrary::Arbitrary for ResponseMeta {
type Parameters = ();
type Strategy = proptest::strategy::BoxedStrategy<Self>;
fn arbitrary_with((): ()) -> Self::Strategy {
use proptest::prelude::*;
(
proptest::option::of(any::<String>()),
proptest::option::of(any::<String>()),
proptest::option::of(any::<String>()),
)
.prop_map(|(request_id, timestamp, version)| Self {
request_id,
timestamp,
version,
})
.boxed()
}
}
#[cfg(all(feature = "proptest", feature = "chrono"))]
impl proptest::arbitrary::Arbitrary for ResponseMeta {
type Parameters = ();
type Strategy = proptest::strategy::BoxedStrategy<Self>;
fn arbitrary_with((): ()) -> Self::Strategy {
use chrono::TimeZone as _;
use proptest::prelude::*;
(
proptest::option::of(any::<String>()),
proptest::option::of(0i64..=32_503_680_000i64),
proptest::option::of(any::<String>()),
)
.prop_map(|(request_id, ts_secs, version)| Self {
request_id,
timestamp: ts_secs.and_then(|s| chrono::Utc.timestamp_opt(s, 0).single()),
version,
})
.boxed()
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct ApiResponse<T> {
pub data: T,
pub meta: ResponseMeta,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub links: Option<Links>,
}
pub struct ApiResponseBuilder<T> {
data: T,
meta: ResponseMeta,
links: Option<Links>,
}
impl<T> ApiResponseBuilder<T> {
#[must_use]
pub fn meta(mut self, meta: ResponseMeta) -> Self {
self.meta = meta;
self
}
#[must_use]
pub fn links(mut self, links: Links) -> Self {
self.links = Some(links);
self
}
#[must_use]
pub fn build(self) -> ApiResponse<T> {
ApiResponse {
data: self.data,
meta: self.meta,
links: self.links,
}
}
}
impl<T> ApiResponse<T> {
#[must_use]
pub fn builder(data: T) -> ApiResponseBuilder<T> {
ApiResponseBuilder {
data,
meta: ResponseMeta::default(),
links: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn response_meta_new_is_empty() {
let m = ResponseMeta::new();
assert!(m.request_id.is_none());
assert!(m.timestamp.is_none());
assert!(m.version.is_none());
}
#[test]
fn response_meta_builder_chain() {
let m = ResponseMeta::new().request_id("req-001").version("1.4.0");
assert_eq!(m.request_id.as_deref(), Some("req-001"));
assert_eq!(m.version.as_deref(), Some("1.4.0"));
}
#[cfg(feature = "chrono")]
#[test]
fn response_meta_timestamp_builder() {
use chrono::Utc;
let ts = Utc::now();
let m = ResponseMeta::new().timestamp(ts);
assert!(m.timestamp.is_some());
}
#[test]
fn api_response_builder_minimal() {
let r: ApiResponse<i32> = ApiResponse::builder(42).build();
assert_eq!(r.data, 42);
assert!(r.links.is_none());
assert!(r.meta.request_id.is_none());
}
#[test]
fn api_response_builder_with_meta_and_links() {
use crate::links::{Link, Links};
let meta = ResponseMeta::new().request_id("r1").version("2.0");
let links = Links::new().push(Link::self_link("/items/1"));
let r: ApiResponse<&str> = ApiResponse::builder("payload")
.meta(meta)
.links(links)
.build();
assert_eq!(r.data, "payload");
assert_eq!(r.meta.request_id.as_deref(), Some("r1"));
assert_eq!(r.meta.version.as_deref(), Some("2.0"));
assert_eq!(
r.links
.as_ref()
.unwrap()
.find("self")
.map(|l| l.href.as_str()),
Some("/items/1")
);
}
#[test]
fn api_response_composes_with_paginated_response() {
use crate::pagination::{PaginatedResponse, PaginationParams};
let params = PaginationParams::default();
let page = PaginatedResponse::new(vec![1i32, 2, 3], 10, ¶ms);
let r = ApiResponse::builder(page).build();
assert_eq!(r.data.total_count, 10);
}
#[cfg(feature = "serde")]
#[test]
fn api_response_serde_round_trip_minimal() {
let r: ApiResponse<i32> = ApiResponse::builder(99).build();
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("links").is_none());
assert_eq!(json["data"], 99);
let back: ApiResponse<i32> = serde_json::from_value(json).unwrap();
assert_eq!(back, r);
}
#[cfg(feature = "serde")]
#[test]
fn api_response_serde_round_trip_full() {
use crate::links::{Link, Links};
let meta = ResponseMeta::new().request_id("abc").version("1.0");
let links = Links::new().push(Link::self_link("/x"));
let r: ApiResponse<String> = ApiResponse::builder("hello".to_string())
.meta(meta)
.links(links)
.build();
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["data"], "hello");
assert_eq!(json["meta"]["request_id"], "abc");
assert!(json["links"].is_array());
let back: ApiResponse<String> = serde_json::from_value(json).unwrap();
assert_eq!(back, r);
}
#[cfg(feature = "serde")]
#[test]
fn response_meta_omits_none_fields() {
let m = ResponseMeta::new().request_id("id1");
let json = serde_json::to_value(&m).unwrap();
assert!(json.get("timestamp").is_none());
assert!(json.get("version").is_none());
assert_eq!(json["request_id"], "id1");
}
}