rust
53 lines · 9 steps
A concurrent webhook dispatcher in Axum
An Axum handler routes incoming webhooks to registered handlers and runs them all concurrently.
Explained by
highlit
1use std::collections::HashMap;
2use std::sync::Arc;
3
4use axum::body::Bytes;
5use axum::extract::State;
6use axum::http::{HeaderMap, StatusCode};
7use futures::future::join_all;
8use serde_json::Value;
9
10#[async_trait::async_trait]
11pub trait WebhookHandler: Send + Sync {
12 async fn handle(&self, payload: &Value) -> anyhow::Result<()>;
13}
14
15#[derive(Clone)]
16pub struct Dispatcher {
17 handlers: Arc<HashMap<String, Vec<Arc<dyn WebhookHandler>>>>,
18}
19
20impl Dispatcher {
21 fn for_event(&self, event: &str) -> &[Arc<dyn WebhookHandler>] {
22 self.handlers.get(event).map(Vec::as_slice).unwrap_or(&[])
23 }
24}
25
26pub async fn receive_webhook(
27 State(dispatcher): State<Dispatcher>,
28 headers: HeaderMap,
29 body: Bytes,
30) -> Result<StatusCode, (StatusCode, String)> {
31 let event = headers
32 .get("x-event-type")
33 .and_then(|v| v.to_str().ok())
34 .ok_or((StatusCode::BAD_REQUEST, "missing x-event-type header".into()))?;
35
36 let payload: Value = serde_json::from_slice(&body)
37 .map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid json: {e}")))?;
38
39 let handlers = dispatcher.for_event(event);
40 if handlers.is_empty() {
41 tracing::warn!(%event, "no handlers registered for event");
42 return Ok(StatusCode::ACCEPTED);
43 }
44
45 let results = join_all(handlers.iter().map(|h| h.handle(&payload))).await;
46 for result in results {
47 if let Err(err) = result {
48 tracing::error!(%event, error = %err, "downstream handler failed");
49 }
50 }
51
52 Ok(StatusCode::OK)
53}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Boxing handlers behind a trait object lets you register any number of implementations under one event key.
- 2join_all turns a collection of futures into a single awaitable that runs them concurrently.
- 3Returning early with ACCEPTED for unknown events keeps a webhook endpoint forgiving instead of failing senders.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
rust
use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Lines, Write};
K-way merge of sorted logs in Rust
binary-heap
k-way-merge
streaming-io
Intermediate
8 steps
rust
use axum::{ extract::{Path, State}, response::sse::{Event, KeepAlive, Sse}, };
Streaming import progress with SSE in Axum
server-sent-events
streams
watch-channel
Advanced
7 steps
rust
use std::f64::consts::PI; #[derive(Debug, Clone, Copy)] pub struct LatLng {
Building geographic bounding boxes in Rust
geospatial
value-types
option
Intermediate
7 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-concurrent-webhook-dispatcher-in-axum-explained-rust-c325/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.