1use axum::Json;
5use axum::extract::State;
6use axum::extract::rejection::JsonRejection;
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
9
10use super::server::AppState;
11
12#[derive(serde::Serialize)]
14struct ErrorResponse {
15 error: String,
16 status: u16,
17}
18
19#[derive(serde::Deserialize)]
24pub(crate) struct WebhookPayload {
25 pub channel: String,
27 pub sender: String,
29 pub body: String,
31}
32
33impl WebhookPayload {
34 pub(crate) fn validate(&self) -> Result<(), &'static str> {
45 if self.sender.len() > 256 {
46 return Err("sender exceeds 256 bytes");
47 }
48 if self.channel.len() > 256 {
49 return Err("channel exceeds 256 bytes");
50 }
51 if self.body.len() > 65536 {
52 return Err("body exceeds 65536 bytes");
53 }
54 Ok(())
55 }
56}
57
58#[derive(serde::Serialize)]
60struct WebhookResponse {
61 status: &'static str,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct WebhookMessage {
76 pub sender: String,
78 pub channel: String,
80 pub body: String,
82}
83
84#[derive(serde::Serialize)]
86struct HealthResponse {
87 status: &'static str,
89 uptime_secs: u64,
91}
92
93#[tracing::instrument(name = "gateway.webhook", skip_all)]
113pub(crate) async fn webhook_handler(
114 State(state): State<AppState>,
115 payload: Result<Json<WebhookPayload>, JsonRejection>,
116) -> impl IntoResponse {
117 let Json(payload) = match payload {
118 Ok(p) => p,
119 Err(e) => {
120 return (
121 e.status(),
122 Json(ErrorResponse {
123 error: e.body_text(),
124 status: e.status().as_u16(),
125 }),
126 )
127 .into_response();
128 }
129 };
130 if let Err(e) = payload.validate() {
131 return (
132 StatusCode::UNPROCESSABLE_ENTITY,
133 Json(ErrorResponse {
134 error: e.to_string(),
135 status: StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
136 }),
137 )
138 .into_response();
139 }
140 let sender = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.sender);
141 let channel = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.channel);
142 let body = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.body);
143 let msg = WebhookMessage {
144 sender,
145 channel,
146 body,
147 };
148 match tokio::time::timeout(state.webhook_send_timeout, state.webhook_tx.send(msg)).await {
149 Ok(Ok(())) => Json(WebhookResponse { status: "accepted" }).into_response(),
150 Ok(Err(_)) => (
151 StatusCode::SERVICE_UNAVAILABLE,
152 Json(ErrorResponse {
153 error: "agent unavailable".to_string(),
154 status: StatusCode::SERVICE_UNAVAILABLE.as_u16(),
155 }),
156 )
157 .into_response(),
158 Err(_elapsed) => {
159 tracing::warn!(
160 timeout_secs = state.webhook_send_timeout.as_secs_f64(),
161 "webhook send timed out: agent backpressure"
162 );
163 (
164 StatusCode::SERVICE_UNAVAILABLE,
165 Json(ErrorResponse {
166 error: "service unavailable: agent backpressure".to_string(),
167 status: StatusCode::SERVICE_UNAVAILABLE.as_u16(),
168 }),
169 )
170 .into_response()
171 }
172 }
173}
174
175#[tracing::instrument(name = "gateway.health", skip_all)]
187pub(crate) async fn health_handler(State(state): State<AppState>) -> impl IntoResponse {
188 Json(HealthResponse {
189 status: "ok",
190 uptime_secs: state.started_at.elapsed().as_secs(),
191 })
192}
193
194#[cfg(feature = "prometheus")]
209#[tracing::instrument(name = "gateway.metrics", skip_all)]
210pub(crate) async fn metrics_handler(
211 axum::extract::State(registry): axum::extract::State<
212 std::sync::Arc<prometheus_client::registry::Registry>,
213 >,
214) -> impl axum::response::IntoResponse {
215 let mut buf = String::new();
216 match prometheus_client::encoding::text::encode(&mut buf, ®istry) {
217 Ok(()) => (
218 [(
219 axum::http::header::CONTENT_TYPE,
220 "application/openmetrics-text; version=1.0.0; charset=utf-8",
221 )],
222 buf,
223 )
224 .into_response(),
225 Err(e) => {
226 tracing::error!("failed to encode prometheus metrics: {e}");
227 (
228 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
229 "metrics encoding failed",
230 )
231 .into_response()
232 }
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::time::{Duration, Instant};
240
241 #[test]
242 fn health_response_serializes() {
243 let resp = HealthResponse {
244 status: "ok",
245 uptime_secs: 42,
246 };
247 let json = serde_json::to_string(&resp).unwrap();
248 assert!(json.contains("\"status\":\"ok\""));
249 }
250
251 #[test]
252 fn webhook_payload_deserializes() {
253 let json = r#"{"channel":"discord","sender":"user1","body":"hello"}"#;
254 let payload: WebhookPayload = serde_json::from_str(json).unwrap();
255 assert_eq!(payload.channel, "discord");
256 assert_eq!(payload.sender, "user1");
257 assert_eq!(payload.body, "hello");
258 }
259
260 #[test]
261 fn validate_accepts_valid_payload() {
262 let payload = WebhookPayload {
263 channel: "ch".into(),
264 sender: "user".into(),
265 body: "hello".into(),
266 };
267 assert!(payload.validate().is_ok());
268 }
269
270 #[test]
271 fn validate_rejects_oversized_sender() {
272 let payload = WebhookPayload {
273 channel: "ch".into(),
274 sender: "a".repeat(257),
275 body: "hello".into(),
276 };
277 assert!(payload.validate().is_err());
278 }
279
280 #[test]
281 fn validate_rejects_oversized_channel() {
282 let payload = WebhookPayload {
283 channel: "c".repeat(257),
284 sender: "user".into(),
285 body: "hello".into(),
286 };
287 assert!(payload.validate().is_err());
288 }
289
290 #[test]
291 fn validate_rejects_oversized_body() {
292 let payload = WebhookPayload {
293 channel: "ch".into(),
294 sender: "user".into(),
295 body: "b".repeat(65537),
296 };
297 assert!(payload.validate().is_err());
298 }
299
300 #[test]
301 fn sanitize_strips_control_chars_keeps_newline() {
302 let input = "hel\x01lo\x7f\nworld";
303 let result = zeph_common::sanitize::strip_control_chars_preserve_whitespace(input);
304 assert_eq!(result, "hello\nworld");
305 }
306
307 #[test]
308 fn sanitize_strips_null_byte() {
309 let input = "he\x00llo";
310 let result = zeph_common::sanitize::strip_control_chars_preserve_whitespace(input);
311 assert_eq!(result, "hello");
312 }
313
314 #[tokio::test]
317 async fn webhook_handler_returns_503_on_send_timeout() {
318 use axum::extract::State;
319 use axum::response::IntoResponse as _;
320
321 let (tx, _rx) = tokio::sync::mpsc::channel::<WebhookMessage>(1);
322 tx.send(WebhookMessage {
324 sender: "fill".into(),
325 channel: "fill".into(),
326 body: "fill".into(),
327 })
328 .await
329 .unwrap();
330
331 let state = AppState {
332 webhook_tx: tx,
333 started_at: Instant::now(),
334 webhook_send_timeout: Duration::from_millis(5),
335 };
336
337 let payload = WebhookPayload {
338 channel: "ch".into(),
339 sender: "user".into(),
340 body: "hello".into(),
341 };
342
343 let response = webhook_handler(State(state), Ok(axum::Json(payload)))
344 .await
345 .into_response();
346 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
347 }
348
349 #[test]
350 fn validate_accepts_at_limit_sender() {
351 let payload = WebhookPayload {
352 channel: "ch".into(),
353 sender: "a".repeat(256),
354 body: "hello".into(),
355 };
356 assert!(payload.validate().is_ok());
357 }
358
359 #[test]
360 fn validate_accepts_at_limit_channel() {
361 let payload = WebhookPayload {
362 channel: "c".repeat(256),
363 sender: "user".into(),
364 body: "hello".into(),
365 };
366 assert!(payload.validate().is_ok());
367 }
368
369 #[test]
370 fn validate_accepts_at_limit_body() {
371 let payload = WebhookPayload {
372 channel: "ch".into(),
373 sender: "user".into(),
374 body: "b".repeat(65536),
375 };
376 assert!(payload.validate().is_ok());
377 }
378
379 #[tokio::test]
380 async fn webhook_handler_sanitizes_body() {
381 use axum::extract::State;
382 use axum::response::IntoResponse as _;
383
384 let (tx, mut rx) = tokio::sync::mpsc::channel::<WebhookMessage>(4);
385 let state = AppState {
386 webhook_tx: tx,
387 started_at: Instant::now(),
388 webhook_send_timeout: Duration::from_secs(1),
389 };
390
391 let payload = WebhookPayload {
392 channel: "ch".into(),
393 sender: "user".into(),
394 body: "hel\x01lo\x7fworld".into(),
395 };
396
397 let response = webhook_handler(State(state), Ok(axum::Json(payload)))
398 .await
399 .into_response();
400 assert_eq!(response.status(), StatusCode::OK);
401 let msg = rx.try_recv().expect("message must be forwarded");
402 assert_eq!(
403 msg,
404 WebhookMessage {
405 sender: "user".into(),
406 channel: "ch".into(),
407 body: "helloworld".into(),
408 }
409 );
410 }
411}