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::{
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            // axum::Error wraps the underlying cause as its source. If that source is a
53            // LengthLimitError the body exceeded the configured limit (HTTP 413). Any other
54            // cause (TCP reset, decompression failure, etc.) is an internal read error (HTTP 500)
55            // and must not be reported as a size-limit violation.
56            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    // Collect headers into a map, while converting them into strings.
69    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            // If a header is included more than once, bail returning an error to the user.
73            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    // Append to the webhook source, retrying if we race with a concurrent `ALTER SOURCE` op.
83    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            // Note: think carefully before adding more errors here, we need to make sure we don't
99            // append data more than once.
100            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
111/// Append the provided `body` and `headers` to the webhook source identified via `database`,
112/// `schema`, and `name`.
113async 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    // Shenanigans to get the types working for the async retry.
124    let (database, schema, name) = (database.to_string(), schema.to_string(), name.to_string());
125
126    // Record the time we receive the request, for use if validation checks the current timestamp.
127    let received_at = adapter_client.now();
128
129    // Get an appender for the provided object, if that object exists.
130    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        // Remove the appender from our map, only re-insert it, if it's valid.
139        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            // We don't have a valid appender, so we need to get one.
145            //
146            // Note: we hold the lock while we acquire and appender to prevent a dogpile.
147            _ => {
148                tracing::info!(?database, ?schema, ?name, "fetching webhook appender");
149                adapter_client.metrics().webhook_get_appender.inc();
150
151                // Acquire and cache a new appender.
152                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    // These must happen before validation as we do not know if validation or
165    // packing will succeed and appending will begin
166    tx.increment_messages_received(1);
167    tx.increment_bytes_received(u64::cast_from(body.len()));
168
169    // If this source requires validation, then validate!
170    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    // Pack our body and headers into a Row.
185    let rows = pack_rows(body, &body_format, headers, &header_tys)?;
186
187    // Send the row to get appended.
188    tx.append(rows).await?;
189
190    Ok(())
191}
192
193/// Packs the body and headers of a webhook request into as many rows as necessary.
194///
195/// TODO(parkmycar): Should we be consolidating the returned Rows here? Presumably something in
196/// storage would already be doing it, so no need to do it twice?
197fn 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    // This method isn't that "deep" but it reflects the way we intend for the packing process to
204    // work and makes testing easier.
205    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
212/// Transforms the body of a webhook request into a `Vec<BodyRow>`.
213fn 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                // Automatically expand multiple JSON objects delimited by whitespace, e.g.
229                // newlines, into a single batch.
230                .into_iter::<serde_json::Value>()
231                // Optionally expand a JSON array into separate rows, if requested.
232                .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            // Note: `into_iter()` should be re-using the underlying allocation of the `objects`
242            // vector, and it's more readable to split these into separate iterators.
243            let rows = objects
244                .into_iter()
245                // Map a JSON object into a Row.
246                .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    // A `Row` cannot describe its schema without unpacking it. To add some safety we wrap the
259    // returned `Row`s in a newtype to signify they already have the "body" column packed.
260    let body_rows = rows.into_iter().map(BodyRow).collect();
261
262    Ok(body_rows)
263}
264
265/// Pack the headers of a request into a [`Row`].
266fn pack_header(
267    mut body_row: BodyRow,
268    headers: &BTreeMap<String, String>,
269    header_tys: &WebhookHeaders,
270) -> Result<Row, AppendWebhookError> {
271    // 1 column for the body plus however many are needed for the headers.
272    let num_cols = 1 + header_tys.num_columns();
273    // The provided Row already has the Body written.
274    let mut num_cols_written = 1;
275
276    let mut packer = RowPacker::for_existing_row(body_row.inner_mut());
277
278    // Pack the headers into our row, if required.
279    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    // Pack the mapped headers.
287    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            // If our block list is empty, then don't filter anything.
312            filters.block.is_empty() || !filters.block.contains(*header_name)
313        })
314        .filter(|(header_name, _val)| {
315            // If our allow list is empty, then don't filter anything.
316            filters.allow.is_empty() || filters.allow.contains(*header_name)
317        })
318        .map(|(key, val)| (key.as_str(), val.as_str()))
319}
320
321/// A [`Row`] that has the body of a request already packed into it.
322///
323/// Note: if you're constructing a [`BodyRow`] you need to guarantee the only column packed into
324/// the [`Row`] is a single "body" column.
325#[repr(transparent)]
326struct BodyRow(Row);
327
328impl BodyRow {
329    /// Obtain a mutable reference to the inner [`Row`].
330    fn inner_mut(&mut self) -> &mut Row {
331        &mut self.0
332    }
333
334    /// Return the inner [`Row`].
335    fn into_inner(self) -> Row {
336        self.0
337    }
338}
339
340/// Errors we can encounter when appending data to a Webhook Source.
341///
342/// Webhook sources are a bit special since they are handled by `environmentd` (all other sources
343/// are handled by `clusterd`) and data is "pushed" to them (all other source pull data). The
344/// errors also generally need to map to HTTP status codes that we can use to respond to a webhook
345/// request. As such, webhook errors don't cleanly map to any existing error type, hence the
346/// existence of this error type.
347///
348/// Note: 429 Too Many Requests is handled at the HTTP layer via Tower's
349/// `GlobalConcurrencyLimitLayer`, not through these error variants.
350#[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                    // TODO(parkmycar): Maybe map this to a HTTP 410 Gone instead of 404?
399                    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    // TODO(parkmycar): Move this strategy to `ore`?
461    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        // IdentifierMissing should get mapped to a specific status code.
494        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        // We should yield nothing since we block the only thing we allow.
548        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        // 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: false },
565            &BTreeMap::default(),
566            &WebhookHeaders::default(),
567        )
568        .unwrap();
569        assert_eq!(rows.len(), 1);
570
571        // We should get a single Row regardless of whether or not we're requested to expand.
572        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        // If we don't expand the body, we should have a single row.
599        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        // If we _do_ expand the body, we should have a two rows.
609        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            // Include the headers column with a random set of block and allow.
625            let filters = WebhookHeaderFilters { block, allow };
626            // Include half of the existing headers, append on some non-existing ones too.
627            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            // Call this method to make sure it doesn't panic.
645            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            // Include the headers column with a random set of block and allow.
693            let filters = WebhookHeaderFilters { block, allow };
694            // Include half of the existing headers, append on some non-existing ones too.
695            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}