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