use std::sync::Arc;
use fraiseql_core::security::{GuestQueryBridge, SecurityContext};
use fraiseql_error::{FraiseQLError, Result};
use crate::{
HostContext,
host::HttpResponse,
types::{EventPayload, LogLevel},
};
pub struct BeforeMutationHost {
event_payload: EventPayload,
reader: Option<Arc<dyn GuestQueryBridge>>,
principal: Option<SecurityContext>,
}
impl BeforeMutationHost {
#[must_use]
pub fn new(
event_payload: EventPayload,
reader: Option<Arc<dyn GuestQueryBridge>>,
principal: Option<&SecurityContext>,
) -> Self {
Self {
event_payload,
reader,
principal: principal.cloned(),
}
}
fn outside_the_surface(op: &str) -> FraiseQLError {
FraiseQLError::Authorization {
message: format!(
"`{op}` is not available to a before:mutation hook: the hook runs \
synchronously on the write path, within a latency budget, and its side \
effects are not rolled back if the write is refused. Move the effect to an \
after:mutation function, which is durable, retried and dead-lettered."
),
action: Some(op.to_string()),
resource: Some("before:mutation".to_string()),
}
}
}
#[allow(unknown_lints, clippy::unused_async_trait_impl)]
impl HostContext for BeforeMutationHost {
async fn query(
&self,
graphql: &str,
variables: serde_json::Value,
) -> Result<serde_json::Value> {
let reader = self.reader.as_ref().ok_or_else(|| FraiseQLError::Unsupported {
message: "no read bridge is wired on this before:mutation host — the engine \
supplies one for every adjudicated write"
.to_string(),
})?;
let variables = (!variables.is_null()).then_some(variables);
reader.query(graphql, variables.as_ref()).await
}
async fn sql_query(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<serde_json::Value>> {
Err(Self::outside_the_surface("sql_query"))
}
async fn http_request(
&self,
_method: &str,
_url: &str,
_headers: &[(String, String)],
_body: Option<&[u8]>,
) -> Result<HttpResponse> {
Err(Self::outside_the_surface("http_request"))
}
async fn storage_get(&self, _bucket: &str, _key: &str) -> Result<Vec<u8>> {
Err(Self::outside_the_surface("storage_get"))
}
async fn storage_put(
&self,
_bucket: &str,
_key: &str,
_body: &[u8],
_content_type: &str,
) -> Result<()> {
Err(Self::outside_the_surface("storage_put"))
}
async fn send_email(
&self,
_request: &crate::outbound::SendEmailRequest,
) -> Result<crate::outbound::SendEmailResponse> {
Err(Self::outside_the_surface("send_email"))
}
fn auth_context(&self) -> Result<serde_json::Value> {
let context = self.principal.as_ref().ok_or_else(|| FraiseQLError::Unsupported {
message: "this mutation was issued anonymously: a before:mutation hook has no \
authenticated context to read"
.to_string(),
})?;
Ok(crate::host::auth_context_json(context))
}
fn env_var(&self, _name: &str) -> Result<Option<String>> {
Err(Self::outside_the_surface("env_var"))
}
fn event_payload(&self) -> &EventPayload {
&self.event_payload
}
fn log(&self, level: LogLevel, message: &str) {
match level {
LogLevel::Debug => tracing::debug!(target: "fraiseql::functions::guest", "{message}"),
LogLevel::Info => tracing::info!(target: "fraiseql::functions::guest", "{message}"),
LogLevel::Warn => tracing::warn!(target: "fraiseql::functions::guest", "{message}"),
LogLevel::Error => tracing::error!(target: "fraiseql::functions::guest", "{message}"),
}
}
}
#[cfg(test)]
mod tests;