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}