use std::{collections::BTreeMap, sync::Arc};
use axum::{
Router,
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
};
use fraiseql_functions::{
InboundMessage, IngestError, IngestSource, PushSource, RawDelivery, Source, Transport,
};
use fraiseql_webhooks::{
Delivery, Disposition, EventHandler, PostgresIdempotencyStore, Result as WebhookResult,
StaticSecretProvider, WebhookError, WebhookPipeline, signature::ProviderRegistry,
};
use serde_json::{Value, json};
use sqlx::{PgPool, Postgres, Transaction};
use crate::{config::WebhookRouteConfig, inbound::spine::emit_in_tx};
pub struct WebhookSource {
provider: String,
}
impl WebhookSource {
#[must_use]
pub fn new(provider: impl Into<String>) -> Self {
Self {
provider: provider.into(),
}
}
}
impl Source for WebhookSource {
fn source(&self) -> IngestSource {
IngestSource::Webhook {
provider: self.provider.clone(),
}
}
fn transport(&self) -> Transport {
Transport::Push
}
}
impl PushSource for WebhookSource {
fn normalize(&self, delivery: &RawDelivery<'_>) -> Result<InboundMessage, IngestError> {
if delivery.event_id.is_empty() {
return Err(IngestError::new("webhook delivery has no event id"));
}
let mut message =
InboundMessage::new(self.source(), delivery.event_id, delivery.received_at);
if !delivery.event_type.is_empty() {
message.subject = Some(delivery.event_type.to_string());
}
message.headers = delivery.headers.clone();
message.payload = Some(delivery.payload.clone());
Ok(message)
}
}
struct SpineEventHandler;
impl EventHandler for SpineEventHandler {
async fn handle(
&self,
_function_name: &str,
params: Value,
tx: &mut Transaction<'_, Postgres>,
) -> WebhookResult<Value> {
let message: InboundMessage = serde_json::from_value(params)?;
emit_in_tx(tx, &message)
.await
.map_err(|error| WebhookError::Database(error.to_string()))?;
serde_json::to_value(&message).map_err(Into::into)
}
}
#[derive(Debug, Clone)]
struct ResolvedRoute {
provider: String,
secret_name: String,
}
type InboundPipeline =
WebhookPipeline<StaticSecretProvider, PostgresIdempotencyStore, SpineEventHandler>;
#[derive(Clone)]
pub struct WebhookInboundState {
pipeline: Arc<InboundPipeline>,
registry: Arc<ProviderRegistry>,
routes: Arc<BTreeMap<String, ResolvedRoute>>,
hooks: Option<Arc<crate::subsystems::BeforeMutationHooks>>,
query_executor_factory: Option<crate::routes::after_mutation::QueryExecutorFactory>,
}
impl WebhookInboundState {
#[must_use]
pub fn new(
pool: PgPool,
routes: &std::collections::HashMap<String, WebhookRouteConfig>,
get_env: impl Fn(&str) -> Option<String>,
) -> Self {
let mut secrets = StaticSecretProvider::new();
let mut resolved = BTreeMap::new();
for (name, config) in routes {
let segment = config.path.clone().unwrap_or_else(|| name.clone());
match get_env(&config.secret_env) {
Some(secret) => secrets = secrets.with_secret(config.secret_env.clone(), secret),
None => {
tracing::warn!(
route = %name,
secret_env = %config.secret_env,
"inbound webhook route not fully configured: signing secret env is unset; \
deliveries will fail signature verification until it is provided"
);
},
}
resolved.insert(
segment,
ResolvedRoute {
provider: config.provider.clone(),
secret_name: config.secret_env.clone(),
},
);
}
let store = PostgresIdempotencyStore::new(pool.clone());
let pipeline = WebhookPipeline::new(pool, secrets, store, SpineEventHandler);
Self {
pipeline: Arc::new(pipeline),
registry: Arc::new(ProviderRegistry::new()),
routes: Arc::new(resolved),
hooks: None,
query_executor_factory: None,
}
}
#[must_use]
pub fn with_hooks(mut self, hooks: Arc<crate::subsystems::BeforeMutationHooks>) -> Self {
self.hooks = Some(hooks);
self
}
#[must_use]
pub fn with_query_executor_factory(
mut self,
factory: crate::routes::after_mutation::QueryExecutorFactory,
) -> Self {
self.query_executor_factory = Some(factory);
self
}
#[cfg(test)]
#[must_use]
pub(crate) const fn query_executor_factory(
&self,
) -> Option<&crate::routes::after_mutation::QueryExecutorFactory> {
self.query_executor_factory.as_ref()
}
pub async fn init_spine(pool: &PgPool) -> fraiseql_error::Result<()> {
super::spine::PostgresInboundSpine::new(pool.clone()).init().await
}
}
fn collect_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
.filter_map(|(name, value)| {
value.to_str().ok().map(|v| (name.as_str().to_string(), v.to_string()))
})
.collect()
}
fn extract_event_id(payload: &Value, headers: &BTreeMap<String, String>, body: &[u8]) -> String {
headers
.get("webhook-id")
.or_else(|| headers.get("x-github-delivery"))
.cloned()
.or_else(|| payload.get("id").and_then(Value::as_str).map(str::to_string))
.unwrap_or_else(|| {
use std::hash::{Hash as _, Hasher as _};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut hasher);
format!("body:{:016x}", hasher.finish())
})
}
fn extract_event_type(payload: &Value, headers: &BTreeMap<String, String>) -> String {
headers
.get("x-github-event")
.cloned()
.or_else(|| payload.get("type").and_then(Value::as_str).map(str::to_string))
.unwrap_or_default()
}
fn json_status(status: StatusCode, body: &Value) -> Response {
(status, body.to_string()).into_response()
}
pub async fn webhook_handler(
State(state): State<WebhookInboundState>,
Path(provider): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let Some(route) = state.routes.get(&provider) else {
return json_status(
StatusCode::NOT_FOUND,
&json!({ "error": format!("no inbound webhook route '{provider}'") }),
);
};
let Some(verifier) = state.registry.get(&route.provider) else {
return json_status(
StatusCode::INTERNAL_SERVER_ERROR,
&json!({ "error": format!("unknown webhook provider '{}'", route.provider) }),
);
};
let Some(signature) = headers.get(verifier.signature_header()).and_then(|v| v.to_str().ok())
else {
return json_status(
StatusCode::BAD_REQUEST,
&json!({ "error": format!("missing signature header '{}'", verifier.signature_header()) }),
);
};
let signature = signature.to_string();
let Ok(payload) = serde_json::from_slice::<Value>(&body) else {
return json_status(
StatusCode::BAD_REQUEST,
&json!({ "error": "webhook body is not valid JSON" }),
);
};
let header_map = collect_headers(&headers);
let event_id = extract_event_id(&payload, &header_map, &body);
let event_type = extract_event_type(&payload, &header_map);
let source = WebhookSource::new(route.provider.clone());
let raw = RawDelivery {
event_id: &event_id,
event_type: &event_type,
payload: &payload,
headers: &header_map,
received_at: chrono::Utc::now(),
};
let message = match source.normalize(&raw) {
Ok(message) => message,
Err(error) => {
return json_status(StatusCode::BAD_REQUEST, &json!({ "error": error.to_string() }));
},
};
let params = serde_json::to_value(&message).unwrap_or(Value::Null);
let delivery = Delivery {
provider: &route.provider,
event_id: &event_id,
event_type: &event_type,
function_name: &provider,
body: &body,
signature: &signature,
timestamp: None,
url: None,
params,
};
match state.pipeline.process(verifier.as_ref(), &route.secret_name, &delivery).await {
Ok(Disposition::Processed(_)) => {
dispatch_after_ingest(&state, &message);
json_status(StatusCode::OK, &json!({ "status": "processed" }))
},
Ok(Disposition::Duplicate) => {
json_status(StatusCode::OK, &json!({ "status": "duplicate" }))
},
Ok(_) => json_status(StatusCode::OK, &json!({ "status": "accepted" })),
Err(error) => {
let mapped: fraiseql_error::FraiseQLError = error.into();
let status = StatusCode::from_u16(mapped.status_code())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
json_status(status, &json!({ "error": mapped.to_string() }))
},
}
}
fn dispatch_after_ingest(state: &WebhookInboundState, message: &InboundMessage) {
let Some(ref hooks) = state.hooks else {
return;
};
let plans = crate::routes::after_mutation::plan_after_ingest_dispatch(hooks, message);
if !plans.is_empty() {
crate::routes::after_mutation::spawn_after_ingest(
hooks,
plans,
state.query_executor_factory.clone(),
);
}
}
pub fn webhook_router(state: WebhookInboundState) -> Router {
Router::new()
.route("/webhooks/{provider}", post(webhook_handler))
.with_state(state)
}
#[cfg(test)]
mod tests;