mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-30 09:38:33 +08:00
feat(injector): add support for new crate - injector for external vault proxy (#8959)
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
887
crates/injector/src/injector.rs
Normal file
887
crates/injector/src/injector.rs
Normal file
@ -0,0 +1,887 @@
|
||||
pub mod core {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use common_utils::request::{Method, RequestBuilder, RequestContent};
|
||||
use error_stack::ResultExt;
|
||||
use masking::{self, ExposeInterface};
|
||||
use nom::{
|
||||
bytes::complete::{tag, take_while1},
|
||||
character::complete::{char, multispace0},
|
||||
sequence::{delimited, preceded, terminated},
|
||||
IResult,
|
||||
};
|
||||
use router_env::{instrument, logger, tracing};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate as injector_types;
|
||||
use crate::{ContentType, InjectorRequest, InjectorResponse};
|
||||
|
||||
impl From<injector_types::HttpMethod> for Method {
|
||||
fn from(method: injector_types::HttpMethod) -> Self {
|
||||
match method {
|
||||
injector_types::HttpMethod::GET => Self::Get,
|
||||
injector_types::HttpMethod::POST => Self::Post,
|
||||
injector_types::HttpMethod::PUT => Self::Put,
|
||||
injector_types::HttpMethod::PATCH => Self::Patch,
|
||||
injector_types::HttpMethod::DELETE => Self::Delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy configuration structure (copied from hyperswitch_interfaces to make injector standalone)
|
||||
#[derive(Debug, serde::Deserialize, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct Proxy {
|
||||
/// The URL of the HTTP proxy server.
|
||||
pub http_url: Option<String>,
|
||||
/// The URL of the HTTPS proxy server.
|
||||
pub https_url: Option<String>,
|
||||
/// The timeout duration (in seconds) for idle connections in the proxy pool.
|
||||
pub idle_pool_connection_timeout: Option<u64>,
|
||||
/// A comma-separated list of hosts that should bypass the proxy.
|
||||
pub bypass_proxy_hosts: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Proxy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
http_url: Default::default(),
|
||||
https_url: Default::default(),
|
||||
idle_pool_connection_timeout: Some(90),
|
||||
bypass_proxy_hosts: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified HTTP client for injector (copied from external_services to make injector standalone)
|
||||
/// This is a minimal implementation that covers the essential functionality needed by injector
|
||||
#[instrument(skip_all)]
|
||||
pub async fn send_request(
|
||||
client_proxy: &Proxy,
|
||||
request: common_utils::request::Request,
|
||||
_option_timeout_secs: Option<u64>,
|
||||
) -> error_stack::Result<reqwest::Response, InjectorError> {
|
||||
logger::info!("Making HTTP request using standalone injector HTTP client");
|
||||
|
||||
// Create reqwest client with proxy configuration
|
||||
let mut client_builder = reqwest::Client::builder();
|
||||
|
||||
// Configure proxy if provided
|
||||
if let Some(proxy_url) = &client_proxy.https_url {
|
||||
let proxy = reqwest::Proxy::https(proxy_url).map_err(|e| {
|
||||
logger::error!("Failed to configure HTTPS proxy: {}", e);
|
||||
error_stack::Report::new(InjectorError::HttpRequestFailed)
|
||||
})?;
|
||||
client_builder = client_builder.proxy(proxy);
|
||||
}
|
||||
|
||||
if let Some(proxy_url) = &client_proxy.http_url {
|
||||
let proxy = reqwest::Proxy::http(proxy_url).map_err(|e| {
|
||||
logger::error!("Failed to configure HTTP proxy: {}", e);
|
||||
error_stack::Report::new(InjectorError::HttpRequestFailed)
|
||||
})?;
|
||||
client_builder = client_builder.proxy(proxy);
|
||||
}
|
||||
|
||||
let client = client_builder.build().map_err(|e| {
|
||||
logger::error!("Failed to build HTTP client: {}", e);
|
||||
error_stack::Report::new(InjectorError::HttpRequestFailed)
|
||||
})?;
|
||||
|
||||
// Build the request
|
||||
let method = match request.method {
|
||||
Method::Get => reqwest::Method::GET,
|
||||
Method::Post => reqwest::Method::POST,
|
||||
Method::Put => reqwest::Method::PUT,
|
||||
Method::Patch => reqwest::Method::PATCH,
|
||||
Method::Delete => reqwest::Method::DELETE,
|
||||
};
|
||||
|
||||
let mut req_builder = client.request(method, &request.url);
|
||||
|
||||
// Add headers
|
||||
for (key, value) in &request.headers {
|
||||
let header_value = match value {
|
||||
masking::Maskable::Masked(secret) => secret.clone().expose(),
|
||||
masking::Maskable::Normal(normal) => normal.clone(),
|
||||
};
|
||||
req_builder = req_builder.header(key, header_value);
|
||||
}
|
||||
|
||||
// Add body if present
|
||||
if let Some(body) = request.body {
|
||||
match body {
|
||||
RequestContent::Json(payload) => {
|
||||
req_builder = req_builder.json(&payload);
|
||||
}
|
||||
RequestContent::FormUrlEncoded(payload) => {
|
||||
req_builder = req_builder.form(&payload);
|
||||
}
|
||||
RequestContent::RawBytes(payload) => {
|
||||
req_builder = req_builder.body(payload);
|
||||
}
|
||||
_ => {
|
||||
logger::warn!("Unsupported request content type, using raw bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send the request
|
||||
let response = req_builder.send().await.map_err(|e| {
|
||||
logger::error!("HTTP request failed: {}", e);
|
||||
error_stack::Report::new(InjectorError::HttpRequestFailed)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InjectorError {
|
||||
#[error("Token replacement failed: {0}")]
|
||||
TokenReplacementFailed(String),
|
||||
#[error("HTTP request failed")]
|
||||
HttpRequestFailed,
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(String),
|
||||
#[error("Invalid template: {0}")]
|
||||
InvalidTemplate(String),
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn injector_core(
|
||||
request: InjectorRequest,
|
||||
) -> error_stack::Result<InjectorResponse, InjectorError> {
|
||||
logger::info!("Starting injector_core processing");
|
||||
let injector = Injector::new();
|
||||
injector.injector_core(request).await
|
||||
}
|
||||
|
||||
/// Represents a token reference found in a template string
|
||||
#[derive(Debug)]
|
||||
pub struct TokenReference {
|
||||
/// The field name to be replaced (without the {{$}} wrapper)
|
||||
pub field: String,
|
||||
}
|
||||
|
||||
/// Parses a single token reference from a string using nom parser combinators
|
||||
///
|
||||
/// Expects tokens in the format `{{$field_name}}` where field_name contains
|
||||
/// only alphanumeric characters and underscores.
|
||||
pub fn parse_token(input: &str) -> IResult<&str, TokenReference> {
|
||||
let (input, field) = delimited(
|
||||
tag("{{"),
|
||||
preceded(
|
||||
multispace0,
|
||||
preceded(
|
||||
char('$'),
|
||||
terminated(
|
||||
take_while1(|c: char| c.is_alphanumeric() || c == '_'),
|
||||
multispace0,
|
||||
),
|
||||
),
|
||||
),
|
||||
tag("}}"),
|
||||
)(input)?;
|
||||
Ok((
|
||||
input,
|
||||
TokenReference {
|
||||
field: field.to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Finds all token references in a string using nom parser
|
||||
///
|
||||
/// Scans through the entire input string and extracts all valid token references.
|
||||
/// Returns a vector of TokenReference structs containing the field names.
|
||||
pub fn find_all_tokens(input: &str) -> Vec<TokenReference> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current_input = input;
|
||||
|
||||
while !current_input.is_empty() {
|
||||
if let Ok((remaining, token_ref)) = parse_token(current_input) {
|
||||
tokens.push(token_ref);
|
||||
current_input = remaining;
|
||||
} else {
|
||||
// Move forward one character if no token found
|
||||
if let Some((_, rest)) = current_input.split_at_checked(1) {
|
||||
current_input = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Recursively searches for a field in vault data JSON structure
|
||||
///
|
||||
/// Performs a depth-first search through the JSON object hierarchy to find
|
||||
/// a field with the specified name. Returns the first matching value found.
|
||||
pub fn find_field_recursively_in_vault_data(
|
||||
obj: &serde_json::Map<String, Value>,
|
||||
field_name: &str,
|
||||
) -> Option<Value> {
|
||||
obj.get(field_name).cloned().or_else(|| {
|
||||
obj.values()
|
||||
.filter_map(|val| {
|
||||
if let Value::Object(inner_obj) = val {
|
||||
find_field_recursively_in_vault_data(inner_obj, field_name)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TokenInjector {
|
||||
async fn injector_core(
|
||||
&self,
|
||||
request: InjectorRequest,
|
||||
) -> error_stack::Result<InjectorResponse, InjectorError>;
|
||||
}
|
||||
|
||||
pub struct Injector;
|
||||
|
||||
impl Injector {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Processes a string template and replaces token references with vault data
|
||||
#[instrument(skip_all)]
|
||||
pub fn interpolate_string_template_with_vault_data(
|
||||
&self,
|
||||
template: String,
|
||||
vault_data: &Value,
|
||||
vault_connector: &injector_types::VaultConnectors,
|
||||
) -> error_stack::Result<String, InjectorError> {
|
||||
// Find all tokens using nom parser
|
||||
let tokens = find_all_tokens(&template);
|
||||
let mut result = template;
|
||||
|
||||
for token_ref in tokens.into_iter() {
|
||||
let extracted_field_value = self.extract_field_from_vault_data(
|
||||
vault_data,
|
||||
&token_ref.field,
|
||||
vault_connector,
|
||||
)?;
|
||||
let token_str = match extracted_field_value {
|
||||
Value::String(token_value) => token_value,
|
||||
_ => serde_json::to_string(&extracted_field_value).unwrap_or_default(),
|
||||
};
|
||||
|
||||
// Replace the token in the result string
|
||||
let token_pattern = format!("{{{{${}}}}}", token_ref.field);
|
||||
result = result.replace(&token_pattern, &token_str);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub fn interpolate_token_references_with_vault_data(
|
||||
&self,
|
||||
value: Value,
|
||||
vault_data: &Value,
|
||||
vault_connector: &injector_types::VaultConnectors,
|
||||
) -> error_stack::Result<Value, InjectorError> {
|
||||
match value {
|
||||
Value::Object(obj) => {
|
||||
let new_obj = obj
|
||||
.into_iter()
|
||||
.map(|(key, val)| {
|
||||
self.interpolate_token_references_with_vault_data(
|
||||
val,
|
||||
vault_data,
|
||||
vault_connector,
|
||||
)
|
||||
.map(|processed| (key, processed))
|
||||
})
|
||||
.collect::<error_stack::Result<serde_json::Map<_, _>, InjectorError>>()?;
|
||||
Ok(Value::Object(new_obj))
|
||||
}
|
||||
Value::String(s) => {
|
||||
let processed_string = self.interpolate_string_template_with_vault_data(
|
||||
s,
|
||||
vault_data,
|
||||
vault_connector,
|
||||
)?;
|
||||
Ok(Value::String(processed_string))
|
||||
}
|
||||
_ => Ok(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
fn extract_field_from_vault_data(
|
||||
&self,
|
||||
vault_data: &Value,
|
||||
field_name: &str,
|
||||
vault_connector: &injector_types::VaultConnectors,
|
||||
) -> error_stack::Result<Value, InjectorError> {
|
||||
logger::debug!(
|
||||
"Extracting field '{}' from vault data using vault type {:?}",
|
||||
field_name,
|
||||
vault_connector
|
||||
);
|
||||
|
||||
match vault_data {
|
||||
Value::Object(obj) => {
|
||||
let raw_value = find_field_recursively_in_vault_data(obj, field_name)
|
||||
.ok_or_else(|| {
|
||||
error_stack::Report::new(InjectorError::TokenReplacementFailed(
|
||||
format!("Field '{field_name}' not found"),
|
||||
))
|
||||
})?;
|
||||
|
||||
// Apply vault-specific token transformation
|
||||
self.apply_vault_specific_transformation(raw_value, vault_connector, field_name)
|
||||
}
|
||||
_ => Err(error_stack::Report::new(
|
||||
InjectorError::TokenReplacementFailed(
|
||||
"Vault data is not a valid JSON object".to_string(),
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
fn apply_vault_specific_transformation(
|
||||
&self,
|
||||
extracted_field_value: Value,
|
||||
vault_connector: &injector_types::VaultConnectors,
|
||||
field_name: &str,
|
||||
) -> error_stack::Result<Value, InjectorError> {
|
||||
match vault_connector {
|
||||
injector_types::VaultConnectors::VGS => {
|
||||
logger::debug!(
|
||||
"VGS vault: Using direct token replacement for field '{}'",
|
||||
field_name
|
||||
);
|
||||
Ok(extracted_field_value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn make_http_request(
|
||||
&self,
|
||||
config: &injector_types::DomainConnectionConfig,
|
||||
payload: &str,
|
||||
content_type: &ContentType,
|
||||
) -> error_stack::Result<Value, InjectorError> {
|
||||
logger::info!(
|
||||
method = ?config.http_method,
|
||||
base_url = %config.base_url,
|
||||
endpoint = %config.endpoint_path,
|
||||
content_type = ?content_type,
|
||||
payload_length = payload.len(),
|
||||
headers_count = config.headers.len(),
|
||||
"Making HTTP request to connector"
|
||||
);
|
||||
// Validate inputs first
|
||||
if config.endpoint_path.is_empty() {
|
||||
logger::error!("Endpoint path is empty");
|
||||
Err(error_stack::Report::new(InjectorError::InvalidTemplate(
|
||||
"Endpoint path cannot be empty".to_string(),
|
||||
)))?;
|
||||
}
|
||||
|
||||
// Construct URL safely by joining base URL with endpoint path
|
||||
let url = config.base_url.join(&config.endpoint_path).map_err(|e| {
|
||||
logger::error!("Failed to join base URL with endpoint path: {}", e);
|
||||
error_stack::Report::new(InjectorError::InvalidTemplate(format!(
|
||||
"Invalid URL construction: {e}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
logger::debug!("Constructed URL: {}", url);
|
||||
|
||||
// Convert headers to common_utils Headers format safely
|
||||
let headers: Vec<(String, masking::Maskable<String>)> = config
|
||||
.headers
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, masking::Maskable::new_normal(v.expose().clone())))
|
||||
.collect();
|
||||
|
||||
// Determine method and request content
|
||||
let method = Method::from(config.http_method);
|
||||
|
||||
// Determine request content based on content type with error handling
|
||||
let request_content = match content_type {
|
||||
ContentType::ApplicationJson => {
|
||||
// Try to parse as JSON, fallback to raw string
|
||||
match serde_json::from_str::<Value>(payload) {
|
||||
Ok(json) => Some(RequestContent::Json(Box::new(json))),
|
||||
Err(e) => {
|
||||
logger::debug!(
|
||||
"Failed to parse payload as JSON: {}, falling back to raw bytes",
|
||||
e
|
||||
);
|
||||
Some(RequestContent::RawBytes(payload.as_bytes().to_vec()))
|
||||
}
|
||||
}
|
||||
}
|
||||
ContentType::ApplicationXWwwFormUrlencoded => {
|
||||
// Parse form data safely
|
||||
let form_data: HashMap<String, String> =
|
||||
url::form_urlencoded::parse(payload.as_bytes())
|
||||
.into_owned()
|
||||
.collect();
|
||||
Some(RequestContent::FormUrlEncoded(Box::new(form_data)))
|
||||
}
|
||||
ContentType::ApplicationXml | ContentType::TextXml => {
|
||||
Some(RequestContent::RawBytes(payload.as_bytes().to_vec()))
|
||||
}
|
||||
ContentType::TextPlain => {
|
||||
Some(RequestContent::RawBytes(payload.as_bytes().to_vec()))
|
||||
}
|
||||
};
|
||||
|
||||
// Build request safely
|
||||
let mut request_builder = RequestBuilder::new()
|
||||
.method(method)
|
||||
.url(url.as_str())
|
||||
.headers(headers);
|
||||
|
||||
if let Some(content) = request_content {
|
||||
request_builder = request_builder.set_body(content);
|
||||
}
|
||||
|
||||
// Add certificate configuration if provided
|
||||
if let Some(cert_content) = &config.client_cert {
|
||||
logger::debug!("Adding client certificate content");
|
||||
request_builder = request_builder.add_certificate(Some(cert_content.clone()));
|
||||
}
|
||||
|
||||
if let Some(key_content) = &config.client_key {
|
||||
logger::debug!("Adding client private key content");
|
||||
request_builder = request_builder.add_certificate_key(Some(key_content.clone()));
|
||||
}
|
||||
|
||||
if let Some(ca_content) = &config.ca_cert {
|
||||
logger::debug!("Adding CA certificate content");
|
||||
request_builder = request_builder.add_ca_certificate_pem(Some(ca_content.clone()));
|
||||
}
|
||||
|
||||
// Log certificate configuration (but not the actual content)
|
||||
logger::info!(
|
||||
has_client_cert = config.client_cert.is_some(),
|
||||
has_client_key = config.client_key.is_some(),
|
||||
has_ca_cert = config.ca_cert.is_some(),
|
||||
insecure = config.insecure.unwrap_or(false),
|
||||
cert_format = ?config.cert_format,
|
||||
"Certificate configuration applied"
|
||||
);
|
||||
|
||||
let request = request_builder.build();
|
||||
|
||||
let proxy = if let Some(proxy_url) = &config.proxy_url {
|
||||
logger::debug!("Using proxy: {}", proxy_url);
|
||||
// Determine if it's HTTP or HTTPS proxy based on URL scheme
|
||||
if proxy_url.scheme() == "https" {
|
||||
Proxy {
|
||||
http_url: None,
|
||||
https_url: Some(proxy_url.to_string()),
|
||||
idle_pool_connection_timeout: Some(90),
|
||||
bypass_proxy_hosts: None,
|
||||
}
|
||||
} else {
|
||||
Proxy {
|
||||
http_url: Some(proxy_url.to_string()),
|
||||
https_url: None,
|
||||
idle_pool_connection_timeout: Some(90),
|
||||
bypass_proxy_hosts: None,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger::debug!("No proxy configured, using direct connection");
|
||||
Proxy::default()
|
||||
};
|
||||
|
||||
// Send request using local standalone http client
|
||||
logger::debug!("Sending HTTP request to connector");
|
||||
let response = send_request(&proxy, request, None).await?;
|
||||
|
||||
logger::info!(
|
||||
status_code = response.status().as_u16(),
|
||||
"Received response from connector"
|
||||
);
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.change_context(InjectorError::HttpRequestFailed)?;
|
||||
|
||||
logger::debug!(
|
||||
response_length = response_text.len(),
|
||||
"Processing connector response"
|
||||
);
|
||||
|
||||
// Try to parse as JSON, fallback to string value with error logging
|
||||
match serde_json::from_str::<Value>(&response_text) {
|
||||
Ok(json) => {
|
||||
logger::debug!("Successfully parsed response as JSON");
|
||||
Ok(json)
|
||||
}
|
||||
Err(e) => {
|
||||
logger::debug!(
|
||||
"Failed to parse response as JSON: {}, returning as string",
|
||||
e
|
||||
);
|
||||
Ok(Value::String(response_text))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Injector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenInjector for Injector {
|
||||
#[instrument(skip_all)]
|
||||
async fn injector_core(
|
||||
&self,
|
||||
request: InjectorRequest,
|
||||
) -> error_stack::Result<InjectorResponse, InjectorError> {
|
||||
logger::info!("Starting token injection process");
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Convert API model to domain model
|
||||
let domain_request: injector_types::DomainInjectorRequest = request.into();
|
||||
|
||||
// Extract token data from SecretSerdeValue for vault data lookup
|
||||
let vault_data = domain_request
|
||||
.token_data
|
||||
.specific_token_data
|
||||
.expose()
|
||||
.clone();
|
||||
|
||||
logger::debug!(
|
||||
template_length = domain_request.connector_payload.template.len(),
|
||||
vault_connector = ?domain_request.token_data.vault_connector,
|
||||
"Processing token injection request"
|
||||
);
|
||||
|
||||
// Process template string directly with vault-specific logic
|
||||
let processed_payload = self.interpolate_string_template_with_vault_data(
|
||||
domain_request.connector_payload.template,
|
||||
&vault_data,
|
||||
&domain_request.token_data.vault_connector,
|
||||
)?;
|
||||
|
||||
logger::debug!(
|
||||
processed_payload_length = processed_payload.len(),
|
||||
"Token replacement completed"
|
||||
);
|
||||
|
||||
// Determine content type from headers or default to form-urlencoded
|
||||
let content_type = domain_request
|
||||
.connection_config
|
||||
.headers
|
||||
.get("Content-Type")
|
||||
.and_then(|ct| match ct.clone().expose().as_str() {
|
||||
"application/json" => Some(ContentType::ApplicationJson),
|
||||
"application/x-www-form-urlencoded" => {
|
||||
Some(ContentType::ApplicationXWwwFormUrlencoded)
|
||||
}
|
||||
"application/xml" => Some(ContentType::ApplicationXml),
|
||||
"text/xml" => Some(ContentType::TextXml),
|
||||
"text/plain" => Some(ContentType::TextPlain),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(ContentType::ApplicationXWwwFormUrlencoded);
|
||||
|
||||
// Make HTTP request to connector and return raw response
|
||||
let response_data = self
|
||||
.make_http_request(
|
||||
&domain_request.connection_config,
|
||||
&processed_payload,
|
||||
&content_type,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
logger::info!(
|
||||
duration_ms = elapsed.as_millis(),
|
||||
response_size = serde_json::to_string(&response_data)
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0),
|
||||
"Token injection completed successfully"
|
||||
);
|
||||
|
||||
// Return the raw connector response for connector-agnostic handling
|
||||
Ok(response_data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export all items
|
||||
pub use core::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use router_env::logger;
|
||||
|
||||
use super::core::*;
|
||||
use crate::*;
|
||||
|
||||
#[test]
|
||||
fn test_token_parsing() {
|
||||
let result = parse_token("{{$card_number}}");
|
||||
assert!(result.is_ok());
|
||||
let (_, token_ref) = result.unwrap();
|
||||
assert_eq!(token_ref.field, "card_number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_interpolation() {
|
||||
let injector = Injector::new();
|
||||
let template = serde_json::Value::String("card_number={{$card_number}}&cvv={{$cvv}}&expiry={{$exp_month}}/{{$exp_year}}&amount=50¤cy=USD&transaction_type=purchase".to_string());
|
||||
|
||||
let vault_data = serde_json::json!({
|
||||
"card_number": "TEST_card123",
|
||||
"cvv": "TEST_cvv456",
|
||||
"exp_month": "TEST_12",
|
||||
"exp_year": "TEST_2026"
|
||||
});
|
||||
|
||||
// Test with VGS vault (direct replacement)
|
||||
let vault_connector = VaultConnectors::VGS;
|
||||
let result = injector
|
||||
.interpolate_token_references_with_vault_data(template, &vault_data, &vault_connector)
|
||||
.unwrap();
|
||||
assert_eq!(result, serde_json::Value::String("card_number=TEST_card123&cvv=TEST_cvv456&expiry=TEST_12/TEST_2026&amount=50¤cy=USD&transaction_type=purchase".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_field_not_found() {
|
||||
let injector = Injector::new();
|
||||
let template = serde_json::Value::String("{{$unknown_field}}".to_string());
|
||||
|
||||
let vault_data = serde_json::json!({
|
||||
"card_number": "TEST_CARD_NUMBER"
|
||||
});
|
||||
|
||||
let vault_connector = VaultConnectors::VGS;
|
||||
let result = injector.interpolate_token_references_with_vault_data(
|
||||
template,
|
||||
&vault_data,
|
||||
&vault_connector,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recursive_field_search() {
|
||||
let vault_data = serde_json::json!({
|
||||
"payment_method": {
|
||||
"card": {
|
||||
"number": "TEST_CARD_NUMBER"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let obj = vault_data.as_object().unwrap();
|
||||
let result = find_field_recursively_in_vault_data(obj, "number");
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(serde_json::Value::String("TEST_CARD_NUMBER".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_specific_token_handling() {
|
||||
let injector = Injector::new();
|
||||
let template = serde_json::Value::String("{{$card_number}}".to_string());
|
||||
|
||||
let vault_data = serde_json::json!({
|
||||
"card_number": "TOKEN"
|
||||
});
|
||||
|
||||
// Test VGS vault - direct replacement
|
||||
let vgs_result = injector
|
||||
.interpolate_token_references_with_vault_data(
|
||||
template.clone(),
|
||||
&vault_data,
|
||||
&VaultConnectors::VGS,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(vgs_result, serde_json::Value::String("TOKEN".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Integration test that requires network access"]
|
||||
async fn test_injector_core_integration() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Create test request
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert(
|
||||
"Content-Type".to_string(),
|
||||
masking::Secret::new("application/x-www-form-urlencoded".to_string()),
|
||||
);
|
||||
headers.insert(
|
||||
"Authorization".to_string(),
|
||||
masking::Secret::new("Bearer Test".to_string()),
|
||||
);
|
||||
|
||||
let specific_token_data = common_utils::pii::SecretSerdeValue::new(serde_json::json!({
|
||||
"card_number": "TEST_123",
|
||||
"cvv": "123",
|
||||
"exp_month": "12",
|
||||
"exp_year": "25"
|
||||
}));
|
||||
|
||||
let request = InjectorRequest {
|
||||
connector_payload: ConnectorPayload {
|
||||
template: "card_number={{$card_number}}&cvv={{$cvv}}&expiry={{$exp_month}}/{{$exp_year}}&amount=50¤cy=USD&transaction_type=purchase".to_string(),
|
||||
},
|
||||
token_data: TokenData {
|
||||
vault_connector: VaultConnectors::VGS,
|
||||
specific_token_data,
|
||||
},
|
||||
connection_config: ConnectionConfig {
|
||||
base_url: "https://api.stripe.com".parse().unwrap(),
|
||||
endpoint_path: "/v1/payment_intents".to_string(),
|
||||
http_method: HttpMethod::POST,
|
||||
headers,
|
||||
proxy_url: None, // Remove proxy that was causing issues
|
||||
// Certificate fields (None for basic test)
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
ca_cert: None, // Empty CA cert for testing
|
||||
insecure: None,
|
||||
cert_password: None,
|
||||
cert_format: None,
|
||||
max_response_size: None, // Use default
|
||||
},
|
||||
};
|
||||
|
||||
// Test the core function - this will make a real HTTP request to httpbin.org
|
||||
let result = injector_core(request).await;
|
||||
|
||||
// The request should succeed (httpbin.org should be accessible)
|
||||
if let Err(ref e) = result {
|
||||
logger::info!("Error: {e:?}");
|
||||
}
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"injector_core should succeed with valid request: {result:?}"
|
||||
);
|
||||
|
||||
let response = result.unwrap();
|
||||
|
||||
// Print the actual response for demonstration
|
||||
logger::info!("=== HTTP RESPONSE FROM HTTPBIN.ORG ===");
|
||||
logger::info!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&response).unwrap_or_default()
|
||||
);
|
||||
logger::info!("=======================================");
|
||||
|
||||
// Response should be a JSON value from httpbin.org
|
||||
assert!(
|
||||
response.is_object() || response.is_string(),
|
||||
"Response should be JSON object or string"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Integration test that requires network access"]
|
||||
async fn test_certificate_configuration() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert(
|
||||
"Content-Type".to_string(),
|
||||
masking::Secret::new("application/x-www-form-urlencoded".to_string()),
|
||||
);
|
||||
headers.insert(
|
||||
"Authorization".to_string(),
|
||||
masking::Secret::new("Bearer API_KEY".to_string()),
|
||||
);
|
||||
|
||||
let specific_token_data = common_utils::pii::SecretSerdeValue::new(serde_json::json!({
|
||||
"card_number": "TOKEN",
|
||||
"cvv": "123",
|
||||
"exp_month": "12",
|
||||
"exp_year": "25"
|
||||
}));
|
||||
|
||||
// Test with insecure flag (skip certificate verification)
|
||||
let request = InjectorRequest {
|
||||
connector_payload: ConnectorPayload {
|
||||
template: "card_number={{$card_number}}&cvv={{$cvv}}&expiry={{$exp_month}}/{{$exp_year}}&amount=50¤cy=USD&transaction_type=purchase".to_string(),
|
||||
},
|
||||
token_data: TokenData {
|
||||
vault_connector: VaultConnectors::VGS,
|
||||
specific_token_data,
|
||||
},
|
||||
connection_config: ConnectionConfig {
|
||||
base_url: "https://api.stripe.com".parse().unwrap(),
|
||||
endpoint_path: "/v1/payment_intents".to_string(),
|
||||
http_method: HttpMethod::POST,
|
||||
headers,
|
||||
proxy_url: Some("https://proxy.example.com:8443".parse().unwrap()),
|
||||
// Certificate configuration - using insecure for testing
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
ca_cert: Some(masking::Secret::new("CERT".to_string())),
|
||||
insecure: None, // This allows testing with self-signed certs
|
||||
cert_password: None,
|
||||
cert_format: None,
|
||||
max_response_size: None, // Use default
|
||||
},
|
||||
};
|
||||
|
||||
let result = injector_core(request).await;
|
||||
|
||||
// Should succeed even with insecure flag
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Certificate test should succeed: {result:?}"
|
||||
);
|
||||
|
||||
let response = result.unwrap();
|
||||
|
||||
// Print the actual response for demonstration
|
||||
logger::info!("=== CERTIFICATE TEST RESPONSE ===");
|
||||
logger::info!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&response).unwrap_or_default()
|
||||
);
|
||||
logger::info!("================================");
|
||||
|
||||
// Verify the token was replaced in the JSON
|
||||
// httpbin.org returns the request data in the 'data' or 'json' field
|
||||
let response_contains_token = if let Some(response_str) = response.as_str() {
|
||||
response_str.contains("TOKEN")
|
||||
} else if response.is_object() {
|
||||
// Check if the response contains our token in the request data
|
||||
let response_str = serde_json::to_string(&response).unwrap_or_default();
|
||||
response_str.contains("TOKEN")
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(
|
||||
response_contains_token,
|
||||
"Response should contain replaced token: {}",
|
||||
serde_json::to_string_pretty(&response).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
6
crates/injector/src/lib.rs
Normal file
6
crates/injector/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod injector;
|
||||
pub mod types;
|
||||
|
||||
// Re-export all functionality
|
||||
pub use injector::*;
|
||||
pub use types::*;
|
||||
220
crates/injector/src/types.rs
Normal file
220
crates/injector/src/types.rs
Normal file
@ -0,0 +1,220 @@
|
||||
pub mod models {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use common_utils::pii::SecretSerdeValue;
|
||||
use masking::Secret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
// Enums for the injector - making it standalone
|
||||
|
||||
/// Content types supported by the injector for HTTP requests
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
ApplicationJson,
|
||||
ApplicationXWwwFormUrlencoded,
|
||||
ApplicationXml,
|
||||
TextXml,
|
||||
TextPlain,
|
||||
}
|
||||
|
||||
/// HTTP methods supported by the injector
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum HttpMethod {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
PATCH,
|
||||
DELETE,
|
||||
}
|
||||
|
||||
/// Accept types supported by the injector for HTTP requests
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AcceptType {
|
||||
ApplicationJson,
|
||||
ApplicationXml,
|
||||
TextXml,
|
||||
TextPlain,
|
||||
Any,
|
||||
}
|
||||
|
||||
/// Vault connectors supported by the injector for token management
|
||||
///
|
||||
/// Currently supports VGS as the primary vault connector. While only VGS is
|
||||
/// implemented today, this enum structure is maintained for future extensibility
|
||||
/// to support additional vault providers (e.g., Basis Theory, Skyflow, etc.)
|
||||
/// without breaking API compatibility.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum VaultConnectors {
|
||||
/// VGS (Very Good Security) vault connector
|
||||
VGS,
|
||||
}
|
||||
|
||||
/// Token data containing vault-specific information for token replacement
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TokenData {
|
||||
/// The specific token data retrieved from the vault
|
||||
pub specific_token_data: SecretSerdeValue,
|
||||
/// The type of vault connector being used (e.g., VGS)
|
||||
pub vault_connector: VaultConnectors,
|
||||
}
|
||||
|
||||
/// Connector payload containing the template to be processed
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ConnectorPayload {
|
||||
/// Template string containing token references in the format {{$field_name}}
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
/// Configuration for HTTP connection to the external connector
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ConnectionConfig {
|
||||
/// Base URL of the connector endpoint
|
||||
pub base_url: Url,
|
||||
/// Path to append to the base URL for the specific endpoint
|
||||
pub endpoint_path: String,
|
||||
/// HTTP method to use for the request
|
||||
pub http_method: HttpMethod,
|
||||
/// HTTP headers to include in the request
|
||||
pub headers: HashMap<String, Secret<String>>,
|
||||
/// Optional proxy URL for routing the request through a proxy server
|
||||
pub proxy_url: Option<Url>,
|
||||
/// Optional client certificate for mutual TLS authentication
|
||||
pub client_cert: Option<Secret<String>>,
|
||||
/// Optional client private key for mutual TLS authentication
|
||||
pub client_key: Option<Secret<String>>,
|
||||
/// Optional CA certificate for verifying the server certificate
|
||||
pub ca_cert: Option<Secret<String>>,
|
||||
/// Whether to skip certificate verification (for testing only)
|
||||
pub insecure: Option<bool>,
|
||||
/// Optional password for encrypted client certificate
|
||||
pub cert_password: Option<Secret<String>>,
|
||||
/// Format of the client certificate (e.g., "PEM")
|
||||
pub cert_format: Option<String>,
|
||||
/// Maximum response size in bytes (defaults to 10MB if not specified)
|
||||
pub max_response_size: Option<usize>,
|
||||
}
|
||||
|
||||
/// Complete request structure for the injector service
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InjectorRequest {
|
||||
/// Token data from the vault
|
||||
pub token_data: TokenData,
|
||||
/// Payload template to process
|
||||
pub connector_payload: ConnectorPayload,
|
||||
/// HTTP connection configuration
|
||||
pub connection_config: ConnectionConfig,
|
||||
}
|
||||
|
||||
pub type InjectorResponse = serde_json::Value;
|
||||
|
||||
// Domain models for internal use
|
||||
|
||||
/// Domain model for token data containing vault-specific information
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DomainTokenData {
|
||||
/// The specific token data retrieved from the vault, containing sensitive PII
|
||||
pub specific_token_data: SecretSerdeValue,
|
||||
/// The type of vault connector being used for token retrieval
|
||||
pub vault_connector: VaultConnectors,
|
||||
}
|
||||
|
||||
impl From<TokenData> for DomainTokenData {
|
||||
fn from(token_data: TokenData) -> Self {
|
||||
Self {
|
||||
specific_token_data: token_data.specific_token_data,
|
||||
vault_connector: token_data.vault_connector,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain model for connector payload containing the template to be processed
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DomainConnectorPayload {
|
||||
/// Template string containing token references in the format {{$field_name}}
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
impl From<ConnectorPayload> for DomainConnectorPayload {
|
||||
fn from(payload: ConnectorPayload) -> Self {
|
||||
Self {
|
||||
template: payload.template,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain model for HTTP connection configuration to external connectors
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DomainConnectionConfig {
|
||||
/// Base URL of the connector endpoint
|
||||
pub base_url: Url,
|
||||
/// Path to append to the base URL for the specific endpoint
|
||||
pub endpoint_path: String,
|
||||
/// HTTP method to use for the request
|
||||
pub http_method: HttpMethod,
|
||||
/// HTTP headers to include in the request (values are masked for security)
|
||||
pub headers: HashMap<String, Secret<String>>,
|
||||
/// Optional proxy URL for routing the request through a proxy server
|
||||
pub proxy_url: Option<Url>,
|
||||
/// Optional client certificate for mutual TLS authentication (masked)
|
||||
pub client_cert: Option<Secret<String>>,
|
||||
/// Optional client private key for mutual TLS authentication (masked)
|
||||
pub client_key: Option<Secret<String>>,
|
||||
/// Optional CA certificate for verifying the server certificate (masked)
|
||||
pub ca_cert: Option<Secret<String>>,
|
||||
/// Whether to skip certificate verification (should only be true for testing)
|
||||
pub insecure: Option<bool>,
|
||||
/// Optional password for encrypted client certificate (masked)
|
||||
pub cert_password: Option<Secret<String>>,
|
||||
/// Format of the client certificate (e.g., "PEM", "DER")
|
||||
pub cert_format: Option<String>,
|
||||
/// Maximum response size in bytes (defaults to 10MB if not specified)
|
||||
pub max_response_size: Option<usize>,
|
||||
}
|
||||
|
||||
impl From<ConnectionConfig> for DomainConnectionConfig {
|
||||
fn from(config: ConnectionConfig) -> Self {
|
||||
Self {
|
||||
base_url: config.base_url,
|
||||
endpoint_path: config.endpoint_path,
|
||||
http_method: config.http_method,
|
||||
headers: config.headers,
|
||||
proxy_url: config.proxy_url,
|
||||
client_cert: config.client_cert,
|
||||
client_key: config.client_key,
|
||||
ca_cert: config.ca_cert,
|
||||
insecure: config.insecure,
|
||||
cert_password: config.cert_password,
|
||||
cert_format: config.cert_format,
|
||||
max_response_size: config.max_response_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete domain request structure for the injector service
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DomainInjectorRequest {
|
||||
/// Token data retrieved from the vault for replacement
|
||||
pub token_data: DomainTokenData,
|
||||
/// Payload template containing token references to be processed
|
||||
pub connector_payload: DomainConnectorPayload,
|
||||
/// HTTP connection configuration for making the external request
|
||||
pub connection_config: DomainConnectionConfig,
|
||||
}
|
||||
|
||||
impl From<InjectorRequest> for DomainInjectorRequest {
|
||||
fn from(request: InjectorRequest) -> Self {
|
||||
Self {
|
||||
token_data: request.token_data.into(),
|
||||
connector_payload: request.connector_payload.into(),
|
||||
connection_config: request.connection_config.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use models::*;
|
||||
Reference in New Issue
Block a user