use std::{collections::BTreeMap, env, time::Duration};
use http::{header, HeaderMap, HeaderValue};
use serde::Serialize;
use serde_json::{Map, Value};
use crate::{
error::{Error, Operation, Result},
models::{VerificationReason, VerificationResult},
VERSION,
};
pub const DEFAULT_BASE_URL: &str = "https://api.reqkey.com";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
pub const MAX_BODY_CHARACTERS: usize = 1_000;
fn user_agent() -> String {
format!("reqkey-rust/{VERSION}")
}
#[derive(Clone, Debug)]
struct ClientConfig {
project_key: String,
base_url: String,
timeout: Duration,
}
impl ClientConfig {
fn from_builder(
project_key: Option<String>,
root_key: Option<String>,
base_url: &str,
timeout: Duration,
) -> Result<Self> {
if project_key.is_some() && root_key.is_some() {
return Err(Error::Configuration(
"pass project_key or root_key, not both".into(),
));
}
let project_key = project_key
.or(root_key)
.unwrap_or_default()
.trim()
.to_owned();
if project_key.is_empty() {
return Err(Error::Configuration(
"a project key is required; configure one directly or set \
REQKEY_PROJECT_KEY/REQKEY_ROOT_KEY"
.into(),
));
}
if timeout.is_zero() {
return Err(Error::Configuration(
"timeout must be greater than zero".into(),
));
}
let base_url = base_url.trim().trim_end_matches('/').to_owned();
if base_url.is_empty() {
return Err(Error::Configuration("base_url cannot be empty".into()));
}
let parsed = url::Url::parse(&base_url)
.map_err(|error| Error::Configuration(format!("base_url is invalid: {error}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(Error::Configuration(
"base_url must use http or https".into(),
));
}
Ok(Self {
project_key,
base_url,
timeout,
})
}
fn headers(&self) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
let authorization = HeaderValue::from_str(&format!("Bearer {}", self.project_key))
.map_err(|_| Error::Configuration("project key contains invalid bytes".into()))?;
headers.insert(header::AUTHORIZATION, authorization);
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
headers.insert(
header::USER_AGENT,
HeaderValue::from_str(&user_agent())
.map_err(|_| Error::Configuration("invalid SDK user agent".into()))?,
);
Ok(headers)
}
}
#[derive(Default)]
#[must_use = "a client builder does nothing until build() is called"]
pub struct ClientBuilder {
project_key: Option<String>,
root_key: Option<String>,
base_url: Option<String>,
timeout: Option<Duration>,
http_client: Option<reqwest::Client>,
}
impl ClientBuilder {
pub fn project_key(mut self, key: impl Into<String>) -> Self {
self.project_key = Some(key.into());
self
}
pub fn root_key(mut self, key: impl Into<String>) -> Self {
self.root_key = Some(key.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn build(self) -> Result<Client> {
let base_url = self.base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
let config = ClientConfig::from_builder(
self.project_key,
self.root_key,
&base_url,
self.timeout.unwrap_or(DEFAULT_TIMEOUT),
)?;
let http = if let Some(http) = self.http_client {
http
} else {
reqwest::Client::builder()
.timeout(config.timeout)
.build()
.map_err(|error| Error::Configuration(error.to_string()))?
};
Ok(Client { config, http })
}
}
#[derive(Clone)]
pub struct Client {
config: ClientConfig,
http: reqwest::Client,
}
impl Client {
pub fn new(project_key: impl Into<String>) -> Result<Self> {
Self::builder().project_key(project_key).build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn from_env() -> Result<Self> {
let key = env::var("REQKEY_PROJECT_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| env::var("REQKEY_ROOT_KEY").ok());
let mut builder = Self::builder();
if let Some(key) = key {
builder = builder.project_key(key);
}
builder.build()
}
pub fn verify(&self, key: impl Into<String>) -> Verify<'_> {
Verify {
client: self,
key: key.into(),
api_id: None,
credits: 1,
resource: None,
}
}
pub async fn ingest(&self, event: &IngestEvent) -> Result<()> {
event.validate()?;
let response = self
.http
.post(format!("{}/ingest", self.config.base_url))
.headers(self.config.headers()?)
.json(&event)
.send()
.await
.map_err(|error| transport_error(&error, Operation::Ingest))?;
let status = response.status().as_u16();
if matches!(status, 200 | 202) {
return Ok(());
}
let value = response_value(response, Operation::Ingest).await?;
Err(api_error(status, value, "ReqKey ingestion failed"))
}
async fn send_verify(&self, payload: &VerifyPayload) -> Result<VerificationResult> {
let response = self
.http
.post(format!("{}/key/validate", self.config.base_url))
.headers(self.config.headers()?)
.json(payload)
.send()
.await
.map_err(|error| transport_error(&error, Operation::Validate))?;
let status = response.status().as_u16();
let retry_after_header = response
.headers()
.get(header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<f64>().ok());
let value = response_value(response, Operation::Validate).await?;
verification_result(status, retry_after_header, value)
}
}
#[must_use = "a validation request does nothing until send() is awaited"]
pub struct Verify<'a> {
client: &'a Client,
key: String,
api_id: Option<String>,
credits: u64,
resource: Option<String>,
}
impl Verify<'_> {
pub fn api_id(mut self, api_id: impl Into<String>) -> Self {
self.api_id = Some(api_id.into());
self
}
pub const fn credits(mut self, credits: u64) -> Self {
self.credits = credits;
self
}
pub fn resource(mut self, resource: impl Into<String>) -> Self {
self.resource = Some(resource.into());
self
}
pub async fn send(self) -> Result<VerificationResult> {
let payload = VerifyPayload::new(self.key, self.api_id, self.credits, self.resource)?;
self.client.send_verify(&payload).await
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VerifyPayload {
key: String,
#[serde(skip_serializing_if = "Option::is_none")]
api_id: Option<String>,
credits: u64,
#[serde(skip_serializing_if = "Option::is_none")]
resource: Option<String>,
}
impl VerifyPayload {
fn new(
key: String,
api_id: Option<String>,
credits: u64,
resource: Option<String>,
) -> Result<Self> {
if key.trim().is_empty() {
return Err(Error::Configuration(
"the consumer API key cannot be empty".into(),
));
}
Ok(Self {
key,
api_id,
credits,
resource,
})
}
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IngestEvent {
#[serde(skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
api_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
status_code: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
latency_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
client_ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
consumer_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
consumer_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
query_params: Option<BTreeMap<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
request_headers: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
response_headers: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
request_body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
response_body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
}
impl IngestEvent {
pub fn builder() -> IngestEventBuilder {
IngestEventBuilder::default()
}
fn validate(&self) -> Result<()> {
if self.request_id.as_deref().is_some_and(str::trim_is_empty) {
return Err(Error::Configuration(
"request_id cannot be empty when provided".into(),
));
}
if self.api_id.as_deref().is_some_and(str::trim_is_empty) {
return Err(Error::Configuration(
"api_id cannot be empty when provided".into(),
));
}
if self.request_id.is_none() && self.api_id.is_none() {
return Err(Error::Configuration(
"ingestion requires request_id, api_id, or both".into(),
));
}
Ok(())
}
}
trait TrimIsEmpty {
fn trim_is_empty(&self) -> bool;
}
impl TrimIsEmpty for str {
fn trim_is_empty(&self) -> bool {
self.trim().is_empty()
}
}
#[derive(Default)]
#[must_use = "an ingest event builder does nothing until build() is called"]
pub struct IngestEventBuilder(IngestEvent);
macro_rules! string_setter {
($name:ident, $field:ident, $doc:literal) => {
#[doc = $doc]
pub fn $name(mut self, value: impl Into<String>) -> Self {
self.0.$field = Some(value.into());
self
}
};
}
impl IngestEventBuilder {
string_setter!(request_id, request_id, "Set the validation correlation ID.");
string_setter!(api_id, api_id, "Set the ReqKey API ID.");
string_setter!(method, method, "Set the HTTP request method.");
string_setter!(endpoint, endpoint, "Set the normalized endpoint/resource.");
string_setter!(
path,
path,
"Set the request path, optionally including a safe query."
);
string_setter!(client_ip, client_ip, "Set the resolved client IP address.");
string_setter!(user_agent, user_agent, "Set the request user agent.");
string_setter!(user_id, user_id, "Set an application user identifier.");
string_setter!(
consumer_name,
consumer_name,
"Set an explicit consumer display name."
);
string_setter!(
api_key,
api_key,
"Set the consumer API key for identity resolution."
);
string_setter!(
consumer_id,
consumer_id,
"Set a fallback ReqKey consumer ID."
);
string_setter!(timestamp, timestamp, "Set an ISO-8601 event timestamp.");
pub const fn status_code(mut self, value: u16) -> Self {
self.0.status_code = Some(value);
self
}
pub const fn latency_ms(mut self, value: u64) -> Self {
self.0.latency_ms = Some(value);
self
}
pub fn query_params(mut self, value: BTreeMap<String, Value>) -> Self {
self.0.query_params = Some(value);
self
}
pub fn request_headers(mut self, value: BTreeMap<String, String>) -> Self {
self.0.request_headers = Some(value);
self
}
pub fn response_headers(mut self, value: BTreeMap<String, String>) -> Self {
self.0.response_headers = Some(value);
self
}
pub fn request_body(mut self, value: impl Into<String>) -> Self {
let value = value.into();
self.0.request_body = Some(truncate_body(&value));
self
}
pub fn response_body(mut self, value: impl Into<String>) -> Self {
let value = value.into();
self.0.response_body = Some(truncate_body(&value));
self
}
pub fn build(mut self) -> Result<IngestEvent> {
self.0.request_body = self.0.request_body.map(|value| truncate_body(&value));
self.0.response_body = self.0.response_body.map(|value| truncate_body(&value));
self.0.validate()?;
Ok(self.0)
}
}
fn truncate_body(value: &str) -> String {
value.chars().take(MAX_BODY_CHARACTERS).collect()
}
async fn response_value(response: reqwest::Response, operation: Operation) -> Result<Value> {
let status = response.status().as_u16();
response.json::<Value>().await.map_err(|_| Error::Api {
status,
message: format!("ReqKey returned a non-JSON response during {operation}"),
body: None,
})
}
fn transport_error(error: &reqwest::Error, operation: Operation) -> Error {
if error.is_timeout() {
Error::Timeout { operation }
} else {
Error::Transport {
operation,
message: error.to_string(),
}
}
}
fn api_error(status: u16, value: Value, fallback: &str) -> Error {
let message = error_message(&value).unwrap_or_else(|| fallback.to_owned());
if status == 401 {
Error::Authentication {
status,
message,
body: Some(value),
}
} else {
Error::Api {
status,
message,
body: Some(value),
}
}
}
fn error_message(value: &Value) -> Option<String> {
let object = value.as_object()?;
["error", "message"]
.into_iter()
.find_map(|key| object.get(key).and_then(Value::as_str))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn verification_result(
status: u16,
retry_after_header: Option<f64>,
value: Value,
) -> Result<VerificationResult> {
if status == 401 {
return Err(api_error(
status,
value,
"ReqKey rejected the project credential",
));
}
if !matches!(status, 200 | 402 | 403 | 429) {
return Err(api_error(
status,
value,
&format!("ReqKey returned HTTP {status}"),
));
}
let raw = value.as_object().cloned().ok_or_else(|| Error::Api {
status,
message: "ReqKey returned an unexpected response body".into(),
body: Some(value.clone()),
})?;
let valid = raw.get("valid").and_then(Value::as_bool) == Some(true);
let reason = match status {
402 => VerificationReason::InsufficientCredits,
403 => VerificationReason::Forbidden,
429 => VerificationReason::RateLimited,
_ if raw.get("rateLimited").and_then(Value::as_bool) == Some(true) => {
VerificationReason::RateLimited
}
_ if valid => VerificationReason::Valid,
200 => VerificationReason::InvalidKey,
_ => VerificationReason::Denied,
};
let allowed_apis = raw
.get("allowedApis")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|item| {
item.as_str()
.map_or_else(|| item.to_string(), ToOwned::to_owned)
})
.collect()
})
.unwrap_or_default();
let retry_after = raw
.get("retryAfter")
.and_then(Value::as_f64)
.or(retry_after_header);
Ok(VerificationResult {
valid,
reason,
status_code: status,
request_id: string_field(&raw, "requestId"),
message: string_field(&raw, "message"),
api_id: string_field(&raw, "apiId"),
api_name: string_field(&raw, "apiName"),
resource: string_field(&raw, "resource"),
credits_remaining: raw.get("creditsRemaining").and_then(Value::as_i64),
credits_limit: raw.get("creditsLimit").and_then(Value::as_i64),
allowed_apis,
retry_after,
rate_limit: raw.get("rateLimit").and_then(Value::as_object).cloned(),
raw,
})
}
fn string_field(map: &Map<String, Value>, key: &str) -> Option<String> {
map.get(key).and_then(Value::as_str).map(ToOwned::to_owned)
}
#[cfg(feature = "blocking")]
mod blocking {
#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Default)]
#[must_use = "a client builder does nothing until build() is called"]
pub struct SyncClientBuilder {
project_key: Option<String>,
root_key: Option<String>,
base_url: Option<String>,
timeout: Option<Duration>,
http_client: Option<reqwest::blocking::Client>,
}
impl SyncClientBuilder {
pub fn project_key(mut self, key: impl Into<String>) -> Self {
self.project_key = Some(key.into());
self
}
pub fn root_key(mut self, key: impl Into<String>) -> Self {
self.root_key = Some(key.into());
self
}
pub fn base_url(mut self, value: impl Into<String>) -> Self {
self.base_url = Some(value.into());
self
}
pub const fn timeout(mut self, value: Duration) -> Self {
self.timeout = Some(value);
self
}
pub fn http_client(mut self, client: reqwest::blocking::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn build(self) -> Result<SyncClient> {
let base_url = self.base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
let config = ClientConfig::from_builder(
self.project_key,
self.root_key,
&base_url,
self.timeout.unwrap_or(DEFAULT_TIMEOUT),
)?;
let http = if let Some(http) = self.http_client {
http
} else {
reqwest::blocking::Client::builder()
.timeout(config.timeout)
.build()
.map_err(|error| Error::Configuration(error.to_string()))?
};
Ok(SyncClient { config, http })
}
}
pub struct SyncClient {
config: ClientConfig,
http: reqwest::blocking::Client,
}
impl SyncClient {
pub fn new(project_key: impl Into<String>) -> Result<Self> {
Self::builder().project_key(project_key).build()
}
pub fn builder() -> SyncClientBuilder {
SyncClientBuilder::default()
}
pub fn from_env() -> Result<Self> {
let key = env::var("REQKEY_PROJECT_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| env::var("REQKEY_ROOT_KEY").ok());
let mut builder = Self::builder();
if let Some(key) = key {
builder = builder.project_key(key);
}
builder.build()
}
pub fn verify(&self, key: impl Into<String>) -> SyncVerify<'_> {
SyncVerify {
client: self,
key: key.into(),
api_id: None,
credits: 1,
resource: None,
}
}
pub fn ingest(&self, event: &IngestEvent) -> Result<()> {
event.validate()?;
let response = self
.http
.post(format!("{}/ingest", self.config.base_url))
.headers(self.config.headers()?)
.json(&event)
.send()
.map_err(|error| transport_error(&error, Operation::Ingest))?;
let status = response.status().as_u16();
if matches!(status, 200 | 202) {
return Ok(());
}
let value = blocking_response_value(response, Operation::Ingest)?;
Err(api_error(status, value, "ReqKey ingestion failed"))
}
fn send_verify(&self, payload: &VerifyPayload) -> Result<VerificationResult> {
let response = self
.http
.post(format!("{}/key/validate", self.config.base_url))
.headers(self.config.headers()?)
.json(payload)
.send()
.map_err(|error| transport_error(&error, Operation::Validate))?;
let status = response.status().as_u16();
let retry_after = response
.headers()
.get(header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<f64>().ok());
let value = blocking_response_value(response, Operation::Validate)?;
verification_result(status, retry_after, value)
}
}
#[must_use = "a validation request does nothing until send() is called"]
pub struct SyncVerify<'a> {
client: &'a SyncClient,
key: String,
api_id: Option<String>,
credits: u64,
resource: Option<String>,
}
impl SyncVerify<'_> {
pub fn api_id(mut self, value: impl Into<String>) -> Self {
self.api_id = Some(value.into());
self
}
pub const fn credits(mut self, value: u64) -> Self {
self.credits = value;
self
}
pub fn resource(mut self, value: impl Into<String>) -> Self {
self.resource = Some(value.into());
self
}
pub fn send(self) -> Result<VerificationResult> {
let payload = VerifyPayload::new(self.key, self.api_id, self.credits, self.resource)?;
self.client.send_verify(&payload)
}
}
fn blocking_response_value(
response: reqwest::blocking::Response,
operation: Operation,
) -> Result<Value> {
let status = response.status().as_u16();
response.json::<Value>().map_err(|_| Error::Api {
status,
message: format!("ReqKey returned a non-JSON response during {operation}"),
body: None,
})
}
}
#[cfg(feature = "blocking")]
pub use blocking::{SyncClient, SyncClientBuilder, SyncVerify};
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{truncate_body, verification_result, Error, VerificationReason, VerifyPayload};
#[test]
fn verify_payload_rejects_an_empty_consumer_key() {
assert!(matches!(
VerifyPayload::new(" ".into(), None, 1, None),
Err(Error::Configuration(_))
));
}
#[test]
fn normalizer_maps_rate_limited_body_flag() {
let result = verification_result(200, None, json!({"valid": false, "rateLimited": true}))
.expect("decision");
assert_eq!(result.reason, VerificationReason::RateLimited);
}
#[test]
fn body_limit_counts_unicode_characters() {
let body = truncate_body(&"🦀".repeat(1_050));
assert_eq!(body.chars().count(), 1_000);
}
}