use http::HeaderName;
use secrecy::{ExposeSecret, SecretString};
use serde_json::{Map, Value};
use std::sync::Arc;
use std::time::Instant;
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum ApiKeyAttributeError {
#[error("HTTP header name is invalid: {0}")]
InvalidHttpHeaderName(&'static str),
#[error("HTTP header scheme is invalid: {0}")]
InvalidHttpHeaderScheme(&'static str),
}
#[derive(Clone, Debug)]
pub struct ApiKeyAttributes {
attributes: Arc<Map<String, Value>>,
}
impl ApiKeyAttributes {
const HTTP_HEADER_NAME_ATTRIBUTE: &str = "http.header_name";
const HTTP_HEADER_SCHEME_ATTRIBUTE: &str = "http.header_scheme";
#[must_use]
pub fn new() -> ApiKeyAttributes {
Self {
attributes: Arc::new(Map::new()),
}
}
pub fn from_map(attributes: Map<String, Value>) -> Result<Self, ApiKeyAttributeError> {
let mut a = Self::new();
for (key, value) in attributes {
a = a.with_attribute(key, value)?;
}
Ok(a)
}
pub fn with_attribute(
mut self,
name: impl Into<String>,
value: impl Into<Value>,
) -> Result<Self, ApiKeyAttributeError> {
let name: String = name.into();
let value: Value = value.into();
match name.as_str() {
Self::HTTP_HEADER_NAME_ATTRIBUTE => {
if let Value::String(value) = value {
self.with_http_header_name_attribute(value)
} else {
Err(ApiKeyAttributeError::InvalidHttpHeaderName(
"String value expected",
))
}
}
Self::HTTP_HEADER_SCHEME_ATTRIBUTE => {
if let Value::String(value) = value {
self.with_http_header_scheme_attribute(value)
} else {
Err(ApiKeyAttributeError::InvalidHttpHeaderScheme(
"String value expected",
))
}
}
_ => {
let mut attributes = Arc::unwrap_or_clone(self.attributes);
_ = attributes.insert(name, value);
self.attributes = Arc::new(attributes);
Ok(self)
}
}
}
pub fn with_http_header_name_attribute(
mut self,
header_name: impl Into<String>,
) -> Result<Self, ApiKeyAttributeError> {
let header_name: String = header_name.into();
Self::validate_http_header_name_attribute(&header_name)?;
let mut attributes = Arc::unwrap_or_clone(self.attributes);
_ = attributes.insert(
Self::HTTP_HEADER_NAME_ATTRIBUTE.into(),
Value::String(header_name),
);
self.attributes = Arc::new(attributes);
Ok(self)
}
pub fn with_http_header_scheme_attribute(
mut self,
header_scheme: impl Into<String>,
) -> Result<Self, ApiKeyAttributeError> {
let header_scheme: String = header_scheme.into();
Self::validate_http_header_scheme_attribute(&header_scheme)?;
let mut attributes = Arc::unwrap_or_clone(self.attributes);
_ = attributes.insert(
Self::HTTP_HEADER_SCHEME_ATTRIBUTE.into(),
Value::String(header_scheme),
);
self.attributes = Arc::new(attributes);
Ok(self)
}
fn validate_http_header_name_attribute(header_name: &str) -> Result<(), ApiKeyAttributeError> {
HeaderName::from_bytes(header_name.as_bytes())
.map(|_| ())
.map_err(|_| {
ApiKeyAttributeError::InvalidHttpHeaderName("Header name could not be parsed")
})
}
fn validate_http_header_scheme_attribute(
header_scheme: &str,
) -> Result<(), ApiKeyAttributeError> {
if header_scheme.is_empty()
|| !header_scheme.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
{
return Err(ApiKeyAttributeError::InvalidHttpHeaderScheme(
"Header scheme could not be parsed",
));
}
Ok(())
}
}
impl Default for ApiKeyAttributes {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct ApiKey {
value: Arc<SecretString>,
attributes: Option<Arc<Map<String, Value>>>,
expires_on: Option<Instant>,
}
impl ApiKey {
#[must_use]
pub fn new(value: impl Into<SecretString>) -> Self {
Self {
value: Arc::new(value.into()),
attributes: None,
expires_on: None,
}
}
#[must_use]
pub fn with_attributes(mut self, attributes: ApiKeyAttributes) -> Self {
self.attributes = Some(attributes.attributes.clone());
self
}
#[must_use]
pub const fn with_expiry(mut self, expires_on: Instant) -> Self {
self.expires_on = Some(expires_on);
self
}
#[must_use]
pub fn expose_value(&self) -> &str {
self.value.expose_secret()
}
#[must_use]
pub fn get_attributes(&self) -> Option<&Map<String, Value>> {
self.attributes.as_deref()
}
#[must_use]
pub const fn get_expires_on(&self) -> Option<Instant> {
self.expires_on
}
#[must_use]
pub fn get_http_header_name_attribute(&self) -> Option<&str> {
if let Some(header_value) = self
.attributes
.as_ref()
.and_then(|v| v.get(ApiKeyAttributes::HTTP_HEADER_NAME_ATTRIBUTE))
&& let Value::String(header_value) = header_value
{
return Some(header_value.as_str());
}
None
}
#[must_use]
pub fn get_http_header_scheme_attribute(&self) -> Option<&str> {
if let Some(scheme_value) = self
.attributes
.as_ref()
.and_then(|v| v.get(ApiKeyAttributes::HTTP_HEADER_SCHEME_ATTRIBUTE))
&& let Value::String(scheme_value) = scheme_value
{
return Some(scheme_value.as_str());
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_header_name_builder_accepts_valid_value() {
let key = ApiKey::new("secret").with_attributes(
ApiKeyAttributes::new()
.with_http_header_name_attribute("x-api-key")
.expect("header name is valid"),
);
assert_eq!(key.get_http_header_name_attribute(), Some("x-api-key"));
}
#[test]
fn http_header_scheme_builder_accepts_valid_value() {
let key = ApiKey::new("secret").with_attributes(
ApiKeyAttributes::new()
.with_http_header_scheme_attribute("ApiKey")
.expect("header scheme is valid"),
);
assert_eq!(key.get_http_header_scheme_attribute(), Some("ApiKey"));
}
#[test]
fn http_attribute_builders_reject_malformed_values() {
for header_name in ["", "invalid header", "x-api-key\n"] {
assert!(matches!(
ApiKeyAttributes::new().with_http_header_name_attribute(header_name),
Err(ApiKeyAttributeError::InvalidHttpHeaderName(_))
));
}
for header_scheme in [
"",
"invalid scheme",
"ApiKey\n",
"Api:Key",
"Api/Key",
"Api,Key",
"Api\tKey",
"ApiK\u{e9}y",
] {
assert!(matches!(
ApiKeyAttributes::new().with_http_header_scheme_attribute(header_scheme),
Err(ApiKeyAttributeError::InvalidHttpHeaderScheme(_))
));
}
}
#[test]
fn http_header_scheme_builder_accepts_token_characters() {
for header_scheme in ["ApiKey", "A!#$%&'*+-.^_`|~9"] {
assert!(
ApiKeyAttributes::new()
.with_http_header_scheme_attribute(header_scheme)
.is_ok(),
"expected valid scheme: {header_scheme}"
);
}
}
#[test]
fn generic_attribute_builder_validates_reserved_attributes() {
for (name, value) in [
(
ApiKeyAttributes::HTTP_HEADER_NAME_ATTRIBUTE,
Value::Number(42.into()),
),
(
ApiKeyAttributes::HTTP_HEADER_SCHEME_ATTRIBUTE,
Value::Bool(false),
),
(
ApiKeyAttributes::HTTP_HEADER_NAME_ATTRIBUTE,
Value::String("invalid header".into()),
),
(
ApiKeyAttributes::HTTP_HEADER_SCHEME_ATTRIBUTE,
Value::String("invalid scheme".into()),
),
] {
assert!(ApiKeyAttributes::new().with_attribute(name, value).is_err());
}
}
#[test]
fn from_map_preserves_valid_attributes() {
let attributes = serde_json::from_value(serde_json::json!({
"tenant.id": 42,
"http.header_name": "x-api-key",
"http.header_scheme": "ApiKey"
}))
.expect("attributes are an object");
let key = ApiKey::new("secret")
.with_attributes(ApiKeyAttributes::from_map(attributes).expect("attributes are valid"));
assert_eq!(key.get_http_header_name_attribute(), Some("x-api-key"));
assert_eq!(key.get_http_header_scheme_attribute(), Some("ApiKey"));
assert_eq!(
key.get_attributes()
.and_then(|values| values.get("tenant.id")),
Some(&Value::Number(42.into()))
);
}
#[test]
fn cloned_attributes_replace_values_independently() {
let original = ApiKeyAttributes::new()
.with_attribute("tenant.id", 42)
.expect("custom attribute is valid");
let updated = original
.clone()
.with_attribute("tenant.id", 43)
.expect("replacement attribute is valid");
let original_key = ApiKey::new("original").with_attributes(original);
let updated_key = ApiKey::new("updated").with_attributes(updated);
assert_eq!(
original_key
.get_attributes()
.and_then(|values| values.get("tenant.id")),
Some(&Value::Number(42.into()))
);
assert_eq!(
updated_key
.get_attributes()
.and_then(|values| values.get("tenant.id")),
Some(&Value::Number(43.into()))
);
}
}