openapp_sdk_common/
token.rs1use thiserror::Error;
9use url::Url;
10
11pub const API_KEY_SEPARATOR: &str = "_openapp_";
13
14#[derive(Debug, Error, Clone, PartialEq, Eq)]
16pub enum TokenFormatError {
17 #[error("token is empty")]
18 Empty,
19
20 #[error("token does not contain the `{API_KEY_SEPARATOR}` separator")]
21 MissingSeparator,
22
23 #[error("token secret is empty")]
24 EmptySecret,
25
26 #[error("token base URL `{0}` is not a valid absolute URL")]
27 InvalidBaseUrl(String),
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ApiKey {
33 raw: String,
34 base_url: Url,
35 secret: String,
36}
37
38impl ApiKey {
39 pub fn parse(token: impl Into<String>) -> Result<Self, TokenFormatError> {
45 let raw = token.into();
46 let trimmed = raw.trim();
47 if trimmed.is_empty() {
48 return Err(TokenFormatError::Empty);
49 }
50
51 let (base_url_str, secret) = trimmed
52 .split_once(API_KEY_SEPARATOR)
53 .ok_or(TokenFormatError::MissingSeparator)?;
54
55 if secret.is_empty() {
56 return Err(TokenFormatError::EmptySecret);
57 }
58
59 let base_url = Url::parse(base_url_str)
60 .map_err(|_| TokenFormatError::InvalidBaseUrl(base_url_str.to_string()))?;
61
62 Ok(Self {
63 raw: trimmed.to_string(),
64 base_url,
65 secret: secret.to_string(),
66 })
67 }
68
69 #[must_use]
71 pub fn base_url(&self) -> &Url {
72 &self.base_url
73 }
74
75 #[must_use]
77 pub fn secret(&self) -> &str {
78 &self.secret
79 }
80
81 #[must_use]
83 pub fn as_bearer(&self) -> &str {
84 &self.raw
85 }
86}
87
88impl std::fmt::Display for ApiKey {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 let suffix = if self.secret.len() > 6 {
92 format!("…{}", &self.secret[self.secret.len() - 6..])
93 } else {
94 "…".to_string()
95 };
96 write!(f, "ApiKey({} {})", self.base_url, suffix)
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn parses_valid_token() {
106 let tok = ApiKey::parse("https://api.openapp.house/api/v1_openapp_SECRET").unwrap();
107 assert_eq!(tok.base_url().as_str(), "https://api.openapp.house/api/v1");
108 assert_eq!(tok.secret(), "SECRET");
109 assert_eq!(
110 tok.as_bearer(),
111 "https://api.openapp.house/api/v1_openapp_SECRET"
112 );
113 }
114
115 #[test]
116 fn rejects_empty() {
117 assert_eq!(ApiKey::parse("").unwrap_err(), TokenFormatError::Empty);
118 assert_eq!(ApiKey::parse(" ").unwrap_err(), TokenFormatError::Empty);
119 }
120
121 #[test]
122 fn rejects_missing_separator() {
123 assert_eq!(
124 ApiKey::parse("https://api.openapp.house/api/v1").unwrap_err(),
125 TokenFormatError::MissingSeparator
126 );
127 }
128
129 #[test]
130 fn rejects_empty_secret() {
131 assert_eq!(
132 ApiKey::parse("https://api.openapp.house/api/v1_openapp_").unwrap_err(),
133 TokenFormatError::EmptySecret
134 );
135 }
136
137 #[test]
138 fn rejects_invalid_base_url() {
139 let err = ApiKey::parse("not a url_openapp_SECRET").unwrap_err();
140 assert!(matches!(err, TokenFormatError::InvalidBaseUrl(_)));
141 }
142
143 #[test]
144 fn display_hides_secret() {
145 let tok = ApiKey::parse("https://api.openapp.house/api/v1_openapp_supersecret").unwrap();
146 let s = format!("{tok}");
147 assert!(!s.contains("supersecret"), "display leaked secret: {s}");
148 }
149}