Skip to main content

gingr/
config.rs

1use crate::endpoint;
2use secrecy::SecretString;
3use std::fmt;
4use url::Url;
5
6/// Result type returned by fallible config operations.
7pub type Result<T> = core::result::Result<T, Error>;
8
9#[derive(Debug, thiserror::Error, PartialEq, Eq)]
10/// Errors raised when Gingr account settings are empty, malformed, or unsafe to send to the provider.
11pub enum Error {
12    #[error("invalid Gingr subdomain: {value}")]
13    /// Subdomain was empty or contained characters Gingr tenant hosts cannot use.
14    InvalidSubdomain {
15        /// Raw subdomain supplied by config or a fixture; keep it visible so setup issues can be corrected.
16        value: String,
17    },
18    #[error("invalid Gingr base URL: {reason}")]
19    /// Base URL was not a valid HTTPS Gingr endpoint.
20    InvalidBaseUrl {
21        /// Reason the URL was rejected before any Gingr request could be built from it.
22        reason: String,
23    },
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27/// Validated Gingr tenant subdomain, without protocol or host suffix.
28pub struct Subdomain(String);
29
30impl Subdomain {
31    /// Validates the Gingr tenant segment used to route API calls for one resort account.
32    pub fn parse(raw: impl AsRef<str>) -> Result<Self> {
33        let value = raw.as_ref();
34        let valid = !value.is_empty()
35            && value.len() <= 63
36            && value
37                .bytes()
38                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
39            && !value.starts_with('-')
40            && !value.ends_with('-');
41
42        if valid {
43            Ok(Self(value.to_owned()))
44        } else {
45            Err(Error::InvalidSubdomain {
46                value: value.to_owned(),
47            })
48        }
49    }
50
51    /// Returns the normalized provider or storage string slice.
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55}
56
57impl fmt::Display for Subdomain {
58    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59        formatter.write_str(&self.0)
60    }
61}
62
63#[derive(Clone, PartialEq, Eq)]
64/// Canonical Gingr API base URL with HTTPS and no trailing slash.
65pub struct BaseUrl(Url);
66
67impl BaseUrl {
68    /// Constructs the canonical Gingr base URL for a tenant subdomain.
69    pub fn for_subdomain(subdomain: &Subdomain) -> Self {
70        let raw = format!("https://{}.gingrapp.com", subdomain.as_str());
71        Self(Url::parse(&raw).expect("constructed Gingr URL is valid"))
72    }
73
74    /// Validates the HTTPS Gingr app URL used to reach one provider account.
75    pub fn parse(raw: impl AsRef<str>) -> Result<Self> {
76        let raw = raw.as_ref();
77
78        let url = Url::parse(raw).map_err(|error| Error::InvalidBaseUrl {
79            reason: error.to_string(),
80        })?;
81        if url.scheme() != "https" {
82            return Err(Error::InvalidBaseUrl {
83                reason: "Gingr API base URL must use https".to_owned(),
84            });
85        }
86        if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
87            return Err(Error::InvalidBaseUrl {
88                reason: "Gingr API base URL must not include path, query, or fragment".to_owned(),
89            });
90        }
91        let host = url.host_str().unwrap_or_default();
92        let Some(subdomain) = host.strip_suffix(".gingrapp.com") else {
93            return Err(Error::InvalidBaseUrl {
94                reason: "host must be a gingrapp.com subdomain".to_owned(),
95            });
96        };
97        Subdomain::parse(subdomain)?;
98        Ok(Self(url))
99    }
100
101    /// Returns the normalized provider or storage string slice.
102    pub fn as_str(&self) -> &str {
103        self.0.as_str().trim_end_matches('/')
104    }
105
106    pub(crate) fn join_path(
107        &self,
108        path: endpoint::Path,
109    ) -> core::result::Result<Url, url::ParseError> {
110        self.0.join(path.as_str().trim_start_matches('/'))
111    }
112}
113
114impl fmt::Debug for BaseUrl {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter
117            .debug_tuple("BaseUrl")
118            .field(&self.as_str())
119            .finish()
120    }
121}
122
123impl fmt::Display for BaseUrl {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        formatter.write_str(self.as_str())
126    }
127}
128
129#[derive(Clone)]
130/// Secret Gingr API key kept out of debug output and log-safe request views.
131pub struct ApiKey(SecretString);
132
133impl ApiKey {
134    /// Wraps the shared Gingr webhook secret without exposing it in debug output.
135    pub fn from_secret(raw: impl Into<String>) -> Self {
136        Self(SecretString::new(raw.into()))
137    }
138
139    pub(crate) fn expose_for_transport(&self) -> &str {
140        use secrecy::ExposeSecret;
141        self.0.expose_secret()
142    }
143}
144
145impl fmt::Debug for ApiKey {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        formatter.write_str("ApiKey(<redacted>)")
148    }
149}
150
151impl fmt::Display for ApiKey {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        formatter.write_str("<redacted>")
154    }
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
158/// Provider label attached to outbound Gingr requests and diagnostics.
159pub struct Provider {
160    label: Option<String>,
161}
162
163impl Provider {
164    /// Identifies the generic Gingr provider label.
165    pub fn gingr() -> Self {
166        Self { label: None }
167    }
168
169    /// Identifies a labeled Gingr App provider installation.
170    pub fn gingr_app(label: impl Into<String>) -> Self {
171        Self {
172            label: Some(label.into()),
173        }
174    }
175}
176
177impl fmt::Display for Provider {
178    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match &self.label {
180            Some(label) => write!(formatter, "Gingr({label})"),
181            None => formatter.write_str("Gingr"),
182        }
183    }
184}
185
186#[derive(Clone)]
187/// Gingr client configuration bundle shared by endpoint builders and transport.
188pub struct Client {
189    base_url: BaseUrl,
190    api_key: ApiKey,
191    provider: Provider,
192}
193
194impl Client {
195    /// Bundles the validated Gingr URL and secret key used to capture or send provider requests.
196    pub fn new(base_url: BaseUrl, api_key: ApiKey) -> Self {
197        Self {
198            base_url,
199            api_key,
200            provider: Provider::gingr(),
201        }
202    }
203
204    /// Returns the Gingr API base URL used by the client.
205    pub fn base_url(&self) -> &BaseUrl {
206        &self.base_url
207    }
208
209    /// Returns the secret Gingr API key wrapper.
210    pub fn api_key(&self) -> &ApiKey {
211        &self.api_key
212    }
213
214    /// Returns the provider label attached to outbound Gingr requests.
215    pub fn provider(&self) -> &Provider {
216        &self.provider
217    }
218}
219
220impl fmt::Debug for Client {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        formatter
223            .debug_struct("Client")
224            .field("base_url", &self.base_url)
225            .field("api_key", &"<redacted>")
226            .field("provider", &self.provider)
227            .finish()
228    }
229}
230
231impl fmt::Display for Client {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(
234            formatter,
235            "Gingr client config {{ base_url: {}, api_key: <redacted>, provider: {} }}",
236            self.base_url, self.provider
237        )
238    }
239}