Search code examples
rustrust-axum

How should I write a route in Axum which handles webhook POST events with generic payload data?


I need a route to handle webhook events coming to an Axum API.

The Json body has an event property giving the event name, and a data property which will be a different object based on the event type.

#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DbWebhookEvent {
    SignUpCompleted,
}

#[derive(Debug, Deserialize)]
pub struct DbWebhookBody {
    event: DbWebhookEvent,
    data: ??
}

I looked at using the serde RawValue for this use-case but when I add a lifetime to the DbWebhookBody struct I am not sure how this should bubble up through to the app router.

I will then need to deserialize data once the event type is known so that it can be acted upon. My handler function is currently like this:

pub async fn post_db_webhook(
    Extension(app_state): Extension<Arc<AppState>>,
    Json(body): Json<DbWebhookBody>,
) -> Result<Json<DbWebhookResponse>> {
    match body.event {
        DbWebhookEvent::SignUpCompleted => {
          // need to deserialize body data to a struct
          let signup_data = SignupData::from(body.data)
          signup_completed(app_state, body.data);
        }
    }
    Ok(Json(DbWebhookResponse { ok: true }))
}

Solution

  • It is preferable to handle a body like { "event": "name", "data": ... } as an adjacently tagged enum in Serde since it is much more type-safe. However, that assumes you know the names and structures at compile time.

    #[derive(Debug, Deserialize)]
    #[serde(tag = "event", content = "data", rename_all = "kebab-case")]
    pub enum DbWebhookBody {
        SignUpCompleted(SignUpCompletedEvent),
    }
    

    If you want to handle the data field generically, you can use a serde-json Value. It can hold any kind of JSON value and can be inspected without deserializing to a specific type. This is useful if you need to handle data that isn't well structured or adheres to a schema only known at runtime.

    #[derive(Debug, Deserialize)]
    pub struct DbWebhookBody {
        event: DbWebhookEvent,
        data: Value,
    }
    

    If you want to defer deserialization of the data field, then you'd use serde-json's RawValue. You'll need to use it via Box<RawValue> instead of &'_ RawValue since the latter needs to keep a reference to the original request body which axum doesn't support (Json requires DeserializeOwned).

    #[derive(Debug, Deserialize)]
    pub struct DbWebhookBody {
        event: DbWebhookEvent,
        data: Box<RawValue>,
    }