1use crate::endpoint;
2use secrecy::SecretString;
3use std::fmt;
4use url::Url;
5
6pub type Result<T> = core::result::Result<T, Error>;
8
9#[derive(Debug, thiserror::Error, PartialEq, Eq)]
10pub enum Error {
12 #[error("invalid Gingr subdomain: {value}")]
13 InvalidSubdomain {
15 value: String,
17 },
18 #[error("invalid Gingr base URL: {reason}")]
19 InvalidBaseUrl {
21 reason: String,
23 },
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Subdomain(String);
29
30impl Subdomain {
31 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 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)]
64pub struct BaseUrl(Url);
66
67impl BaseUrl {
68 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 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 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)]
130pub struct ApiKey(SecretString);
132
133impl ApiKey {
134 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)]
158pub struct Provider {
160 label: Option<String>,
161}
162
163impl Provider {
164 pub fn gingr() -> Self {
166 Self { label: None }
167 }
168
169 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)]
187pub struct Client {
189 base_url: BaseUrl,
190 api_key: ApiKey,
191 provider: Provider,
192}
193
194impl Client {
195 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 pub fn base_url(&self) -> &BaseUrl {
206 &self.base_url
207 }
208
209 pub fn api_key(&self) -> &ApiKey {
211 &self.api_key
212 }
213
214 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}