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 provider values cannot safely cross this Gingr boundary.
11pub enum Error {
12    #[error("invalid Gingr subdomain: {value}")]
13    /// Subdomain was empty or contained characters Gingr tenant hosts cannot use.
14    InvalidSubdomain {
15        /// Original provider/caller value rejected before it could become a typed boundary value.
16        value: String,
17    },
18    #[error("invalid Gingr base URL: {reason}")]
19    /// Base URL was not a valid HTTPS Gingr endpoint.
20    InvalidBaseUrl {
21        /// Boundary-level reason explaining why this provider request or parse step was rejected.
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    /// Parses provider-sourced text into this boundary type without promoting it to an NVA domain fact.
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    /// Parses provider-sourced text into this boundary type without promoting it to an NVA domain fact.
75    pub fn parse(raw: impl AsRef<str>) -> Result<Self> {
76        let raw = raw.as_ref();
77        let url = Url::parse(raw).map_err(|error| Error::InvalidBaseUrl {
78            reason: error.to_string(),
79        })?;
80        if url.scheme() != "https" {
81            return Err(Error::InvalidBaseUrl {
82                reason: "Gingr API base URL must use https".to_owned(),
83            });
84        }
85        if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
86            return Err(Error::InvalidBaseUrl {
87                reason: "Gingr API base URL must not include path, query, or fragment".to_owned(),
88            });
89        }
90        let host = url.host_str().unwrap_or_default();
91        let Some(subdomain) = host.strip_suffix(".gingrapp.com") else {
92            return Err(Error::InvalidBaseUrl {
93                reason: "host must be a gingrapp.com subdomain".to_owned(),
94            });
95        };
96        Subdomain::parse(subdomain)?;
97        Ok(Self(url))
98    }
99
100    /// Returns the normalized provider or storage string slice.
101    pub fn as_str(&self) -> &str {
102        self.0.as_str().trim_end_matches('/')
103    }
104
105    pub(crate) fn join_path(
106        &self,
107        path: endpoint::Path,
108    ) -> core::result::Result<Url, url::ParseError> {
109        self.0.join(path.as_str().trim_start_matches('/'))
110    }
111}
112
113impl fmt::Debug for BaseUrl {
114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115        formatter
116            .debug_tuple("BaseUrl")
117            .field(&self.as_str())
118            .finish()
119    }
120}
121
122impl fmt::Display for BaseUrl {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        formatter.write_str(self.as_str())
125    }
126}
127
128#[derive(Clone)]
129/// Secret Gingr API key kept out of debug output and log-safe request views.
130pub struct ApiKey(SecretString);
131
132impl ApiKey {
133    /// Wraps the shared Gingr webhook secret without exposing it in debug output.
134    pub fn from_secret(raw: impl Into<String>) -> Self {
135        Self(SecretString::new(raw.into()))
136    }
137
138    pub(crate) fn expose_for_transport(&self) -> &str {
139        use secrecy::ExposeSecret;
140        self.0.expose_secret()
141    }
142}
143
144impl fmt::Debug for ApiKey {
145    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146        formatter.write_str("ApiKey(<redacted>)")
147    }
148}
149
150impl fmt::Display for ApiKey {
151    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        formatter.write_str("<redacted>")
153    }
154}
155
156#[derive(Clone, Debug, PartialEq, Eq)]
157/// Provider label attached to outbound Gingr requests and diagnostics.
158pub struct Provider {
159    label: Option<String>,
160}
161
162impl Provider {
163    /// Identifies the generic Gingr provider label.
164    pub fn gingr() -> Self {
165        Self { label: None }
166    }
167
168    /// Identifies a labeled Gingr App provider installation.
169    pub fn gingr_app(label: impl Into<String>) -> Self {
170        Self {
171            label: Some(label.into()),
172        }
173    }
174}
175
176impl fmt::Display for Provider {
177    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match &self.label {
179            Some(label) => write!(formatter, "Gingr({label})"),
180            None => formatter.write_str("Gingr"),
181        }
182    }
183}
184
185#[derive(Clone)]
186/// Gingr client configuration bundle shared by endpoint builders and transport.
187pub struct Client {
188    base_url: BaseUrl,
189    api_key: ApiKey,
190    provider: Provider,
191}
192
193impl Client {
194    /// Constructs this typed Gingr boundary value after the caller has chosen the provider input to trust.
195    pub fn new(base_url: BaseUrl, api_key: ApiKey) -> Self {
196        Self {
197            base_url,
198            api_key,
199            provider: Provider::gingr(),
200        }
201    }
202
203    /// Returns the Gingr API base URL used by the client.
204    pub fn base_url(&self) -> &BaseUrl {
205        &self.base_url
206    }
207
208    /// Returns the secret Gingr API key wrapper.
209    pub fn api_key(&self) -> &ApiKey {
210        &self.api_key
211    }
212
213    /// Returns the provider label attached to outbound Gingr requests.
214    pub fn provider(&self) -> &Provider {
215        &self.provider
216    }
217}
218
219impl fmt::Debug for Client {
220    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
221        formatter
222            .debug_struct("Client")
223            .field("base_url", &self.base_url)
224            .field("api_key", &"<redacted>")
225            .field("provider", &self.provider)
226            .finish()
227    }
228}
229
230impl fmt::Display for Client {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        write!(
233            formatter,
234            "Gingr client config {{ base_url: {}, api_key: <redacted>, provider: {} }}",
235            self.base_url, self.provider
236        )
237    }
238}