rocket_webhook/lib.rs
1#![forbid(unsafe_code)]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3
4/*!
5⚠️ This crate is in development and may not work as expected yet.
6# Overview
7
8Streamlined webhook validation in Rocket applications.
9
10- Automatically validate and deserialize webhook JSON payloads using the [WebhookPayload] data guard. You can also
11get the raw body using [WebhookPayloadRaw].
12- [Common webhooks](webhooks::built_in) included (GitHub, Slack, Stripe, Standard)
13- Easily validate custom webhooks with one of the generic builders
14
15# Usage
16
17```
18use rocket::{routes, post, serde::{Serialize, Deserialize}};
19use rocket_webhook::{
20 RocketWebhook, WebhookPayload,
21 webhooks::built_in::{GitHubWebhook, SlackWebhook},
22};
23
24#[rocket::launch]
25fn rocket() -> _ {
26 // Build the webhook(s)
27 let github_webhook = RocketWebhook::builder()
28 .webhook(GitHubWebhook::with_secret(b"my-github-secret"))
29 .build();
30 let slack_webhook = RocketWebhook::builder()
31 .webhook(SlackWebhook::with_secret(b"my-slack-secret"))
32 .build();
33
34 // Store the webhook(s) in Rocket state
35 let rocket = rocket::build()
36 .manage(github_webhook)
37 .manage(slack_webhook)
38 .mount("/", routes![github_route]);
39
40 rocket
41}
42
43/// JSON payload to deserialize
44#[derive(Debug, Serialize, Deserialize)]
45struct GithubPayload {
46 action: String,
47}
48
49// use the `WebhookPayload` data guard in a route handler
50#[post("/api/webhooks/github", data = "<payload>")]
51async fn github_route(
52 payload: WebhookPayload<'_, GithubPayload, GitHubWebhook>,
53) -> &'static str {
54 payload.data; // access the validated webhook payload
55 payload.headers; // access the webhook headers
56
57 "OK"
58}
59
60
61```
62
63# Custom webhooks
64If you're using a webhook provider that is not built-in, there are two ways to integrate them:
65
66## Use generic builder
67This is the preferred (and simpler) approach - use one of [the generic webhook builders](webhooks::generic) to build a webhook
68for your provider/service. For example, here is a custom webhook that expects a hex-encoded HMAC SHA256 signature
69in the `Foo-Signature-256` header.
70
71```
72use rocket_webhook::{WebhookError, webhooks::generic::Hmac256Webhook};
73
74let my_webhook = Hmac256Webhook::builder()
75 .secret("my-secret")
76 .expected_signatures(|req| {
77 req.headers()
78 .get_one("Foo-Signature-256")
79 .and_then(|header| hex::decode(header).ok())
80 .map(|header| vec![header])
81 })
82 .build();
83```
84
85## Implement webhook traits
86If a generic builder is not available, you can directly implement one of the [signature traits](webhooks::interface)
87along with the [Webhook](src/webhooks.rs) trait. See the implementations in [webhooks::built_in] for examples.
88
89# Handling errors
90By default, the webhook data guards will return Bad Request (400) for invalid requests (e.g. missing headers) and
91Unauthorized (401) for signature validation failures. Rocket's error responses can be overridden using
92[catchers](https://rocket.rs/guide/v0.5/requests/#error-catchers) scoped to a specific path.
93
94If you need more control over how to
95process and respond to webhook errors, you can wrap the data guards with a Result, using
96the [WebhookError] as the Error type. You can then match on the result and handle the response as desired.
97
98```
99use rocket::{routes, post, serde::{Serialize, Deserialize}};
100use rocket::http::Status;
101use rocket_webhook::{
102 WebhookError, WebhookPayload,
103 webhooks::built_in::{GitHubWebhook},
104};
105
106#[post("/api/webhooks/github", data = "<payload_result>")]
107async fn github_route(
108 payload_result: Result<WebhookPayload<'_, GithubPayload, GitHubWebhook>, WebhookError>,
109) -> (Status, &'static str) {
110 match payload_result {
111 Ok(payload) => (Status::Ok, "Yay!"),
112 Err(err) => match err {
113 WebhookError::Signature(_) => (Status::Unauthorized, "Yikes!"),
114 _ => (Status::UnprocessableEntity, "Oof!")
115 }
116 }
117}
118
119/// Payload to deserialize
120#[derive(Debug, Serialize, Deserialize)]
121struct GithubPayload {
122 action: String,
123}
124```
125
126# Multiple with same type
127If you want to receive webhooks using multiple accounts/keys from the same built-in or generic webhook, you'll need to pass
128in a marker struct when building the webhooks and using the data guards. This is needed to distinguish
129between the two webhooks in Rocket's internal state.
130
131```
132use rocket::{get, routes};
133use rocket_webhook::{
134 RocketWebhook, WebhookPayloadRaw, webhooks::built_in::SlackWebhook,
135};
136
137// Create a marker struct for each account/key
138struct SlackAccount1;
139struct SlackAccount2;
140
141fn two_slack_accounts() {
142 // Use the `builder_with_marker` function
143 let slack_1 = RocketWebhook::builder_with_marker()
144 .webhook(SlackWebhook::with_secret("slack-1-secret"))
145 .marker(SlackAccount1) // pass in the marker here
146 .build();
147 let slack_2 = RocketWebhook::builder_with_marker()
148 .webhook(SlackWebhook::with_secret("slack-2-secret"))
149 .marker(SlackAccount2) // pass in the marker here
150 .build();
151
152 let rocket = rocket::build()
153 .manage(slack_1)
154 .manage(slack_2)
155 .mount("/", routes![slack1_route, slack2_route]);
156}
157
158// Use the marker struct as the last type parameter in the data guard:
159
160#[get("/slack-1", data = "<payload>")]
161async fn slack1_route(payload: WebhookPayloadRaw<'_, SlackWebhook, SlackAccount1>) -> Vec<u8> {
162 payload.data
163}
164
165#[get("/slack-2", data = "<payload>")]
166async fn slack2_route(payload: WebhookPayloadRaw<'_, SlackWebhook, SlackAccount2>) -> Vec<u8> {
167 payload.data
168}
169```
170*/
171
172mod error;
173mod guard;
174mod state;
175
176pub mod webhooks;
177pub use error::WebhookError;
178pub use guard::{WebhookPayload, WebhookPayloadRaw};
179pub use state::RocketWebhook;