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 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 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)]
129pub struct ApiKey(SecretString);
131
132impl ApiKey {
133 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)]
157pub struct Provider {
159 label: Option<String>,
160}
161
162impl Provider {
163 pub fn gingr() -> Self {
165 Self { label: None }
166 }
167
168 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)]
186pub struct Client {
188 base_url: BaseUrl,
189 api_key: ApiKey,
190 provider: Provider,
191}
192
193impl Client {
194 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 pub fn base_url(&self) -> &BaseUrl {
205 &self.base_url
206 }
207
208 pub fn api_key(&self) -> &ApiKey {
210 &self.api_key
211 }
212
213 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}