Skip to main content

gingr/endpoint/
mod.rs

1//! Secret-free request builders for Gingr endpoint contracts.
2//!
3//! Endpoint structs describe provider requests without performing network I/O.
4//! Callers can inspect the path and parameters, attach source provenance, and only
5//! then hand the request to transport code with credentials.
6//!
7//! ```rust
8//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
9//! use gingr::endpoint::{Date, DateRange, LocationId, Request, Reservations};
10//!
11//! let range = DateRange::new(Date::parse("2026-06-18")?, Date::parse("2026-06-19")?)?;
12//! let request = Reservations::for_range(range)
13//!     .location(LocationId::new(170))
14//!     .build();
15//! let parts = request.request_parts();
16//!
17//! assert_eq!(parts.path(), "/api/v1/reservations");
18//! assert!(parts.form_pairs().iter().any(|(key, value)| {
19//!     key == "location_id" && value == "170"
20//! }));
21//! assert!(parts.form_pairs().iter().any(|(key, value)| {
22//!     key == "start_date" && value == "2026-06-18"
23//! }));
24//! # Ok(())
25//! # }
26//! ```
27
28/// Gingr catalog endpoint boundary with provider parameters kept explicit.
29pub mod catalog;
30/// Gingr commerce retail endpoint boundary with provider parameters kept explicit.
31pub mod commerce_retail;
32/// Gingr labor ops endpoint boundary with provider parameters kept explicit.
33pub mod labor_ops;
34/// Gingr owners animals endpoint boundary with provider parameters kept explicit.
35pub mod owners_animals;
36/// Gingr reference data endpoint boundary with provider parameters kept explicit.
37pub mod reference_data;
38/// Gingr report cards files endpoint boundary with provider parameters kept explicit.
39pub mod report_cards_files;
40/// Gingr reservations endpoint boundary with provider parameters kept explicit.
41pub mod reservations;
42
43pub use reservations::Reservations;
44
45use crate::transport;
46use chrono::NaiveDate;
47use std::fmt;
48
49/// Result type returned by fallible endpoint operations.
50pub type Result<T> = core::result::Result<T, Error>;
51
52#[derive(Debug, thiserror::Error, PartialEq, Eq)]
53/// Errors raised when provider values cannot safely cross this Gingr boundary.
54pub enum Error {
55    #[error("invalid Gingr date {value:?}: expected YYYY-MM-DD")]
56    /// Provider date did not match the endpoint date format.
57    InvalidDate {
58        /// Original provider/caller value rejected before it could become a typed boundary value.
59        value: String,
60    },
61    #[error("invalid Gingr ISO date {value:?}: expected YYYY-MM-DD")]
62    /// Provider ISO date could not be parsed for a Gingr request.
63    InvalidIsoDate {
64        /// Original provider/caller value rejected before it could become a typed boundary value.
65        value: String,
66    },
67    #[error("invalid Gingr date range: start {start} must not be after end {end}")]
68    /// Start date is after end date in a Gingr request.
69    ReversedDateRange {
70        /// Start attached to this Gingr error or DTO.
71        start: Date,
72        /// End attached to this Gingr error or DTO.
73        end: Date,
74    },
75    #[error("invalid Gingr date range: reservations range may not exceed 30 days")]
76    /// Date range exceeds the maximum Gingr endpoint window.
77    DateRangeTooLong,
78    #[error("invalid Gingr positive integer {value}: expected non-zero value")]
79    /// Provider integer wrapper rejected zero or an invalid value.
80    InvalidPositiveInteger {
81        /// Original provider/caller value rejected before it could become a typed boundary value.
82        value: u64,
83    },
84    #[error("invalid Gingr text value: expected non-empty text")]
85    /// Required text parameter was empty after trimming.
86    EmptyText,
87    #[error("missing required Gingr endpoint parameter {parameter}")]
88    /// Typed request builder is missing a required Gingr parameter.
89    MissingRequiredParameter {
90        /// Name of the provider parameter missing from a typed endpoint builder.
91        parameter: &'static str,
92    },
93    #[error("invalid Gingr legacy date boundary for {date}: {boundary}")]
94    /// Request asks Gingr for data before the endpoint-supported cutover date.
95    LegacyDateBoundary {
96        /// Date carried with this error or record.
97        date: String,
98        /// Boundary carried with this error or record.
99        boundary: &'static str,
100    },
101    #[error("invalid Gingr pagination: {reason}")]
102    /// Pagination parameters would produce an invalid Gingr request.
103    InvalidPagination {
104        /// Boundary-level reason explaining why this provider request or parse step was rejected.
105        reason: &'static str,
106    },
107    #[error("invalid Gingr subscription bill day {value}: expected 1..=31")]
108    /// Subscription bill day was outside Gingr-supported month bounds.
109    InvalidBillDayOfMonth {
110        /// Original provider/caller value rejected before it could become a typed boundary value.
111        value: u8,
112    },
113}
114
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116/// HTTP methods used by typed Gingr endpoint descriptors.
117pub enum Method {
118    /// Gingr endpoint uses an HTTP GET request.
119    Get,
120    /// Gingr endpoint uses an HTTP POST request.
121    Post,
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
125/// Gingr endpoint date formatted as `YYYY-MM-DD` for provider query/form parameters.
126pub struct Date(NaiveDate);
127
128impl Date {
129    /// Parses provider-sourced text into this boundary type without promoting it to an NVA domain fact.
130    pub fn parse(raw: impl AsRef<str>) -> Result<Self> {
131        let raw = raw.as_ref();
132        NaiveDate::parse_from_str(raw, "%Y-%m-%d")
133            .map(Self)
134            .map_err(|_| Error::InvalidDate {
135                value: raw.to_owned(),
136            })
137    }
138
139    /// Returns the parsed calendar date used by Gingr endpoint filters.
140    pub const fn inner(self) -> NaiveDate {
141        self.0
142    }
143}
144
145impl fmt::Display for Date {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(formatter, "{}", self.0.format("%Y-%m-%d"))
148    }
149}
150
151#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
152/// Gingr ISO-style date filter formatted as `YYYY-MM-DD` where endpoints use nested params.
153pub struct IsoDate(NaiveDate);
154
155impl IsoDate {
156    /// Parses provider-sourced text into this boundary type without promoting it to an NVA domain fact.
157    pub fn parse(raw: impl AsRef<str>) -> Result<Self> {
158        let raw = raw.as_ref();
159        NaiveDate::parse_from_str(raw, "%Y-%m-%d")
160            .map(Self)
161            .map_err(|_| Error::InvalidIsoDate {
162                value: raw.to_owned(),
163            })
164    }
165}
166
167impl fmt::Display for IsoDate {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        write!(formatter, "{}", self.0.format("%Y-%m-%d"))
170    }
171}
172
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174/// Inclusive Gingr date window, capped at the request range this crate explicitly validates.
175pub struct DateRange {
176    start: Date,
177    end: Date,
178}
179
180impl DateRange {
181    /// Constructs this typed Gingr boundary value after the caller has chosen the provider input to trust.
182    pub fn new(start: Date, end: Date) -> Result<Self> {
183        if start > end {
184            return Err(Error::ReversedDateRange { start, end });
185        }
186        if (end.inner() - start.inner()).num_days() > 29 {
187            return Err(Error::DateRangeTooLong);
188        }
189        Ok(Self { start, end })
190    }
191
192    /// Returns the inclusive start date sent to Gingr.
193    pub const fn start(self) -> Date {
194        self.start
195    }
196
197    /// Returns the inclusive end date sent to Gingr.
198    pub const fn end(self) -> Date {
199        self.end
200    }
201}
202
203macro_rules! id_type {
204    ($name:ident) => {
205        #[derive(
206            Clone,
207            Copy,
208            Debug,
209            PartialEq,
210            Eq,
211            PartialOrd,
212            Ord,
213            Hash,
214            serde::Deserialize,
215            serde::Serialize,
216        )]
217        #[serde(transparent)]
218        /// Newtype identifier shared by Gingr endpoints that pass numeric provider IDs.
219        pub struct $name(u64);
220
221        impl $name {
222            /// Wraps an already-observed Gingr identifier without claiming anything beyond provider provenance.
223            pub const fn new(value: u64) -> Self {
224                Self(value)
225            }
226
227            /// Returns the provider numeric identifier carried by this wrapper.
228            pub const fn get(self) -> u64 {
229                self.0
230            }
231        }
232
233        impl fmt::Display for $name {
234            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235                write!(formatter, "{}", self.0)
236            }
237        }
238    };
239}
240
241id_type!(AnimalId);
242id_type!(OwnerId);
243id_type!(ReservationId);
244id_type!(LocationId);
245id_type!(SpeciesId);
246id_type!(FormId);
247id_type!(ReferenceId);
248
249#[derive(Clone, Copy, Debug, PartialEq, Eq)]
250/// Static Gingr API path emitted by an endpoint descriptor.
251pub struct Path(&'static str);
252
253impl Path {
254    /// Wraps an already-observed Gingr identifier without claiming anything beyond provider provenance.
255    pub const fn new(value: &'static str) -> Self {
256        Self(value)
257    }
258
259    /// Returns the validated endpoint path segment.
260    pub const fn as_str(self) -> &'static str {
261        self.0
262    }
263}
264
265impl PartialEq<&str> for Path {
266    fn eq(&self, other: &&str) -> bool {
267        self.0 == *other
268    }
269}
270
271impl fmt::Display for Path {
272    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273        formatter.write_str(self.0)
274    }
275}
276
277#[derive(Clone, Copy, Debug, PartialEq, Eq)]
278/// Positive provider record limit used to bound Gingr list/search responses.
279pub struct Limit(u64);
280
281impl Limit {
282    /// Constructs this typed Gingr boundary value after the caller has chosen the provider input to trust.
283    pub fn new(value: u64) -> Result<Self> {
284        if value == 0 {
285            return Err(Error::InvalidPositiveInteger { value });
286        }
287        Ok(Self(value))
288    }
289}
290
291impl fmt::Display for Limit {
292    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293        write!(formatter, "{}", self.0)
294    }
295}
296
297/// Defines the behavior required from a request participant in the endpoint workflow.
298pub trait Request {
299    /// Describes the provider wire contract for this Gingr request.
300    fn method(&self) -> Method;
301    /// Describes the provider wire contract for this Gingr request.
302    fn path(&self) -> &'static str;
303    /// Describes the provider wire contract for this Gingr request.
304    fn parameters(&self) -> Vec<(String, String)>;
305    /// Describes the provider wire contract for this Gingr request.
306    fn sensitive_parameter_names(&self) -> &'static [&'static str] {
307        &[]
308    }
309
310    /// Describes the provider wire contract for this Gingr request.
311    fn request_parts(&self) -> transport::RequestParts {
312        transport::RequestParts::builder()
313            .method(self.method())
314            .path(Path::new(self.path()))
315            .parameters(self.parameters())
316            .sensitive_parameter_names(self.sensitive_parameter_names())
317            .build()
318    }
319}
320
321pub(crate) fn non_empty_text(value: impl Into<String>) -> Result<String> {
322    let value = value.into().trim().to_owned();
323    if value.is_empty() {
324        return Err(Error::EmptyText);
325    }
326    Ok(value)
327}