use std::{collections::BTreeMap, sync::Arc};
use axum::{
Router,
body::Bytes,
extract::{Path, RawQuery, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
};
use fraiseql_functions::{
InboundMessage, IngestError, IngestSource, PushSource, RawDelivery, Source, Transport,
};
use fraiseql_jwks::JwksSource;
use fraiseql_webhooks::{
Authenticated, Delivery, Disposition, EventHandler, Handled, InboundRequest,
PostgresIdempotencyStore, Result as WebhookResult, SchemeContext, SignatureVerifier,
StaticSecretProvider, VerifiedEvent, WebhookError, WebhookPipeline, build_scheme,
};
use serde_json::{Value, json};
use sqlx::{PgPool, Postgres, Transaction};
use crate::{config::WebhookRouteConfig, inbound::spine::emit_in_tx};
pub struct WebhookSource {
provider: String,
route: String,
}
impl WebhookSource {
#[must_use]
pub fn new(provider: impl Into<String>, route: impl Into<String>) -> Self {
Self {
provider: provider.into(),
route: route.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 idempotency_key = format!("{}:{}:{}", self.route.len(), self.route, delivery.event_id);
let mut message = InboundMessage::new(self.source(), idempotency_key, 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<Handled> {
let message: InboundMessage = serde_json::from_value(params)?;
let emitted = emit_in_tx(tx, &message)
.await
.map_err(|error| WebhookError::Database(error.to_string()))?;
if !emitted.is_new() {
tracing::warn!(
source = ?message.source,
idempotency_key = %message.idempotency_key,
"inbound spine refused a delivery whose ledger claim was fresh: the delivery \
ledger and the spine disagree about this event. Reported as duplicate; \
after:ingest not dispatched."
);
return Ok(Handled::Duplicate);
}
Ok(Handled::Recorded(serde_json::to_value(&message)?))
}
}
#[derive(Clone)]
struct BuiltRoute {
name: String,
provider: String,
scheme: Arc<dyn SignatureVerifier>,
secret_name: Option<String>,
public_url: Option<String>,
}
impl BuiltRoute {
fn resolved_secret(&self, get_env: &impl Fn(&str) -> Option<String>) -> Option<String> {
self.secret_name
.as_deref()
.and_then(get_env)
.filter(|secret| !secret.is_empty())
}
}
impl std::fmt::Debug for BuiltRoute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BuiltRoute")
.field("name", &self.name)
.field("provider", &self.provider)
.field("scheme", &self.scheme.name())
.field("secret_env", &self.secret_name)
.field("public_url", &self.public_url)
.finish()
}
}
#[derive(Clone, Debug, Default)]
pub struct WebhookRoutes {
by_segment: BTreeMap<String, BuiltRoute>,
}
impl WebhookRoutes {
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_segment.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.by_segment.len()
}
}
const TIMESTAMP_TOLERANCE_SECS: u64 = 300;
const JWKS_CACHE_TTL: std::time::Duration = std::time::Duration::from_mins(5);
fn build_routes<S: std::hash::BuildHasher>(
routes: &std::collections::HashMap<String, WebhookRouteConfig, S>,
) -> crate::Result<WebhookRoutes> {
let mut sorted: Vec<(&String, &WebhookRouteConfig)> = routes.iter().collect();
sorted.sort_by_key(|(a, _)| *a);
let mut built: BTreeMap<String, BuiltRoute> = BTreeMap::new();
for (name, config) in sorted {
let segment = config.path.clone().unwrap_or_else(|| name.clone());
if let Some(previous) = built.get(&segment) {
return Err(crate::ServerError::ConfigError(format!(
"[webhooks.{}] and [webhooks.{name}] both resolve to the path \
segment {segment:?}, so only one of them could ever be mounted and which \
one would change between restarts. Give one of them a distinct `path`, or \
remove it. (A route's segment is its `path` override, or its config key \
when `path` is absent — so an override may collide with another route's \
name.)",
previous.name
)));
}
let mut context = SchemeContext::with_tolerance(TIMESTAMP_TOLERANCE_SECS);
if let Some(uri) = config.jwks_uri.as_deref() {
let source = JwksSource::new(uri, JWKS_CACHE_TTL).map_err(|error| {
crate::ServerError::ConfigError(format!("[webhooks.{name}] {error}"))
})?;
context = context.with_jwks(Arc::new(source));
}
let scheme =
build_scheme(&config.provider, &config.scheme_config(), &context).map_err(|error| {
crate::ServerError::ConfigError(format!("[webhooks.{name}] {error}"))
})?;
if scheme.requires_url() && config.public_url.is_none() {
return Err(crate::ServerError::ConfigError(format!(
"[webhooks.{name}] provider = {:?} signs the request URL, so the route \
needs `public_url` set to the exact URL registered at the provider. \
Reconstructing it from request headers would let the sender choose the \
signed material, so the server refuses to guess.",
config.provider
)));
}
built.insert(
segment,
BuiltRoute {
name: name.clone(),
provider: config.provider.clone(),
scheme,
secret_name: config.secret_env.clone(),
public_url: config.public_url.clone(),
},
);
}
Ok(WebhookRoutes { by_segment: built })
}
type InboundPipeline =
WebhookPipeline<StaticSecretProvider, PostgresIdempotencyStore, SpineEventHandler>;
#[derive(Clone)]
pub struct WebhookInboundState {
pipeline: Arc<InboundPipeline>,
routes: Arc<BTreeMap<String, BuiltRoute>>,
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: &WebhookRoutes,
get_env: impl Fn(&str) -> Option<String>,
) -> Self {
let mut secrets = StaticSecretProvider::new();
let mut mounted = BTreeMap::new();
for (segment, route) in routes.by_segment.clone() {
let secret = route.resolved_secret(&get_env);
if let Err(reason) = route.scheme.check_key_material(secret.as_deref()) {
tracing::warn!(
route = %route.name,
secret_env = ?route.secret_name,
%reason,
"inbound webhook route SKIPPED: this scheme cannot verify with the \
route's key material, so the route is not mounted (deliveries answer \
404). Fix the configuration and restart to serve it."
);
continue;
}
if let (Some(name), Some(value)) = (route.secret_name.clone(), secret) {
secrets = secrets.with_secret(name, value);
}
mounted.insert(segment, route);
}
let store = PostgresIdempotencyStore::new(pool.clone());
let pipeline = WebhookPipeline::new(pool, secrets, store, SpineEventHandler);
Self {
pipeline: Arc::new(pipeline),
routes: Arc::new(mounted),
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) fn mounted_segments(&self) -> Vec<String> {
self.routes.keys().cloned().collect()
}
#[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
}
}
pub fn webhook_routes_check<S: std::hash::BuildHasher>(
routes: &std::collections::HashMap<String, WebhookRouteConfig, S>,
get_env: impl Fn(&str) -> Option<String>,
is_production: bool,
) -> crate::Result<WebhookRoutes> {
let built = build_routes(routes)?;
for route in built.by_segment.values() {
let secret = route.resolved_secret(&get_env);
let Err(error) = route.scheme.check_key_material(secret.as_deref()) else {
continue;
};
if secret.is_some() {
return Err(crate::ServerError::ConfigError(format!(
"[webhooks.{}] the {} scheme cannot use the key material in \
secret_env = {:?}: {error}",
route.name, route.provider, route.secret_name
)));
}
if !is_production {
tracing::warn!(
route = %route.name,
secret_env = ?route.secret_name,
"inbound webhook route will be skipped: the signing secret this scheme \
needs is not set. Allowed only because FRAISEQL_ENV=development."
);
continue;
}
let development_note = " (For local development only, FRAISEQL_ENV=development \
downgrades this to a warning and skips the route.)";
return Err(crate::ServerError::ConfigError(match &route.secret_name {
Some(name) => format!(
"[webhooks.{}] secret_env = {name:?} is not set (or is empty) in the \
environment, so the configured route cannot verify any delivery. Set the \
variable, or remove the route.{development_note}",
route.name
),
None => format!(
"[webhooks.{}] this route sets no `secret_env`, and {error}{development_note}",
route.name
),
}));
}
Ok(built)
}
const BODY_SHA256_PARAM: &str = "bodySHA256";
fn body_sha256_query(query: Option<&str>) -> Option<&str> {
query?
.split('&')
.filter_map(|pair| pair.split_once('='))
.find_map(|(key, value)| (key == BODY_SHA256_PARAM).then_some(value))
}
fn append_query_param(base: &str, hash: &str) -> String {
let separator = if base.contains('?') { '&' } else { '?' };
format!("{base}{separator}{BODY_SHA256_PARAM}={hash}")
}
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, body: &[u8]) -> String {
payload.get("id").and_then(Value::as_str).map_or_else(
|| {
use sha2::{Digest as _, Sha256};
format!("body:{}", hex::encode(Sha256::digest(body)))
},
str::to_string,
)
}
fn extract_event_type(payload: &Value, headers: &BTreeMap<String, String>) -> String {
payload
.get("type")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| headers.get("x-github-event").cloned())
.unwrap_or_default()
}
fn json_status(status: StatusCode, body: &Value) -> Response {
(status, body.to_string()).into_response()
}
const FORM_MEDIA_TYPE: &str = "application/x-www-form-urlencoded";
fn is_form_encoded(headers: &HeaderMap) -> bool {
headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(';')
.next()
.unwrap_or_default()
.trim()
.eq_ignore_ascii_case(FORM_MEDIA_TYPE)
})
}
fn form_to_json(body: &[u8]) -> Value {
use serde_json::map::Entry;
let mut object = serde_json::Map::new();
for (key, value) in url::form_urlencoded::parse(body) {
let value = Value::String(value.into_owned());
match object.entry(key.into_owned()) {
Entry::Vacant(slot) => {
slot.insert(value);
},
Entry::Occupied(mut slot) => match slot.get_mut() {
Value::Array(values) => values.push(value),
first => *first = Value::Array(vec![first.take(), value]),
},
}
}
Value::Object(object)
}
fn parse_body(body: &[u8], headers: &HeaderMap) -> WebhookResult<Value> {
if is_form_encoded(headers) {
return Ok(form_to_json(body));
}
serde_json::from_slice::<Value>(body)
.map_err(|_| WebhookError::InvalidPayload("webhook body is not valid JSON".to_string()))
}
pub async fn webhook_handler(
State(state): State<WebhookInboundState>,
Path(segment): Path<String>,
RawQuery(query): RawQuery,
headers: HeaderMap,
body: Bytes,
) -> Response {
let Some(route) = state.routes.get(&segment) else {
return json_status(
StatusCode::NOT_FOUND,
&json!({ "error": format!("no inbound webhook route '{segment}'") }),
);
};
let verifier = route.scheme.as_ref();
if verifier.requires_url() && route.public_url.is_none() {
return json_status(
StatusCode::INTERNAL_SERVER_ERROR,
&json!({ "error": "server configuration error" }),
);
}
let signing_url = route.public_url.as_ref().map(|base| {
body_sha256_query(query.as_deref())
.map_or_else(|| base.clone(), |hash| append_query_param(base, hash))
});
let header_map = collect_headers(&headers);
let received_at = chrono::Utc::now();
let source = WebhookSource::new(route.provider.clone(), segment.clone());
let event_of = |authenticated: Authenticated<'_>| -> WebhookResult<VerifiedEvent> {
let (event_id, event_type, event_payload) = match authenticated {
Authenticated::Body(verified_body) => {
let payload = parse_body(verified_body, &headers)?;
let event_id = extract_event_id(&payload, verified_body);
let event_type = extract_event_type(&payload, &header_map);
(event_id, event_type, payload)
},
Authenticated::BodyWithId {
body: verified_body,
id,
} => {
let payload = parse_body(verified_body, &headers)?;
let event_type = extract_event_type(&payload, &header_map);
(id.to_string(), event_type, payload)
},
Authenticated::Event {
id,
event_type,
payload,
} => (id.to_string(), event_type.to_string(), payload.clone()),
};
let raw = RawDelivery {
event_id: &event_id,
event_type: &event_type,
payload: &event_payload,
headers: &header_map,
received_at,
};
let message = source
.normalize(&raw)
.map_err(|error| WebhookError::InvalidPayload(error.to_string()))?;
Ok(VerifiedEvent {
id: event_id,
event_type,
params: serde_json::to_value(&message)?,
})
};
let delivery = Delivery {
route: &segment,
function_name: &segment,
request: InboundRequest::new(&header_map, &body, signing_url.as_deref()),
};
match state
.pipeline
.process(verifier, route.secret_name.as_deref(), &delivery, event_of)
.await
{
Ok(Disposition::Processed(recorded)) => {
match serde_json::from_value::<InboundMessage>(recorded) {
Ok(message) => dispatch_after_ingest(&state, &message),
Err(error) => tracing::error!(
route = %segment,
%error,
"inbound webhook delivery committed but its persisted message could \
not be read back; after:ingest not dispatched"
),
}
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();
tracing::warn!(
route = %segment,
provider = %route.provider,
error = %mapped,
"inbound webhook delivery failed"
);
mapped.into_response()
},
}
}
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/{segment}", post(webhook_handler))
.with_state(state)
}
#[cfg(test)]
mod tests;