use indexmap::IndexMap;
use oino_ai::{JsonObject, JsonValue};
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ApiKeyCredential {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<IndexMap<String, String>>,
#[serde(flatten)]
pub extra: JsonObject,
}
impl std::fmt::Debug for ApiKeyCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiKeyCredential")
.field("key", &self.key.as_ref().map(|_| "<redacted>"))
.field("env", &self.env.as_ref().map(IndexMap::len))
.finish_non_exhaustive()
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct OAuthCredential {
pub access: String,
pub refresh: String,
pub expires: i64,
#[serde(flatten)]
pub extra: JsonObject,
}
impl std::fmt::Debug for OAuthCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthCredential")
.field("expires", &self.expires)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthCredential {
ApiKey(ApiKeyCredential),
Oauth(OAuthCredential),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CredentialType {
ApiKey,
Oauth,
}
impl CredentialType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ApiKey => "api_key",
Self::Oauth => "oauth",
}
}
}
impl AuthCredential {
#[must_use]
pub fn api_key(key: impl Into<String>) -> Self {
Self::ApiKey(ApiKeyCredential {
key: Some(key.into()),
..ApiKeyCredential::default()
})
}
#[must_use]
pub fn kind(&self) -> CredentialType {
match self {
Self::ApiKey(_) => CredentialType::ApiKey,
Self::Oauth(_) => CredentialType::Oauth,
}
}
#[must_use]
pub fn to_value(&self) -> JsonValue {
serde_json::to_value(self).unwrap_or(JsonValue::Null)
}
#[must_use]
pub fn from_value(value: &JsonValue) -> Option<Self> {
let object = value.as_object()?;
match object.get("type").and_then(JsonValue::as_str)? {
"api_key" => {
let key_ok = object.get("key").is_none_or(JsonValue::is_string);
let env_ok = object.get("env").is_none_or(|env| {
env.as_object()
.is_some_and(|m| m.values().all(JsonValue::is_string))
});
if !(key_ok && env_ok) {
return None;
}
serde_json::from_value(value.clone()).ok()
}
"oauth" => {
let access = object.get("access")?.as_str()?.to_owned();
let refresh = object.get("refresh")?.as_str()?.to_owned();
let expires = object.get("expires")?.as_f64().filter(|n| n.is_finite())?;
let extra = object
.iter()
.filter(|(k, _)| {
!matches!(k.as_str(), "type" | "access" | "refresh" | "expires")
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Some(Self::Oauth(OAuthCredential {
access,
refresh,
expires: js_ms(expires),
extra,
}))
}
_ => None,
}
}
}
#[allow(
clippy::cast_possible_truncation,
reason = "expiry timestamps are integral milliseconds well inside i64"
)]
fn js_ms(value: f64) -> i64 {
value.trunc() as i64
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialInfo {
pub provider_id: String,
#[serde(rename = "type")]
pub kind: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn round_trips_pi_shapes() {
let value = json!({"type": "oauth", "access": "a", "refresh": "r", "expires": 1.0e12, "accountId": "x"});
let credential = AuthCredential::from_value(&value).unwrap();
assert_eq!(
credential.to_value(),
json!({"type": "oauth", "access": "a", "refresh": "r", "expires": 1_000_000_000_000_i64, "accountId": "x"})
);
let value = json!({"type": "api_key", "key": "k", "env": {"A": "b"}});
assert_eq!(
AuthCredential::from_value(&value).unwrap().to_value(),
value
);
assert!(AuthCredential::from_value(&json!({"type": "api_key", "key": 5})).is_none());
assert!(AuthCredential::from_value(&json!({"type": "oauth", "access": "a"})).is_none());
assert!(AuthCredential::from_value(&json!({"type": "other"})).is_none());
}
}