use std::{
collections::{BTreeMap, BTreeSet},
net::IpAddr,
panic::{catch_unwind, AssertUnwindSafe},
str::FromStr,
sync::Arc,
time::Instant,
};
use http::{header, HeaderMap, HeaderName, HeaderValue, Method};
use serde::Serialize;
use serde_json::Value;
use crate::{
Client, Error, IngestEvent, Operation, Result, VerificationReason, VerificationResult,
};
const DEFAULT_KEY_NAME: &str = "X-API-Key";
const PROXY_CLIENT_IP_HEADERS: [&str; 12] = [
"x-forwarded-for",
"x-real-ip",
"cf-connecting-ip",
"true-client-ip",
"x-vercel-forwarded-for",
"fly-client-ip",
"fastly-client-ip",
"x-azure-clientip",
"x-appengine-user-ip",
"cloudfront-viewer-address",
"forwarded",
"x-client-ip",
];
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Mode {
Validate,
Ingest,
#[default]
Both,
}
impl Mode {
const fn validates(self) -> bool {
matches!(self, Self::Validate | Self::Both)
}
const fn ingests(self) -> bool {
matches!(self, Self::Ingest | Self::Both)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum FailureMode {
#[default]
Closed,
Open,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum KeyScheme {
#[default]
Raw,
Bearer,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[must_use]
pub enum KeyLocation {
Header(String),
Query(String),
Cookie(String),
}
impl Default for KeyLocation {
fn default() -> Self {
Self::Header(DEFAULT_KEY_NAME.into())
}
}
impl KeyLocation {
pub fn x_api_key() -> Self {
Self::default()
}
pub fn header(name: impl Into<String>) -> Self {
Self::Header(name.into())
}
pub fn query(name: impl Into<String>) -> Self {
Self::Query(name.into())
}
pub fn cookie(name: impl Into<String>) -> Self {
Self::Cookie(name.into())
}
fn name(&self) -> &str {
match self {
Self::Header(name) | Self::Query(name) | Self::Cookie(name) => name,
}
}
const fn is_header(&self) -> bool {
matches!(self, Self::Header(_))
}
const fn is_query(&self) -> bool {
matches!(self, Self::Query(_))
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DenialCode {
MissingApiKey,
InvalidApiKey,
InsufficientCredits,
AccessDenied,
RateLimited,
ReqKeyUnavailable,
}
impl DenialCode {
pub const fn as_str(self) -> &'static str {
match self {
Self::MissingApiKey => "missing_api_key",
Self::InvalidApiKey => "invalid_api_key",
Self::InsufficientCredits => "insufficient_credits",
Self::AccessDenied => "access_denied",
Self::RateLimited => "rate_limited",
Self::ReqKeyUnavailable => "reqkey_unavailable",
}
}
}
fn default_error_messages() -> BTreeMap<DenialCode, String> {
[
(DenialCode::MissingApiKey, "An API key is required."),
(
DenialCode::InvalidApiKey,
"The API key is invalid or inactive.",
),
(
DenialCode::InsufficientCredits,
"The API key has insufficient credits.",
),
(
DenialCode::AccessDenied,
"The API key is not allowed to access this API.",
),
(
DenialCode::RateLimited,
"The API key has exceeded its rate limit.",
),
(
DenialCode::ReqKeyUnavailable,
"API key verification is temporarily unavailable.",
),
]
.into_iter()
.map(|(code, message)| (code, message.to_owned()))
.collect()
}
type CreditResolver = Arc<dyn Fn(&RequestContext) -> Result<u64> + Send + Sync>;
type Predicate = Arc<dyn Fn(&RequestContext) -> bool + Send + Sync>;
type OptionalStringResolver = Arc<dyn Fn(&RequestContext) -> Option<String> + Send + Sync>;
type StringResolver = Arc<dyn Fn(&RequestContext) -> Result<String> + Send + Sync>;
type ErrorCallback = Arc<dyn Fn(&MiddlewareErrorEvent) + Send + Sync>;
#[derive(Clone)]
enum CreditCost {
Fixed(u64),
Dynamic(CreditResolver),
}
impl Default for CreditCost {
fn default() -> Self {
Self::Fixed(1)
}
}
#[must_use = "middleware configuration is not validated until build() is called"]
pub struct MiddlewareConfigBuilder {
api_id: String,
mode: Mode,
enabled: bool,
key_location: KeyLocation,
key_scheme: KeyScheme,
custom_key_resolver: Option<OptionalStringResolver>,
credits: CreditCost,
exclude_paths: Vec<String>,
skip_methods: BTreeSet<Method>,
should_protect: Option<Predicate>,
request_id_resolver: Option<OptionalStringResolver>,
path_resolver: Option<StringResolver>,
consumer_name_resolver: Option<OptionalStringResolver>,
client_ip_resolver: Option<OptionalStringResolver>,
on_error: Option<ErrorCallback>,
ingest_denied_requests: bool,
error_messages: BTreeMap<DenialCode, String>,
capture_query_params: bool,
capture_request_headers: bool,
capture_response_headers: bool,
capture_request_body: bool,
capture_response_body: bool,
capture_client_ip: bool,
capture_user_agent: bool,
excluded_headers: BTreeSet<String>,
failure_mode: FailureMode,
}
impl MiddlewareConfigBuilder {
fn new(api_id: String) -> Self {
Self {
api_id,
mode: Mode::Both,
enabled: true,
key_location: KeyLocation::default(),
key_scheme: KeyScheme::Raw,
custom_key_resolver: None,
credits: CreditCost::default(),
exclude_paths: Vec::new(),
skip_methods: [Method::OPTIONS].into_iter().collect(),
should_protect: None,
request_id_resolver: None,
path_resolver: None,
consumer_name_resolver: None,
client_ip_resolver: None,
on_error: None,
ingest_denied_requests: true,
error_messages: default_error_messages(),
capture_query_params: false,
capture_request_headers: false,
capture_response_headers: false,
capture_request_body: false,
capture_response_body: false,
capture_client_ip: false,
capture_user_agent: true,
excluded_headers: default_excluded_headers(),
failure_mode: FailureMode::Closed,
}
}
pub const fn mode(mut self, mode: Mode) -> Self {
self.mode = mode;
self
}
pub const fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn key_location(mut self, location: KeyLocation) -> Self {
self.key_location = location;
self
}
pub const fn key_scheme(mut self, scheme: KeyScheme) -> Self {
self.key_scheme = scheme;
self
}
pub fn consumer_key_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&RequestContext) -> Option<String> + Send + Sync + 'static,
{
self.custom_key_resolver = Some(Arc::new(resolver));
self
}
pub fn credit_cost(mut self, credits: u64) -> Self {
self.credits = CreditCost::Fixed(credits);
self
}
pub fn credit_cost_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&RequestContext) -> Result<u64> + Send + Sync + 'static,
{
self.credits = CreditCost::Dynamic(Arc::new(resolver));
self
}
pub fn exclude_path(mut self, pattern: impl Into<String>) -> Self {
self.exclude_paths.push(pattern.into());
self
}
pub fn skip_methods<I>(mut self, methods: I) -> Self
where
I: IntoIterator<Item = Method>,
{
self.skip_methods = methods.into_iter().collect();
self
}
pub fn should_protect<F>(mut self, predicate: F) -> Self
where
F: Fn(&RequestContext) -> bool + Send + Sync + 'static,
{
self.should_protect = Some(Arc::new(predicate));
self
}
pub fn request_id_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&RequestContext) -> Option<String> + Send + Sync + 'static,
{
self.request_id_resolver = Some(Arc::new(resolver));
self
}
pub fn path_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&RequestContext) -> Result<String> + Send + Sync + 'static,
{
self.path_resolver = Some(Arc::new(resolver));
self
}
pub fn consumer_name_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&RequestContext) -> Option<String> + Send + Sync + 'static,
{
self.consumer_name_resolver = Some(Arc::new(resolver));
self
}
pub fn client_ip_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&RequestContext) -> Option<String> + Send + Sync + 'static,
{
self.client_ip_resolver = Some(Arc::new(resolver));
self
}
pub fn on_error<F>(mut self, callback: F) -> Self
where
F: Fn(&MiddlewareErrorEvent) + Send + Sync + 'static,
{
self.on_error = Some(Arc::new(callback));
self
}
pub const fn ingest_denied_requests(mut self, enabled: bool) -> Self {
self.ingest_denied_requests = enabled;
self
}
pub fn error_message(mut self, code: DenialCode, message: impl Into<String>) -> Self {
self.error_messages.insert(code, message.into());
self
}
pub const fn capture_query_params(mut self, enabled: bool) -> Self {
self.capture_query_params = enabled;
self
}
pub const fn capture_request_headers(mut self, enabled: bool) -> Self {
self.capture_request_headers = enabled;
self
}
pub const fn capture_response_headers(mut self, enabled: bool) -> Self {
self.capture_response_headers = enabled;
self
}
pub const fn capture_request_body(mut self, enabled: bool) -> Self {
self.capture_request_body = enabled;
self
}
pub const fn capture_response_body(mut self, enabled: bool) -> Self {
self.capture_response_body = enabled;
self
}
pub const fn capture_client_ip(mut self, enabled: bool) -> Self {
self.capture_client_ip = enabled;
self
}
pub const fn capture_user_agent(mut self, enabled: bool) -> Self {
self.capture_user_agent = enabled;
self
}
pub fn exclude_header(mut self, name: impl Into<String>) -> Self {
self.excluded_headers
.insert(name.into().to_ascii_lowercase());
self
}
pub const fn failure_mode(mut self, mode: FailureMode) -> Self {
self.failure_mode = mode;
self
}
pub fn build(mut self) -> Result<MiddlewareConfig> {
if self.api_id.trim().is_empty() {
return Err(Error::Configuration("api_id cannot be empty".into()));
}
if self.key_location.name().trim().is_empty() {
return Err(Error::Configuration(
"consumer key name cannot be empty".into(),
));
}
if self.key_scheme == KeyScheme::Bearer && !self.key_location.is_header() {
return Err(Error::Configuration(
"query-parameter and cookie keys must use the raw scheme".into(),
));
}
for pattern in &self.exclude_paths {
if pattern.is_empty() {
return Err(Error::Configuration(
"excluded path patterns cannot be empty".into(),
));
}
}
self.excluded_headers
.insert(self.key_location.name().to_ascii_lowercase());
Ok(MiddlewareConfig {
api_id: self.api_id,
mode: self.mode,
enabled: self.enabled,
key_location: self.key_location,
key_scheme: self.key_scheme,
custom_key_resolver: self.custom_key_resolver,
credits: self.credits,
exclude_paths: self.exclude_paths,
skip_methods: self.skip_methods,
should_protect: self.should_protect,
request_id_resolver: self.request_id_resolver,
path_resolver: self.path_resolver,
consumer_name_resolver: self.consumer_name_resolver,
client_ip_resolver: self.client_ip_resolver,
on_error: self.on_error,
ingest_denied_requests: self.ingest_denied_requests,
error_messages: self.error_messages,
capture_query_params: self.capture_query_params,
capture_request_headers: self.capture_request_headers,
capture_response_headers: self.capture_response_headers,
capture_request_body: self.capture_request_body,
capture_response_body: self.capture_response_body,
capture_client_ip: self.capture_client_ip,
capture_user_agent: self.capture_user_agent,
excluded_headers: self.excluded_headers,
failure_mode: self.failure_mode,
})
}
}
#[derive(Clone)]
pub struct MiddlewareConfig {
api_id: String,
mode: Mode,
enabled: bool,
key_location: KeyLocation,
key_scheme: KeyScheme,
custom_key_resolver: Option<OptionalStringResolver>,
credits: CreditCost,
exclude_paths: Vec<String>,
skip_methods: BTreeSet<Method>,
should_protect: Option<Predicate>,
request_id_resolver: Option<OptionalStringResolver>,
path_resolver: Option<StringResolver>,
consumer_name_resolver: Option<OptionalStringResolver>,
client_ip_resolver: Option<OptionalStringResolver>,
on_error: Option<ErrorCallback>,
ingest_denied_requests: bool,
error_messages: BTreeMap<DenialCode, String>,
capture_query_params: bool,
capture_request_headers: bool,
capture_response_headers: bool,
capture_request_body: bool,
capture_response_body: bool,
capture_client_ip: bool,
capture_user_agent: bool,
excluded_headers: BTreeSet<String>,
failure_mode: FailureMode,
}
impl MiddlewareConfig {
pub fn builder(api_id: impl Into<String>) -> MiddlewareConfigBuilder {
MiddlewareConfigBuilder::new(api_id.into())
}
pub fn api_id(&self) -> &str {
&self.api_id
}
pub const fn captures_response_body(&self) -> bool {
self.capture_response_body
}
}
#[derive(Clone, Debug)]
#[must_use]
pub struct RequestContext {
pub method: Method,
pub path: String,
pub query: Option<String>,
pub headers: HeaderMap,
pub client_ip: Option<String>,
pub request_body: Option<String>,
received_at: Instant,
}
impl RequestContext {
pub fn new(method: Method, path: impl Into<String>) -> Self {
Self {
method,
path: path.into(),
query: None,
headers: HeaderMap::new(),
client_ip: None,
request_body: None,
received_at: Instant::now(),
}
}
pub fn with_query(mut self, query: impl Into<String>) -> Self {
let query = query.into();
self.query = (!query.is_empty()).then_some(query);
self
}
pub fn with_headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn with_client_ip(mut self, value: impl Into<String>) -> Self {
let value = value.into();
self.client_ip = (!value.trim().is_empty()).then_some(value);
self
}
pub fn with_request_body(mut self, value: impl Into<String>) -> Self {
self.request_body = Some(value.into());
self
}
}
#[derive(Clone, Debug)]
#[must_use]
pub struct ResponseContext {
pub status_code: u16,
pub headers: HeaderMap,
pub response_body: Option<String>,
pub latency_ms: u64,
}
impl ResponseContext {
pub fn new(status_code: u16) -> Self {
Self {
status_code,
headers: HeaderMap::new(),
response_body: None,
latency_ms: 0,
}
}
pub fn with_headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.response_body = Some(body.into());
self
}
pub const fn with_latency_ms(mut self, latency_ms: u64) -> Self {
self.latency_ms = latency_ms;
self
}
}
#[derive(Clone, Debug)]
pub struct MiddlewareErrorEvent {
pub operation: Operation,
pub error: Error,
pub method: Method,
pub path: String,
pub request_id: Option<String>,
pub status_code: Option<u16>,
}
#[derive(Clone, Debug)]
pub struct ReqKeyFailure(pub Error);
#[derive(Clone, Debug)]
pub struct DeniedResponse {
pub status_code: u16,
pub error: DenialCode,
pub message: String,
pub retry_after: Option<u64>,
}
impl DeniedResponse {
pub fn json_body(&self) -> String {
serde_json::json!({
"error": self.error.as_str(),
"message": self.message,
})
.to_string()
}
pub fn headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
if let Some(seconds) = self.retry_after {
if let Ok(value) = HeaderValue::from_str(&seconds.to_string()) {
headers.insert(header::RETRY_AFTER, value);
}
}
headers
}
}
#[derive(Clone, Debug)]
pub struct AuthorizedRequest {
pub decision: Option<VerificationResult>,
pub failure: Option<ReqKeyFailure>,
request: RequestContext,
consumer_key: Option<String>,
handler_started: Instant,
validation_time_ms: Option<f64>,
}
impl AuthorizedRequest {
pub fn request_id(&self) -> Option<&str> {
self.decision
.as_ref()
.and_then(|decision| decision.request_id.as_deref())
}
pub fn response_headers(&self) -> HeaderMap {
decision_headers(self.decision.as_ref(), self.validation_time_ms)
}
pub fn elapsed_ms(&self) -> u64 {
duration_millis(self.handler_started.elapsed())
}
}
#[derive(Clone, Debug)]
pub enum AuthorizationOutcome {
Bypass,
Authorized(AuthorizedRequest),
Denied(DeniedResponse),
}
#[derive(Clone)]
pub struct Middleware {
client: Client,
config: MiddlewareConfig,
}
impl Middleware {
pub const fn new(client: Client, config: MiddlewareConfig) -> Self {
Self { client, config }
}
pub const fn config(&self) -> &MiddlewareConfig {
&self.config
}
pub async fn authorize(&self, request: RequestContext) -> AuthorizationOutcome {
if !self.applies_to(&request) {
return AuthorizationOutcome::Bypass;
}
let consumer_key = self.consumer_key(&request);
let mut decision = None;
let mut failure = None;
let mut validation_time_ms = None;
if self.config.mode.validates() {
let Some(key) = consumer_key.as_deref() else {
let denial = self.denial(401, DenialCode::MissingApiKey, None);
self.ingest_denied(&request, None, None, &denial).await;
return AuthorizationOutcome::Denied(denial);
};
let resource = match self.resource_path(&request) {
Ok(resource) => resource,
Err(error) => {
return self.handle_validation_error(request, consumer_key, error, None);
}
};
let credits = match self.credit_cost(&request) {
Ok(credits) => credits,
Err(error) => {
return self.handle_validation_error(request, consumer_key, error, None);
}
};
let started = Instant::now();
match self
.client
.verify(key)
.api_id(&self.config.api_id)
.credits(credits)
.resource(resource)
.send()
.await
{
Ok(result) => {
validation_time_ms = Some(started.elapsed().as_secs_f64() * 1_000.0);
if !result.valid {
let (status, code) = denial_for(&result);
let retry_after = result.retry_after.map(retry_after_seconds);
let denial = self.denial(status, code, retry_after);
self.ingest_denied(
&request,
Some(&result),
consumer_key.as_deref(),
&denial,
)
.await;
return AuthorizationOutcome::Denied(denial);
}
decision = Some(result);
}
Err(error) => {
validation_time_ms = Some(started.elapsed().as_secs_f64() * 1_000.0);
self.notify_error(
&request,
Operation::Validate,
error.clone(),
None,
Some(503),
);
if self.config.failure_mode == FailureMode::Closed {
tracing::warn!(error = %error, "ReqKey validation failed closed");
return AuthorizationOutcome::Denied(self.denial(
503,
DenialCode::ReqKeyUnavailable,
None,
));
}
failure = Some(ReqKeyFailure(error));
}
}
}
AuthorizationOutcome::Authorized(AuthorizedRequest {
decision,
failure,
request,
consumer_key,
handler_started: Instant::now(),
validation_time_ms,
})
}
pub async fn record(&self, authorized: AuthorizedRequest, mut response: ResponseContext) {
if !self.config.mode.ingests() {
return;
}
if response.latency_ms == 0 {
response.latency_ms = authorized.elapsed_ms();
}
let request_id = authorized
.decision
.as_ref()
.and_then(|decision| decision.request_id.clone())
.or_else(|| {
self.config
.request_id_resolver
.as_ref()
.and_then(|resolver| resolver(&authorized.request))
.and_then(nonempty)
});
match self.ingest_event(
&authorized.request,
request_id.clone(),
authorized.consumer_key.as_deref(),
&response,
) {
Ok(event) => {
if let Err(error) = self.client.ingest(&event).await {
tracing::warn!(error = %error, "ReqKey analytics ingestion failed");
self.notify_error(
&authorized.request,
Operation::Ingest,
error,
request_id,
Some(response.status_code),
);
}
}
Err(error) => {
tracing::warn!(error = %error, "ReqKey analytics event could not be built");
self.notify_error(
&authorized.request,
Operation::Ingest,
error,
request_id,
Some(response.status_code),
);
}
}
}
fn handle_validation_error(
&self,
request: RequestContext,
consumer_key: Option<String>,
error: Error,
validation_time_ms: Option<f64>,
) -> AuthorizationOutcome {
self.notify_error(
&request,
Operation::Validate,
error.clone(),
None,
Some(503),
);
if self.config.failure_mode == FailureMode::Closed {
AuthorizationOutcome::Denied(self.denial(503, DenialCode::ReqKeyUnavailable, None))
} else {
AuthorizationOutcome::Authorized(AuthorizedRequest {
decision: None,
failure: Some(ReqKeyFailure(error)),
request,
consumer_key,
handler_started: Instant::now(),
validation_time_ms,
})
}
}
fn applies_to(&self, request: &RequestContext) -> bool {
self.config.enabled
&& !self.config.skip_methods.contains(&request.method)
&& !self
.config
.exclude_paths
.iter()
.any(|pattern| path_matches(&request.path, pattern))
&& self
.config
.should_protect
.as_ref()
.is_none_or(|predicate| predicate(request))
}
fn consumer_key(&self, request: &RequestContext) -> Option<String> {
let value = if let Some(resolver) = &self.config.custom_key_resolver {
resolver(request)
} else {
match &self.config.key_location {
KeyLocation::Header(name) => HeaderName::from_str(name)
.ok()
.and_then(|name| request.headers.get(name))
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned),
KeyLocation::Query(name) => request.query.as_deref().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == name)
.map(|(_, value)| value.into_owned())
}),
KeyLocation::Cookie(name) => request
.headers
.get(header::COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|cookies| cookie_value(cookies, name)),
}
};
extract_credential(value.as_deref(), self.config.key_scheme)
}
fn credit_cost(&self, request: &RequestContext) -> Result<u64> {
match &self.config.credits {
CreditCost::Fixed(value) => Ok(*value),
CreditCost::Dynamic(resolver) => resolver(request),
}
}
fn resource_path(&self, request: &RequestContext) -> Result<String> {
let resource = if let Some(resolver) = &self.config.path_resolver {
resolver(request)?
} else {
request.path.clone()
};
nonempty(resource)
.ok_or_else(|| Error::Configuration("the path resolver returned an empty path".into()))
}
fn denial(
&self,
status_code: u16,
error: DenialCode,
retry_after: Option<u64>,
) -> DeniedResponse {
DeniedResponse {
status_code,
error,
message: self
.config
.error_messages
.get(&error)
.cloned()
.unwrap_or_default(),
retry_after,
}
}
async fn ingest_denied(
&self,
request: &RequestContext,
decision: Option<&VerificationResult>,
consumer_key: Option<&str>,
denial: &DeniedResponse,
) {
if self.config.mode != Mode::Both || !self.config.ingest_denied_requests {
return;
}
let response = ResponseContext::new(denial.status_code)
.with_headers(denial.headers())
.with_latency_ms(duration_millis(request.received_at.elapsed()));
let response = if self.config.capture_response_body {
response.with_body(denial.json_body())
} else {
response
};
let request_id = decision
.and_then(|value| value.request_id.clone())
.or_else(|| {
self.config
.request_id_resolver
.as_ref()
.and_then(|resolver| resolver(request))
.and_then(nonempty)
});
let event = self.ingest_event(request, request_id.clone(), consumer_key, &response);
match event {
Ok(event) => {
if let Err(error) = self.client.ingest(&event).await {
tracing::warn!(error = %error, "ReqKey denied-request ingestion failed");
self.notify_error(
request,
Operation::Ingest,
error,
request_id,
Some(denial.status_code),
);
}
}
Err(error) => self.notify_error(
request,
Operation::Ingest,
error,
request_id,
Some(denial.status_code),
),
}
}
fn ingest_event(
&self,
request: &RequestContext,
request_id: Option<String>,
consumer_key: Option<&str>,
response: &ResponseContext,
) -> Result<IngestEvent> {
let resource = self.resource_path(request)?;
let (query_params, safe_query) = self.captured_query(request);
let path =
safe_query.map_or_else(|| resource.clone(), |query| format!("{resource}?{query}"));
let mut builder = IngestEvent::builder()
.api_id(&self.config.api_id)
.method(request.method.as_str())
.endpoint(resource)
.path(path)
.status_code(response.status_code)
.latency_ms(response.latency_ms);
if let Some(request_id) = request_id {
builder = builder.request_id(request_id);
}
if let Some(key) = consumer_key {
builder = builder.api_key(key);
}
if let Some(params) = query_params {
builder = builder.query_params(params);
}
if self.config.capture_request_headers {
builder = builder.request_headers(self.filtered_headers(&request.headers));
}
if self.config.capture_response_headers {
builder = builder.response_headers(self.filtered_headers(&response.headers));
}
if self.config.capture_request_body && textual_content(&request.headers) {
if let Some(body) = &request.request_body {
builder = builder.request_body(body);
}
}
if self.config.capture_response_body && textual_content(&response.headers) {
if let Some(body) = &response.response_body {
builder = builder.response_body(body);
}
}
if self.config.capture_user_agent {
if let Some(value) = header_string(&request.headers, header::USER_AGENT) {
builder = builder.user_agent(value);
}
}
if let Some(resolver) = &self.config.consumer_name_resolver {
if let Some(value) = resolver(request).and_then(nonempty) {
builder = builder.consumer_name(value);
}
}
if self.config.capture_client_ip {
let value = self
.config
.client_ip_resolver
.as_ref()
.and_then(|resolver| resolver(request))
.and_then(nonempty)
.or_else(|| request.client_ip.clone().and_then(nonempty))
.or_else(|| client_ip_from_headers(&request.headers));
if let Some(value) = value {
builder = builder.client_ip(value);
}
}
builder.build()
}
fn captured_query(
&self,
request: &RequestContext,
) -> (Option<BTreeMap<String, Value>>, Option<String>) {
if !self.config.capture_query_params {
return (None, None);
}
let Some(query) = &request.query else {
return (Some(BTreeMap::new()), None);
};
let pairs: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes())
.filter(|(key, _)| {
!(self.config.key_location.is_query() && key == self.config.key_location.name())
})
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let values = pairs
.iter()
.cloned()
.map(|(key, value)| (key, Value::String(value)))
.collect();
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(
pairs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.finish();
(Some(values), (!encoded.is_empty()).then_some(encoded))
}
fn filtered_headers(&self, headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
.filter(|(name, _)| {
!self
.config
.excluded_headers
.contains(name.as_str().to_ascii_lowercase().as_str())
})
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.as_str().to_owned(), value.to_owned()))
})
.collect()
}
fn notify_error(
&self,
request: &RequestContext,
operation: Operation,
error: Error,
request_id: Option<String>,
status_code: Option<u16>,
) {
let Some(callback) = &self.config.on_error else {
return;
};
let event = MiddlewareErrorEvent {
operation,
error,
method: request.method.clone(),
path: request.path.clone(),
request_id,
status_code,
};
if catch_unwind(AssertUnwindSafe(|| callback(&event))).is_err() {
tracing::error!("ReqKey middleware error callback panicked");
}
}
}
pub fn decision_headers(
decision: Option<&VerificationResult>,
validation_time_ms: Option<f64>,
) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(decision) = decision {
insert_header(
&mut headers,
"x-reqkey-request-id",
decision.request_id.as_deref(),
);
insert_header(
&mut headers,
"x-reqkey-credits-limit",
decision
.credits_limit
.map(|value| value.to_string())
.as_deref(),
);
insert_header(
&mut headers,
"x-reqkey-credits-remaining",
decision
.credits_remaining
.map(|value| value.to_string())
.as_deref(),
);
}
if let Some(milliseconds) = validation_time_ms {
insert_header(
&mut headers,
"x-reqkey-validation-time-ms",
Some(&format!("{milliseconds:.3}")),
);
}
headers
}
pub fn path_matches(path: &str, pattern: &str) -> bool {
pattern
.strip_suffix('*')
.map_or(path == pattern, |prefix| path.starts_with(prefix))
}
pub fn extract_credential(value: Option<&str>, scheme: KeyScheme) -> Option<String> {
let value = value?.trim();
if value.is_empty() {
return None;
}
if scheme == KeyScheme::Raw {
return Some(value.to_owned());
}
let (prefix, credential) = value.split_once(char::is_whitespace)?;
if !prefix.eq_ignore_ascii_case("bearer") || credential.trim().is_empty() {
return None;
}
Some(credential.trim().to_owned())
}
pub fn client_ip_from_headers(headers: &HeaderMap) -> Option<String> {
for name in PROXY_CLIENT_IP_HEADERS {
let Some(value) = headers.get(name).and_then(|value| value.to_str().ok()) else {
continue;
};
let candidates: Vec<&str> = if name == "forwarded" {
value
.split(',')
.flat_map(|element| element.split(';'))
.filter_map(|parameter| {
let (name, raw) = parameter.trim().split_once('=')?;
name.eq_ignore_ascii_case("for").then_some(raw)
})
.collect()
} else {
value.split(',').collect()
};
for candidate in candidates {
if let Some(ip) = parse_ip(candidate) {
return Some(ip);
}
}
}
None
}
fn denial_for(decision: &VerificationResult) -> (u16, DenialCode) {
match decision.reason {
VerificationReason::InsufficientCredits => (402, DenialCode::InsufficientCredits),
VerificationReason::RateLimited => (429, DenialCode::RateLimited),
VerificationReason::Forbidden => (403, DenialCode::AccessDenied),
_ => (401, DenialCode::InvalidApiKey),
}
}
fn default_excluded_headers() -> BTreeSet<String> {
[
"authorization",
"cookie",
"proxy-authorization",
"set-cookie",
"x-api-key",
]
.into_iter()
.map(ToOwned::to_owned)
.collect()
}
fn cookie_value(cookies: &str, expected: &str) -> Option<String> {
cookies.split(';').find_map(|cookie| {
let (name, value) = cookie.trim().split_once('=')?;
(name == expected).then(|| value.to_owned())
})
}
fn parse_ip(raw: &str) -> Option<String> {
let mut candidate = raw.trim().trim_matches('"');
if candidate.is_empty()
|| candidate.eq_ignore_ascii_case("unknown")
|| candidate.starts_with('_')
{
return None;
}
if let Some(without_bracket) = candidate.strip_prefix('[') {
candidate = without_bracket.split_once(']')?.0;
}
if let Ok(ip) = IpAddr::from_str(candidate) {
return Some(ip.to_string());
}
if candidate.matches(':').count() == 1 {
return IpAddr::from_str(candidate.split_once(':')?.0)
.ok()
.map(|ip| ip.to_string());
}
None
}
fn textual_content(headers: &HeaderMap) -> bool {
let Some(content_type) = header_string(headers, header::CONTENT_TYPE) else {
return true;
};
let content_type = content_type.to_ascii_lowercase();
[
"json",
"text/",
"xml",
"javascript",
"x-www-form-urlencoded",
]
.iter()
.any(|marker| content_type.contains(marker))
}
fn header_string(headers: &HeaderMap, name: HeaderName) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
}
fn insert_header(headers: &mut HeaderMap, name: &'static str, value: Option<&str>) {
let Some(value) = value else {
return;
};
if let Ok(value) = HeaderValue::from_str(value) {
headers.insert(HeaderName::from_static(name), value);
}
}
fn nonempty(value: String) -> Option<String> {
(!value.trim().is_empty()).then_some(value)
}
fn duration_millis(duration: std::time::Duration) -> u64 {
duration.as_millis().try_into().unwrap_or(u64::MAX)
}
fn retry_after_seconds(seconds: f64) -> u64 {
if seconds.is_nan() || seconds <= 0.0 {
return 0;
}
if seconds.is_infinite() {
return u64::MAX;
}
std::time::Duration::from_secs_f64(seconds.min(std::time::Duration::MAX.as_secs_f64()))
.as_secs()
}
#[cfg(test)]
mod tests {
use super::{cookie_value, extract_credential, path_matches, retry_after_seconds, KeyScheme};
#[test]
fn exact_and_prefix_paths_have_distinct_semantics() {
assert!(path_matches("/health", "/health"));
assert!(!path_matches("/health/live", "/health"));
assert!(path_matches("/docs/openapi.json", "/docs/*"));
}
#[test]
fn bearer_and_cookie_helpers_are_strict() {
assert_eq!(
extract_credential(Some("bearer consumer_1"), KeyScheme::Bearer).as_deref(),
Some("consumer_1")
);
assert_eq!(
cookie_value("session=one; api_key=consumer_1", "api_key").as_deref(),
Some("consumer_1")
);
}
#[test]
fn retry_after_is_clamped_and_rounded_down() {
assert_eq!(retry_after_seconds(-1.0), 0);
assert_eq!(retry_after_seconds(2.9), 2);
assert_eq!(retry_after_seconds(f64::INFINITY), u64::MAX);
}
}