use crate::config::GatewardenConfig;
use crate::crypto::digest::format_digest_header;
use crate::GatewardenError;
use reqwest::blocking::{Client, Response};
use reqwest::header::{CONTENT_TYPE, DATE, HOST, USER_AGENT};
use std::time::Duration;
#[derive(Debug)]
pub struct KeygenResponse {
pub status: u16,
pub date: Option<String>,
pub signature: Option<String>,
pub digest: Option<String>,
pub body: Vec<u8>,
pub request_path: String,
pub host: String,
}
impl KeygenResponse {
fn from_response(
response: Response,
request_path: String,
host: String,
) -> Result<Self, GatewardenError> {
let status = response.status().as_u16();
let headers = response.headers().clone();
let date = headers
.get(DATE)
.and_then(|v| v.to_str().ok())
.map(String::from);
let signature = headers
.get("Keygen-Signature")
.and_then(|v| v.to_str().ok())
.map(String::from);
let digest = headers
.get("Digest")
.and_then(|v| v.to_str().ok())
.map(String::from);
let body = response
.bytes()
.map_err(|e| GatewardenError::KeygenTransport(format!("Failed to read body: {}", e)))?
.to_vec();
Ok(Self {
status,
date,
signature,
digest,
body,
request_path,
host,
})
}
pub fn body_str(&self) -> Result<&str, GatewardenError> {
std::str::from_utf8(&self.body)
.map_err(|e| GatewardenError::ProtocolError(format!("Invalid UTF-8 in body: {}", e)))
}
}
pub struct KeygenClient {
client: Client,
user_agent: String,
account_id: String,
host: String,
timeout: Duration,
}
impl KeygenClient {
pub fn new(config: &GatewardenConfig) -> Result<Self, GatewardenError> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| {
GatewardenError::KeygenTransport(format!("Failed to create client: {}", e))
})?;
let user_agent = build_user_agent(config);
Ok(Self {
client,
user_agent,
account_id: config.account_id.to_string(),
host: "api.keygen.sh".to_string(),
timeout: Duration::from_secs(30),
})
}
#[cfg(test)]
pub fn with_host(config: &GatewardenConfig, host: String) -> Result<Self, GatewardenError> {
let mut client = Self::new(config)?;
client.host = host;
Ok(client)
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
if let Ok(client) = Client::builder().timeout(timeout).build() {
self.client = client;
}
self
}
pub fn try_with_timeout(mut self, timeout: Duration) -> Result<Self, GatewardenError> {
self.timeout = timeout;
self.client = Client::builder().timeout(timeout).build().map_err(|e| {
GatewardenError::ConfigError(format!("Failed to build HTTP client: {}", e))
})?;
Ok(self)
}
pub fn validate_key(
&self,
license_key: &str,
scope_entitlements: &[&str],
) -> Result<KeygenResponse, GatewardenError> {
let path = format!(
"/v1/accounts/{}/licenses/actions/validate-key",
self.account_id
);
let url = format!("https://{}{}", self.host, path);
let body = if scope_entitlements.is_empty() {
serde_json::json!({
"meta": {
"key": license_key
}
})
} else {
serde_json::json!({
"meta": {
"key": license_key,
"scope": {
"entitlements": scope_entitlements
}
}
})
};
let body_bytes = serde_json::to_vec(&body)
.map_err(|e| GatewardenError::ProtocolError(format!("Failed to serialize: {}", e)))?;
let digest_header = format_digest_header(&body_bytes);
let response = self
.client
.post(&url)
.header(USER_AGENT, &self.user_agent)
.header(HOST, &self.host)
.header(CONTENT_TYPE, "application/vnd.api+json")
.header("Digest", &digest_header)
.header("Accept", "application/vnd.api+json")
.body(body_bytes)
.send()
.map_err(|e| GatewardenError::KeygenTransport(format!("Request failed: {}", e)))?;
KeygenResponse::from_response(response, path, self.host.clone())
}
pub fn host(&self) -> &str {
&self.host
}
}
pub fn build_user_agent(config: &GatewardenConfig) -> String {
let product = &config.user_agent_product;
let app = &config.app_name;
let gw_version = env!("CARGO_PKG_VERSION");
format!("{}/gatewarden-{} {}", product, gw_version, app)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> GatewardenConfig {
GatewardenConfig {
app_name: "shimmy/1.0.0",
feature_name: "vision",
account_id: "test-account-id",
public_key_hex: "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
required_entitlements: &["vision"],
user_agent_product: "shimmy-vision",
cache_namespace: "shimmy",
offline_grace: Duration::from_secs(86400),
}
}
#[test]
fn test_build_user_agent() {
let config = test_config();
let ua = build_user_agent(&config);
assert!(ua.starts_with("shimmy-vision/gatewarden-"));
assert!(ua.contains("shimmy/1.0.0"));
}
#[test]
fn test_build_user_agent_format() {
let config = GatewardenConfig {
app_name: "myapp/2.0.0",
feature_name: "pro",
account_id: "acc",
public_key_hex: "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
required_entitlements: &[],
user_agent_product: "myproduct",
cache_namespace: "myproduct",
offline_grace: Duration::from_secs(0),
};
let ua = build_user_agent(&config);
let gw_version = env!("CARGO_PKG_VERSION");
assert_eq!(
ua,
format!("myproduct/gatewarden-{} myapp/2.0.0", gw_version)
);
}
#[test]
fn test_keygen_response_body_str_valid_utf8() {
let response = KeygenResponse {
status: 200,
date: None,
signature: None,
digest: None,
body: b"hello world".to_vec(),
request_path: "/test".to_string(),
host: "api.keygen.sh".to_string(),
};
assert_eq!(response.body_str().unwrap(), "hello world");
}
#[test]
fn test_keygen_response_body_str_invalid_utf8() {
let response = KeygenResponse {
status: 200,
date: None,
signature: None,
digest: None,
body: vec![0xFF, 0xFE],
request_path: "/test".to_string(),
host: "api.keygen.sh".to_string(),
};
assert!(response.body_str().is_err());
}
#[test]
fn test_client_creation() {
let config = test_config();
let client = KeygenClient::new(&config);
assert!(client.is_ok());
}
#[test]
fn test_client_host() {
let config = test_config();
let client = KeygenClient::new(&config).unwrap();
assert_eq!(client.host(), "api.keygen.sh");
}
}