1use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use mz_adapter::{AppendWebhookError, AppendWebhookResponse, WebhookAppenderCache};
16use mz_ore::cast::CastFrom;
17use mz_ore::retry::{Retry, RetryResult};
18use mz_ore::str::StrExt;
19use mz_repr::adt::jsonb::Jsonb;
20use mz_repr::{Datum, Diff, Row, RowPacker, SqlScalarType};
21use mz_sql::plan::{WebhookBodyFormat, WebhookHeaderFilters, WebhookHeaders};
22use mz_storage_types::controller::StorageError;
23
24use axum::body::Body;
25use axum::extract::{Path, State};
26use axum::response::IntoResponse;
27use bytes::Bytes;
28use http::StatusCode;
29use mz_adapter_types::dyncfgs::WEBHOOK_MAX_REQUEST_SIZE_BYTES;
30use thiserror::Error;
31
32use crate::http::WebhookState;
33
34pub async fn handle_webhook(
35 State(WebhookState {
36 adapter_client_rx,
37 webhook_cache,
38 dyncfgs,
39 }): State<WebhookState>,
40 Path((database, schema, name)): Path<(String, String, String)>,
41 headers: http::HeaderMap,
42 body: Body,
43) -> impl IntoResponse {
44 let max_request_size = WEBHOOK_MAX_REQUEST_SIZE_BYTES.get(&dyncfgs);
45 let body = axum::body::to_bytes(body, max_request_size)
46 .await
47 .map_err(|err| {
48 use std::error::Error;
49 if err
54 .source()
55 .is_some_and(|s| s.is::<http_body_util::LengthLimitError>())
56 {
57 WebhookError::BodyTooLarge {
58 max_bytes: max_request_size,
59 }
60 } else {
61 WebhookError::Internal(anyhow::anyhow!(err))
62 }
63 })?;
64 let adapter_client = adapter_client_rx.clone().await.expect("sender not dropped");
65 let mut headers_s = BTreeMap::new();
67 for (name, val) in headers.iter() {
68 if let Ok(val_s) = val.to_str().map(|s| s.to_string()) {
69 let existing = headers_s.insert(name.as_str().to_string(), val_s);
71 if existing.is_some() {
72 let msg = format!("{} provided more than once", name.as_str());
73 return Err(WebhookError::InvalidHeaders(msg));
74 }
75 }
76 }
77 let headers = Arc::new(headers_s);
78
79 Retry::default()
81 .max_tries(2)
82 .retry_async(|_| async {
83 let result = append_webhook(
84 &adapter_client,
85 &webhook_cache,
86 &database,
87 &schema,
88 &name,
89 &body,
90 &headers,
91 )
92 .await;
93
94 match result {
97 Ok(()) => RetryResult::Ok(()),
98 Err(e @ AppendWebhookError::ChannelClosed) => RetryResult::RetryableErr(e),
99 Err(e) => RetryResult::FatalErr(e),
100 }
101 })
102 .await?;
103
104 Ok::<_, WebhookError>(())
105}
106
107async fn append_webhook(
110 adapter_client: &mz_adapter::Client,
111 webhook_cache: &WebhookAppenderCache,
112 database: &str,
113 schema: &str,
114 name: &str,
115 body: &Bytes,
116 headers: &Arc<BTreeMap<String, String>>,
117) -> Result<(), AppendWebhookError> {
118 let (database, schema, name) = (database.to_string(), schema.to_string(), name.to_string());
120
121 let received_at = adapter_client.now();
123
124 let AppendWebhookResponse {
126 tx,
127 body_format,
128 header_tys,
129 validator,
130 } = async {
131 let mut guard = webhook_cache.entries.lock().await;
132
133 match guard.remove(&(database.clone(), schema.clone(), name.clone())) {
135 Some(appender) if !appender.tx.is_closed() => {
136 guard.insert((database, schema, name), appender.clone());
137 Ok::<_, AppendWebhookError>(appender)
138 }
139 _ => {
143 tracing::info!(?database, ?schema, ?name, "fetching webhook appender");
144 adapter_client.metrics().webhook_get_appender.inc();
145
146 let appender = adapter_client
148 .get_webhook_appender(database.clone(), schema.clone(), name.clone())
149 .await?;
150
151 guard.insert((database, schema, name), appender.clone());
152
153 Ok(appender)
154 }
155 }
156 }
157 .await?;
158
159 tx.increment_messages_received(1);
162 tx.increment_bytes_received(u64::cast_from(body.len()));
163
164 if let Some(validator) = validator {
166 let valid = validator
167 .eval(Bytes::clone(body), Arc::clone(headers), received_at)
168 .await?;
169 if !valid {
170 return Err(AppendWebhookError::ValidationFailed);
171 }
172 }
173
174 let rows = pack_rows(body, &body_format, headers, &header_tys)?;
176
177 tx.append(rows).await?;
179
180 Ok(())
181}
182
183fn pack_rows(
188 body: &[u8],
189 body_format: &WebhookBodyFormat,
190 headers: &BTreeMap<String, String>,
191 header_tys: &WebhookHeaders,
192) -> Result<Vec<(Row, Diff)>, AppendWebhookError> {
193 let rows = transform_body(body, body_format)?
196 .into_iter()
197 .map(|row| pack_header(row, headers, header_tys).map(|row| (row, Diff::ONE)))
198 .collect::<Result<_, _>>()?;
199 Ok(rows)
200}
201
202fn transform_body(
204 body: &[u8],
205 format: &WebhookBodyFormat,
206) -> Result<Vec<BodyRow>, AppendWebhookError> {
207 let rows = match format {
208 WebhookBodyFormat::Bytes => {
209 vec![Row::pack_slice(&[Datum::Bytes(body)])]
210 }
211 WebhookBodyFormat::Text => {
212 let s = std::str::from_utf8(body)
213 .map_err(|m| AppendWebhookError::InvalidUtf8Body { msg: m.to_string() })?;
214 vec![Row::pack_slice(&[Datum::String(s)])]
215 }
216 WebhookBodyFormat::Json { array } => {
217 let objects = serde_json::Deserializer::from_slice(body)
218 .into_iter::<serde_json::Value>()
221 .flat_map(|value| match value {
223 Ok(serde_json::Value::Array(inners)) if *array => {
224 itertools::Either::Left(inners.into_iter().map(Result::Ok))
225 }
226 value => itertools::Either::Right(std::iter::once(value)),
227 })
228 .collect::<Result<Vec<_>, _>>()
229 .map_err(|m| AppendWebhookError::InvalidJsonBody { msg: m.to_string() })?;
230
231 let rows = objects
234 .into_iter()
235 .map(|o| {
237 let row = Jsonb::from_serde_json(o)
238 .map_err(|m| AppendWebhookError::InvalidJsonBody { msg: m.to_string() })?
239 .into_row();
240 Ok::<_, AppendWebhookError>(row)
241 })
242 .collect::<Result<_, _>>()?;
243
244 rows
245 }
246 };
247
248 let body_rows = rows.into_iter().map(BodyRow).collect();
251
252 Ok(body_rows)
253}
254
255fn pack_header(
257 mut body_row: BodyRow,
258 headers: &BTreeMap<String, String>,
259 header_tys: &WebhookHeaders,
260) -> Result<Row, AppendWebhookError> {
261 let num_cols = 1 + header_tys.num_columns();
263 let mut num_cols_written = 1;
265
266 let mut packer = RowPacker::for_existing_row(body_row.inner_mut());
267
268 if let Some(filters) = &header_tys.header_column {
270 packer.push_dict(
271 filter_headers(headers, filters).map(|(name, val)| (name, Datum::String(val))),
272 );
273 num_cols_written += 1;
274 }
275
276 for idx in num_cols_written..num_cols {
278 let (header_name, use_bytes) = header_tys
279 .mapped_headers
280 .get(&idx)
281 .ok_or_else(|| anyhow::anyhow!("Invalid header column index {idx}"))?;
282 let header = headers.get(header_name);
283 let datum = match header {
284 Some(h) if *use_bytes => Datum::Bytes(h.as_bytes()),
285 Some(h) => Datum::String(h),
286 None => Datum::Null,
287 };
288 packer.push(datum);
289 }
290
291 Ok(body_row.into_inner())
292}
293
294fn filter_headers<'a: 'b, 'b>(
295 headers: &'a BTreeMap<String, String>,
296 filters: &'b WebhookHeaderFilters,
297) -> impl Iterator<Item = (&'a str, &'a str)> + 'b {
298 headers
299 .iter()
300 .filter(|(header_name, _val)| {
301 filters.block.is_empty() || !filters.block.contains(*header_name)
303 })
304 .filter(|(header_name, _val)| {
305 filters.allow.is_empty() || filters.allow.contains(*header_name)
307 })
308 .map(|(key, val)| (key.as_str(), val.as_str()))
309}
310
311#[repr(transparent)]
316struct BodyRow(Row);
317
318impl BodyRow {
319 fn inner_mut(&mut self) -> &mut Row {
321 &mut self.0
322 }
323
324 fn into_inner(self) -> Row {
326 self.0
327 }
328}
329
330#[derive(Error, Debug)]
341pub enum WebhookError {
342 #[error("no object was found at the path {}", .0.quoted())]
343 NotFound(String),
344 #[error("the required auth could not be found")]
345 SecretMissing,
346 #[error("headers of request were invalid: {0}")]
347 InvalidHeaders(String),
348 #[error("failed to deserialize body as {ty:?}: {msg}")]
349 InvalidBody { ty: SqlScalarType, msg: String },
350 #[error("failed to validate the request")]
351 ValidationFailed,
352 #[error("error occurred while running validation")]
353 ValidationError,
354 #[error("service unavailable")]
355 Unavailable,
356 #[error("internal storage failure! {0:?}")]
357 InternalStorageError(StorageError),
358 #[error("request body exceeds the maximum allowed size of {max_bytes} bytes")]
359 BodyTooLarge { max_bytes: usize },
360 #[error("internal failure! {0:?}")]
361 Internal(#[from] anyhow::Error),
362}
363
364impl From<AppendWebhookError> for WebhookError {
365 fn from(err: AppendWebhookError) -> Self {
366 match err {
367 AppendWebhookError::MissingSecret => WebhookError::SecretMissing,
368 AppendWebhookError::ValidationError => WebhookError::ValidationError,
369 AppendWebhookError::InvalidUtf8Body { msg } => WebhookError::InvalidBody {
370 ty: SqlScalarType::String,
371 msg,
372 },
373 AppendWebhookError::InvalidJsonBody { msg } => WebhookError::InvalidBody {
374 ty: SqlScalarType::Jsonb,
375 msg,
376 },
377 AppendWebhookError::UnknownWebhook {
378 database,
379 schema,
380 name,
381 } => WebhookError::NotFound(format!("'{database}.{schema}.{name}'")),
382 AppendWebhookError::ValidationFailed => WebhookError::ValidationFailed,
383 AppendWebhookError::ChannelClosed => {
384 WebhookError::Internal(anyhow::anyhow!("channel closed"))
385 }
386 AppendWebhookError::StorageError(storage_err) => {
387 match storage_err {
388 StorageError::IdentifierMissing(id) | StorageError::IdentifierInvalid(id) => {
390 WebhookError::NotFound(id.to_string())
391 }
392 StorageError::ShuttingDown(_) => WebhookError::Unavailable,
393 e => WebhookError::InternalStorageError(e),
394 }
395 }
396 AppendWebhookError::InternalError(err) => WebhookError::Internal(err),
397 }
398 }
399}
400
401impl IntoResponse for WebhookError {
402 fn into_response(self) -> axum::response::Response {
403 match self {
404 e @ WebhookError::NotFound(_) | e @ WebhookError::SecretMissing => {
405 (StatusCode::NOT_FOUND, e.to_string()).into_response()
406 }
407 e @ WebhookError::InvalidBody { .. }
408 | e @ WebhookError::ValidationFailed
409 | e @ WebhookError::ValidationError => {
410 (StatusCode::BAD_REQUEST, e.to_string()).into_response()
411 }
412 e @ WebhookError::InvalidHeaders(_) => {
413 (StatusCode::UNAUTHORIZED, e.to_string()).into_response()
414 }
415 e @ WebhookError::BodyTooLarge { .. } => {
416 (StatusCode::PAYLOAD_TOO_LARGE, e.to_string()).into_response()
417 }
418 e @ WebhookError::Unavailable => {
419 (StatusCode::SERVICE_UNAVAILABLE, e.to_string()).into_response()
420 }
421 e @ WebhookError::InternalStorageError(_) => {
422 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
423 }
424 WebhookError::Internal(e) => (
425 StatusCode::INTERNAL_SERVER_ERROR,
426 e.root_cause().to_string(),
427 )
428 .into_response(),
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use std::collections::{BTreeMap, BTreeSet};
436
437 use axum::response::IntoResponse;
438 use bytes::Bytes;
439 use http::StatusCode;
440 use mz_adapter::AppendWebhookError;
441 use mz_ore::assert_none;
442 use mz_repr::{GlobalId, Row};
443 use mz_sql::plan::{WebhookBodyFormat, WebhookHeaderFilters, WebhookHeaders};
444 use mz_storage_types::controller::StorageError;
445 use proptest::prelude::*;
446 use proptest::strategy::Union;
447
448 use super::{WebhookError, filter_headers, pack_rows};
449
450 fn arbitrary_json() -> impl Strategy<Value = serde_json::Value> {
452 let json_leaf = Union::new(vec![
453 any::<()>().prop_map(|_| serde_json::Value::Null).boxed(),
454 any::<bool>().prop_map(serde_json::Value::Bool).boxed(),
455 any::<i64>()
456 .prop_map(|x| serde_json::Value::Number(x.into()))
457 .boxed(),
458 any::<String>().prop_map(serde_json::Value::String).boxed(),
459 ]);
460
461 json_leaf.prop_recursive(4, 32, 8, |element| {
462 Union::new(vec![
463 prop::collection::vec(element.clone(), 0..16)
464 .prop_map(serde_json::Value::Array)
465 .boxed(),
466 prop::collection::hash_map(".*", element, 0..16)
467 .prop_map(|map| serde_json::Value::Object(map.into_iter().collect()))
468 .boxed(),
469 ])
470 })
471 }
472
473 #[track_caller]
474 fn check_rows(rows: &Vec<(Row, mz_repr::Diff)>, expected_rows: usize, expected_cols: usize) {
475 assert_eq!(rows.len(), expected_rows);
476 for (row, _diff) in rows {
477 assert_eq!(row.unpack().len(), expected_cols);
478 }
479 }
480
481 #[mz_ore::test]
482 fn smoke_test_storage_error_response_status() {
483 let resp = WebhookError::from(AppendWebhookError::StorageError(
485 StorageError::IdentifierMissing(GlobalId::User(42)),
486 ))
487 .into_response();
488 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
489 }
490
491 #[mz_ore::test]
492 fn smoke_test_filter_headers() {
493 let block = BTreeSet::from(["foo".to_string()]);
494 let allow = BTreeSet::from(["bar".to_string()]);
495
496 let headers = BTreeMap::from([
497 ("foo".to_string(), "1".to_string()),
498 ("bar".to_string(), "2".to_string()),
499 ("baz".to_string(), "3".to_string()),
500 ]);
501 let mut filters = WebhookHeaderFilters::default();
502 filters.block.clone_from(&block);
503
504 let mut h = filter_headers(&headers, &filters);
505 assert_eq!(h.next().unwrap().0, "bar");
506 assert_eq!(h.next().unwrap().0, "baz");
507 assert_none!(h.next());
508
509 let mut filters = WebhookHeaderFilters::default();
510 filters.allow.clone_from(&allow);
511
512 let mut h = filter_headers(&headers, &filters);
513 assert_eq!(h.next().unwrap().0, "bar");
514 assert_none!(h.next());
515
516 let mut filters = WebhookHeaderFilters::default();
517 filters.allow = allow;
518 filters.block = block;
519
520 let mut h = filter_headers(&headers, &filters);
521 assert_eq!(h.next().unwrap().0, "bar");
522 assert_none!(h.next());
523 }
524
525 #[mz_ore::test]
526 fn filter_headers_block_overrides_allow() {
527 let block = BTreeSet::from(["foo".to_string()]);
528 let allow = block.clone();
529
530 let headers = BTreeMap::from([
531 ("foo".to_string(), "1".to_string()),
532 ("bar".to_string(), "2".to_string()),
533 ("baz".to_string(), "3".to_string()),
534 ]);
535 let filters = WebhookHeaderFilters { block, allow };
536
537 let mut h = filter_headers(&headers, &filters);
539 assert_none!(h.next());
540 }
541
542 #[mz_ore::test]
543 fn test_json_array_single() {
544 let single_raw = r#"
545 {
546 "event_type": "i am a single object",
547 "another_field": 42
548 }
549 "#;
550
551 let rows = pack_rows(
553 single_raw.as_bytes(),
554 &WebhookBodyFormat::Json { array: false },
555 &BTreeMap::default(),
556 &WebhookHeaders::default(),
557 )
558 .unwrap();
559 assert_eq!(rows.len(), 1);
560
561 let rows = pack_rows(
563 single_raw.as_bytes(),
564 &WebhookBodyFormat::Json { array: true },
565 &BTreeMap::default(),
566 &WebhookHeaders::default(),
567 )
568 .unwrap();
569 assert_eq!(rows.len(), 1);
570 }
571
572 #[mz_ore::test]
573 fn test_json_deserializer_multi() {
574 let multi_raw = r#"
575 [
576 { "event_type": "smol" },
577 { "event_type": "dog" }
578 ]
579 "#;
580
581 let rows = pack_rows(
582 multi_raw.as_bytes(),
583 &WebhookBodyFormat::Json { array: false },
584 &BTreeMap::default(),
585 &WebhookHeaders::default(),
586 )
587 .unwrap();
588 assert_eq!(rows.len(), 1);
590
591 let rows = pack_rows(
592 multi_raw.as_bytes(),
593 &WebhookBodyFormat::Json { array: true },
594 &BTreeMap::default(),
595 &WebhookHeaders::default(),
596 )
597 .unwrap();
598 assert_eq!(rows.len(), 2);
600 }
601
602 proptest! {
603 #[mz_ore::test]
604 fn proptest_pack_row_never_panics(
605 body: Vec<u8>,
606 body_ty: WebhookBodyFormat,
607 headers: BTreeMap<String, String>,
608 non_existent_headers: Vec<String>,
609 block: BTreeSet<String>,
610 allow: BTreeSet<String>,
611 ) {
612 let body = Bytes::from(body);
613
614 let filters = WebhookHeaderFilters { block, allow };
616 let mut use_bytes = false;
618 let mapped_headers = headers
619 .keys()
620 .take(headers.len() / 2)
621 .chain(non_existent_headers.iter())
622 .cloned()
623 .enumerate()
624 .map(|(idx, name)| {
625 use_bytes = !use_bytes;
626 (idx + 2, (name, use_bytes))
627 })
628 .collect();
629 let header_tys = WebhookHeaders {
630 header_column: Some(filters),
631 mapped_headers,
632 };
633
634 let _ = pack_rows(&body[..], &body_ty, &headers, &header_tys);
636 }
637
638 #[mz_ore::test]
639 fn proptest_pack_row_succeeds_for_bytes(
640 body: Vec<u8>,
641 headers: BTreeMap<String, String>,
642 include_headers: bool,
643 ) {
644 let body = Bytes::from(body);
645
646 let body_ty = WebhookBodyFormat::Bytes;
647 let mut header_tys = WebhookHeaders::default();
648 header_tys.header_column = include_headers.then(Default::default);
649
650 let rows = pack_rows(&body[..], &body_ty, &headers, &header_tys).unwrap();
651 check_rows(&rows, 1, header_tys.num_columns() + 1);
652 }
653
654 #[mz_ore::test]
655 fn proptest_pack_row_succeeds_for_strings(
656 body: String,
657 headers: BTreeMap<String, String>,
658 include_headers: bool,
659 ) {
660 let body = Bytes::from(body);
661
662 let body_ty = WebhookBodyFormat::Text;
663 let mut header_tys = WebhookHeaders::default();
664 header_tys.header_column = include_headers.then(Default::default);
665
666 let rows = pack_rows(&body[..], &body_ty, &headers, &header_tys).unwrap();
667 check_rows(&rows, 1, header_tys.num_columns() + 1);
668 }
669
670 #[mz_ore::test]
671 fn proptest_pack_row_succeeds_for_selective_headers(
672 body: String,
673 headers: BTreeMap<String, String>,
674 include_headers: bool,
675 non_existent_headers: Vec<String>,
676 block: BTreeSet<String>,
677 allow: BTreeSet<String>,
678 ) {
679 let body = Bytes::from(body);
680 let body_ty = WebhookBodyFormat::Text;
681
682 let filters = WebhookHeaderFilters { block, allow };
684 let mut use_bytes = false;
686 let column_offset = if include_headers { 2 } else { 1 };
687 let mapped_headers = headers
688 .keys()
689 .take(headers.len() / 2)
690 .chain(non_existent_headers.iter())
691 .cloned()
692 .enumerate()
693 .map(|(idx, name)| {
694 use_bytes = !use_bytes;
695 (idx + column_offset, (name, use_bytes))
696 })
697 .collect();
698 let header_tys = WebhookHeaders {
699 header_column: include_headers.then_some(filters),
700 mapped_headers,
701 };
702
703 let rows = pack_rows(&body[..], &body_ty, &headers, &header_tys).unwrap();
704 check_rows(&rows, 1, header_tys.num_columns() + 1);
705 }
706
707 #[mz_ore::test]
708 fn proptest_pack_json_with_array_expansion(
709 body in arbitrary_json(),
710 expand_array: bool,
711 headers: BTreeMap<String, String>,
712 include_headers: bool,
713 ) {
714 let json_raw = serde_json::to_vec(&body).unwrap();
715 let mut header_tys = WebhookHeaders::default();
716 header_tys.header_column = include_headers.then(Default::default);
717
718 let rows = pack_rows(
719 &json_raw[..],
720 &WebhookBodyFormat::Json { array: expand_array },
721 &headers,
722 &header_tys,
723 )
724 .unwrap();
725
726 let expected_num_rows = match body {
727 serde_json::Value::Array(inner) if expand_array => inner.len(),
728 _ => 1,
729 };
730 check_rows(&rows, expected_num_rows, header_tys.num_columns() + 1);
731 }
732 }
733}