#[cfg(test)]
mod tests;
pub mod http_validator;
pub mod sql_classifier;
pub mod storage;
use std::{collections::HashSet, sync::Arc};
use fraiseql_core::security::SecurityContext;
use fraiseql_error::Result;
use crate::{
HostContext,
types::{EventPayload, LogEntry, LogLevel},
};
#[derive(Debug, Clone)]
pub struct HostContextConfig {
pub allowed_domains: Vec<String>,
pub allowed_env_vars: HashSet<String>,
pub max_http_response_bytes: usize,
pub http_connect_timeout_ms: u64,
pub http_read_timeout_ms: u64,
pub max_storage_upload_bytes: usize,
}
impl Default for HostContextConfig {
fn default() -> Self {
Self {
allowed_domains: vec!["*".to_string()],
allowed_env_vars: HashSet::new(),
max_http_response_bytes: 10 * 1024 * 1024, http_connect_timeout_ms: 5000,
http_read_timeout_ms: 30000,
max_storage_upload_bytes: 100 * 1024 * 1024, }
}
}
pub trait QueryExecutor: Send + Sync {
fn execute_query(
&self,
query: &str,
variables: Option<&serde_json::Value>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value>> + Send + '_>>;
}
pub struct LiveHostContext {
event_payload: EventPayload,
config: HostContextConfig,
logs: Arc<std::sync::Mutex<Vec<LogEntry>>>,
query_executor: Option<Arc<dyn QueryExecutor>>,
http_client: Option<Arc<reqwest::Client>>,
pub storage_backend: Option<Arc<dyn storage::StorageBackend>>,
pub security_context: SecurityContext,
}
impl LiveHostContext {
#[must_use]
pub fn new(event_payload: EventPayload, config: HostContextConfig) -> Self {
Self {
event_payload,
config,
logs: Arc::new(std::sync::Mutex::new(Vec::new())),
query_executor: None,
http_client: None,
storage_backend: None,
security_context: Self::default_security_context(),
}
}
pub fn with_executor(
event_payload: EventPayload,
config: HostContextConfig,
executor: Arc<dyn QueryExecutor>,
) -> Self {
Self {
event_payload,
config,
logs: Arc::new(std::sync::Mutex::new(Vec::new())),
query_executor: Some(executor),
http_client: None,
storage_backend: None,
security_context: Self::default_security_context(),
}
}
#[must_use]
pub fn with_http_client(
event_payload: EventPayload,
config: HostContextConfig,
http_client: Arc<reqwest::Client>,
) -> Self {
Self {
event_payload,
config,
logs: Arc::new(std::sync::Mutex::new(Vec::new())),
query_executor: None,
http_client: Some(http_client),
storage_backend: None,
security_context: Self::default_security_context(),
}
}
fn default_security_context() -> SecurityContext {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
SecurityContext {
user_id: fraiseql_core::types::UserId("anonymous".to_string()),
roles: vec![],
tenant_id: None,
scopes: vec![],
attributes: std::collections::HashMap::new(),
request_id: format!("req-{}", now),
ip_address: None,
authenticated_at: chrono::Utc::now(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(24),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
#[must_use]
pub fn captured_logs(&self) -> Vec<LogEntry> {
self.logs.lock().expect("log mutex poisoned").clone()
}
}
impl HostContext for LiveHostContext {
async fn query(
&self,
graphql: &str,
variables: serde_json::Value,
) -> Result<serde_json::Value> {
let executor = self.query_executor.as_ref().ok_or_else(|| {
fraiseql_error::FraiseQLError::Unsupported {
message: "query executor not configured".to_string(),
}
})?;
executor.execute_query(graphql, Some(&variables)).await
}
async fn sql_query(
&self,
sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<serde_json::Value>> {
let classification = sql_classifier::classify_sql(sql)?;
match classification {
sql_classifier::SqlClassification::ReadOnly => {
Ok(vec![])
},
sql_classifier::SqlClassification::Rejected(reason) => {
Err(fraiseql_error::FraiseQLError::Authorization {
message: format!("SQL query not allowed: {}", reason),
action: Some("execute_sql_query".to_string()),
resource: None,
})
},
}
}
async fn http_request(
&self,
method: &str,
url: &str,
headers: &[(String, String)],
body: Option<&[u8]>,
) -> Result<crate::host::HttpResponse> {
let http_config = http_validator::HttpClientConfig {
allowed_domains: self.config.allowed_domains.clone(),
max_response_bytes: self.config.max_http_response_bytes,
connect_timeout_ms: self.config.http_connect_timeout_ms,
read_timeout_ms: self.config.http_read_timeout_ms,
};
http_validator::validate_outbound_url(url, &http_config)?;
let client = if let Some(client) = &self.http_client {
client.clone()
} else {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(
self.config.http_connect_timeout_ms,
))
.timeout(std::time::Duration::from_millis(self.config.http_read_timeout_ms))
.build()
.map_err(|e| fraiseql_error::FraiseQLError::Internal {
message: format!("failed to create HTTP client: {}", e),
source: None,
})?;
Arc::new(client)
};
let mut req = match method.to_uppercase().as_str() {
"GET" => client.get(url),
"POST" => client.post(url),
"PUT" => client.put(url),
"PATCH" => client.patch(url),
"DELETE" => client.delete(url),
"HEAD" => client.head(url),
_ => {
return Err(fraiseql_error::FraiseQLError::Validation {
message: format!("unsupported HTTP method: {}", method),
path: None,
});
},
};
for (key, value) in headers {
req = req.header(key.clone(), value.clone());
}
if let Some(body_bytes) = body {
req = req.body(body_bytes.to_vec());
}
let response = req.send().await.map_err(|e| fraiseql_error::FraiseQLError::Internal {
message: format!("HTTP request failed: {}", e),
source: None,
})?;
let status = response.status().as_u16();
let response_headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body_bytes =
response.bytes().await.map_err(|e| fraiseql_error::FraiseQLError::Internal {
message: format!("failed to read response body: {}", e),
source: None,
})?;
if body_bytes.len() > self.config.max_http_response_bytes {
return Err(fraiseql_error::FraiseQLError::Validation {
message: format!(
"response body too large: {} > {}",
body_bytes.len(),
self.config.max_http_response_bytes
),
path: None,
});
}
Ok(crate::host::HttpResponse {
status,
headers: response_headers,
body: body_bytes.to_vec(),
})
}
async fn storage_get(&self, bucket: &str, key: &str) -> Result<Vec<u8>> {
let backend = self.storage_backend.as_ref().ok_or_else(|| {
fraiseql_error::FraiseQLError::Unsupported {
message: "storage backend not configured".to_string(),
}
})?;
backend.get(bucket, key).await
}
async fn storage_put(
&self,
bucket: &str,
key: &str,
body: &[u8],
content_type: &str,
) -> Result<()> {
if body.len() > self.config.max_storage_upload_bytes {
return Err(fraiseql_error::FraiseQLError::Validation {
message: format!(
"upload size {} exceeds limit {}",
body.len(),
self.config.max_storage_upload_bytes
),
path: None,
});
}
let backend = self.storage_backend.as_ref().ok_or_else(|| {
fraiseql_error::FraiseQLError::Unsupported {
message: "storage backend not configured".to_string(),
}
})?;
backend.put(bucket, key, body, content_type).await
}
fn auth_context(&self) -> Result<serde_json::Value> {
Ok(serde_json::json!({
"sub": self.security_context.user_id,
"user_id": self.security_context.user_id, "roles": self.security_context.roles,
"scopes": self.security_context.scopes,
"tenant_id": self.security_context.tenant_id,
"expires_at": self.security_context.expires_at.to_rfc3339(),
"authenticated_at": self.security_context.authenticated_at.to_rfc3339(),
}))
}
fn env_var(&self, name: &str) -> Result<Option<String>> {
if self.config.allowed_env_vars.contains(name) {
Ok(std::env::var(name).ok())
} else {
Ok(None)
}
}
fn event_payload(&self) -> &EventPayload {
&self.event_payload
}
fn log(&self, level: LogLevel, message: &str) {
let entry = LogEntry {
level,
message: message.to_string(),
timestamp: chrono::Utc::now(),
};
self.logs.lock().expect("log mutex poisoned").push(entry);
}
}