Skip to main content

mz_environmentd/http/
webhook.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Helpers for handling events from a Webhook source.
11
12use 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            // axum::Error wraps the underlying cause as its source. If that source is a
50            // LengthLimitError the body exceeded the configured limit (HTTP 413). Any other
51            // cause (TCP reset, decompression failure, etc.) is an internal read error (HTTP 500)
52            // and must not be reported as a size-limit violation.
53            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    // Collect headers into a map, while converting them into strings.
66    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            // If a header is included more than once, bail returning an error to the user.
70            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    // Append to the webhook source, retrying if we race with a concurrent `ALTER SOURCE` op.
80    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            // Note: think carefully before adding more errors here, we need to make sure we don't
95            // append data more than once.
96            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
107/// Append the provided `body` and `headers` to the webhook source identified via `database`,
108/// `schema`, and `name`.
109async 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    // Shenanigans to get the types working for the async retry.
119    let (database, schema, name) = (database.to_string(), schema.to_string(), name.to_string());
120
121    // Record the time we receive the request, for use if validation checks the current timestamp.
122    let received_at = adapter_client.now();
123
124    // Get an appender for the provided object, if that object exists.
125    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        // Remove the appender from our map, only re-insert it, if it's valid.
134        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            // We don't have a valid appender, so we need to get one.
140            //
141            // Note: we hold the lock while we acquire and appender to prevent a dogpile.
142            _ => {
143                tracing::info!(?database, ?schema, ?name, "fetching webhook appender");
144                adapter_client.metrics().webhook_get_appender.inc();
145
146                // Acquire and cache a new appender.
147                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    // These must happen before validation as we do not know if validation or
160    // packing will succeed and appending will begin
161    tx.increment_messages_received(1);
162    tx.increment_bytes_received(u64::cast_from(body.len()));
163
164    // If this source requires validation, then validate!
165    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    // Pack our body and headers into a Row.
175    let rows = pack_rows(body, &body_format, headers, &header_tys)?;
176
177    // Send the row to get appended.
178    tx.append(rows).await?;
179
180    Ok(())
181}
182
183/// Packs the body and headers of a webhook request into as many rows as necessary.
184///
185/// TODO(parkmycar): Should we be consolidating the returned Rows here? Presumably something in
186/// storage would already be doing it, so no need to do it twice?
187fn 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    // This method isn't that "deep" but it reflects the way we intend for the packing process to
194    // work and makes testing easier.
195    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
202/// Transforms the body of a webhook request into a `Vec<BodyRow>`.
203fn 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                // Automatically expand multiple JSON objects delimited by whitespace, e.g.
219                // newlines, into a single batch.
220                .into_iter::<serde_json::Value>()
221                // Optionally expand a JSON array into separate rows, if requested.
222                .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            // Note: `into_iter()` should be re-using the underlying allocation of the `objects`
232            // vector, and it's more readable to split these into separate iterators.
233            let rows = objects
234                .into_iter()
235                // Map a JSON object into a Row.
236                .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    // A `Row` cannot describe its schema without unpacking it. To add some safety we wrap the
249    // returned `Row`s in a newtype to signify they already have the "body" column packed.
250    let body_rows = rows.into_iter().map(BodyRow).collect();
251
252    Ok(body_rows)
253}
254
255/// Pack the headers of a request into a [`Row`].
256fn pack_header(
257    mut body_row: BodyRow,
258    headers: &BTreeMap<String, String>,
259    header_tys: &WebhookHeaders,
260) -> Result<Row, AppendWebhookError> {
261    // 1 column for the body plus however many are needed for the headers.
262    let num_cols = 1 + header_tys.num_columns();
263    // The provided Row already has the Body written.
264    let mut num_cols_written = 1;
265
266    let mut packer = RowPacker::for_existing_row(body_row.inner_mut());
267
268    // Pack the headers into our row, if required.
269    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    // Pack the mapped headers.
277    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            // If our block list is empty, then don't filter anything.
302            filters.block.is_empty() || !filters.block.contains(*header_name)
303        })
304        .filter(|(header_name, _val)| {
305            // If our allow list is empty, then don't filter anything.
306            filters.allow.is_empty() || filters.allow.contains(*header_name)
307        })
308        .map(|(key, val)| (key.as_str(), val.as_str()))
309}
310
311/// A [`Row`] that has the body of a request already packed into it.
312///
313/// Note: if you're constructing a [`BodyRow`] you need to guarantee the only column packed into
314/// the [`Row`] is a single "body" column.
315#[repr(transparent)]
316struct BodyRow(Row);
317
318impl BodyRow {
319    /// Obtain a mutable reference to the inner [`Row`].
320    fn inner_mut(&mut self) -> &mut Row {
321        &mut self.0
322    }
323
324    /// Return the inner [`Row`].
325    fn into_inner(self) -> Row {
326        self.0
327    }
328}
329
330/// Errors we can encounter when appending data to a Webhook Source.
331///
332/// Webhook sources are a bit special since they are handled by `environmentd` (all other sources
333/// are handled by `clusterd`) and data is "pushed" to them (all other source pull data). The
334/// errors also generally need to map to HTTP status codes that we can use to respond to a webhook
335/// request. As such, webhook errors don't cleanly map to any existing error type, hence the
336/// existence of this error type.
337///
338/// Note: 429 Too Many Requests is handled at the HTTP layer via Tower's
339/// `GlobalConcurrencyLimitLayer`, not through these error variants.
340#[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                    // TODO(parkmycar): Maybe map this to a HTTP 410 Gone instead of 404?
389                    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    // TODO(parkmycar): Move this strategy to `ore`?
451    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        // IdentifierMissing should get mapped to a specific status code.
484        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        // We should yield nothing since we block the only thing we allow.
538        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        // We should get a single Row regardless of whether or not we're requested to expand.
552        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        // We should get a single Row regardless of whether or not we're requested to expand.
562        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        // If we don't expand the body, we should have a single row.
589        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        // If we _do_ expand the body, we should have a two rows.
599        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            // Include the headers column with a random set of block and allow.
615            let filters = WebhookHeaderFilters { block, allow };
616            // Include half of the existing headers, append on some non-existing ones too.
617            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            // Call this method to make sure it doesn't panic.
635            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            // Include the headers column with a random set of block and allow.
683            let filters = WebhookHeaderFilters { block, allow };
684            // Include half of the existing headers, append on some non-existing ones too.
685            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}