From ab0be9a47a40474cb0293b6164ab97a05ec20469 Mon Sep 17 00:00:00 2001 From: Li Jiajia Date: Fri, 14 Aug 2026 01:49:41 -0400 Subject: [PATCH] feat(rest): wrap catalog responses in HttpResponse --- crates/catalog/rest/public-api.txt | 12 ++- crates/catalog/rest/src/auth/oauth2.rs | 12 ++- crates/catalog/rest/src/catalog.rs | 76 ++++++-------- crates/catalog/rest/src/client.rs | 126 +++++++++++++++++----- crates/catalog/rest/src/lib.rs | 2 + crates/catalog/rest/src/response.rs | 139 +++++++++++++++++++++++++ 6 files changed, 287 insertions(+), 80 deletions(-) create mode 100644 crates/catalog/rest/src/response.rs diff --git a/crates/catalog/rest/public-api.txt b/crates/catalog/rest/public-api.txt index 6f3fa58a47..3d6aefbd05 100644 --- a/crates/catalog/rest/public-api.txt +++ b/crates/catalog/rest/public-api.txt @@ -126,7 +126,7 @@ impl<'de> serde_core::de::Deserialize<'de> for iceberg_catalog_rest::ErrorRespon pub fn iceberg_catalog_rest::ErrorResponse::deserialize<__D>(__deserializer: __D) -> core::result::Result::Error> where __D: serde_core::de::Deserializer<'de> pub struct iceberg_catalog_rest::HttpClient impl iceberg_catalog_rest::HttpClient -pub async fn iceberg_catalog_rest::HttpClient::post_form(&self, url: &str, headers: &http::header::map::HeaderMap, form: &std::collections::hash::map::HashMap<&str, &str>) -> iceberg::error::Result<(http::status::StatusCode, alloc::vec::Vec)> +pub async fn iceberg_catalog_rest::HttpClient::post_form(&self, url: &str, headers: &http::header::map::HeaderMap, form: &std::collections::hash::map::HashMap<&str, &str>) -> iceberg::error::Result pub fn iceberg_catalog_rest::HttpClient::with_auth_session(&self, auth_session: alloc::sync::Arc) -> Self pub fn iceberg_catalog_rest::HttpClient::without_auth_session(&self) -> Self impl core::clone::Clone for iceberg_catalog_rest::HttpClient @@ -141,6 +141,16 @@ pub fn iceberg_catalog_rest::HttpRequest::headers_mut(&mut self) -> &mut http::h pub fn iceberg_catalog_rest::HttpRequest::method(&self) -> &http::method::Method pub fn iceberg_catalog_rest::HttpRequest::new(inner: reqwest::async_impl::request::Request) -> Self pub fn iceberg_catalog_rest::HttpRequest::url_str(&self) -> &str +pub struct iceberg_catalog_rest::HttpResponse +impl iceberg_catalog_rest::HttpResponse +pub fn iceberg_catalog_rest::HttpResponse::body(&self) -> &[u8] +pub fn iceberg_catalog_rest::HttpResponse::headers(&self) -> &http::header::map::HeaderMap +pub fn iceberg_catalog_rest::HttpResponse::new(status: http::status::StatusCode, headers: http::header::map::HeaderMap, body: alloc::vec::Vec) -> Self +pub fn iceberg_catalog_rest::HttpResponse::status(&self) -> http::status::StatusCode +impl core::clone::Clone for iceberg_catalog_rest::HttpResponse +pub fn iceberg_catalog_rest::HttpResponse::clone(&self) -> iceberg_catalog_rest::HttpResponse +impl core::fmt::Debug for iceberg_catalog_rest::HttpResponse +pub fn iceberg_catalog_rest::HttpResponse::fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct iceberg_catalog_rest::ListNamespaceResponse pub iceberg_catalog_rest::ListNamespaceResponse::namespaces: alloc::vec::Vec pub iceberg_catalog_rest::ListNamespaceResponse::next_page_token: core::option::Option diff --git a/crates/catalog/rest/src/auth/oauth2.rs b/crates/catalog/rest/src/auth/oauth2.rs index 897fcbb853..22d7e8f20e 100644 --- a/crates/catalog/rest/src/auth/oauth2.rs +++ b/crates/catalog/rest/src/auth/oauth2.rs @@ -278,29 +278,31 @@ impl ClientCredentialsConfig { .map(|(k, v)| (k.as_str(), v.as_str())), ); - let (status, body) = self + let response = self .client .post_form(&self.token_endpoint, &self.extra_headers, ¶ms) .await?; + let status = response.status(); + let body = response.body(); let auth_res: TokenResponse = if status == StatusCode::OK { - Ok(serde_json::from_slice(&body).map_err(|e| { + Ok(serde_json::from_slice(body).map_err(|e| { Error::new( ErrorKind::Unexpected, "Failed to parse response from rest catalog server!", ) .with_context("operation", "auth") .with_context("url", self.token_endpoint.clone()) - .with_context("json", String::from_utf8_lossy(&body)) + .with_context("json", String::from_utf8_lossy(body)) .with_source(e) })?) } else { - let e: ErrorResponse = serde_json::from_slice(&body).map_err(|e| { + let e: ErrorResponse = serde_json::from_slice(body).map_err(|e| { Error::new(ErrorKind::Unexpected, "Received unexpected response") .with_context("code", status.to_string()) .with_context("operation", "auth") .with_context("url", self.token_endpoint.clone()) - .with_context("json", String::from_utf8_lossy(&body)) + .with_context("json", String::from_utf8_lossy(body)) .with_source(e) })?; Err(Error::from(e)) diff --git a/crates/catalog/rest/src/catalog.rs b/crates/catalog/rest/src/catalog.rs index 0c15c50663..e1f45f6cbb 100644 --- a/crates/catalog/rest/src/catalog.rs +++ b/crates/catalog/rest/src/catalog.rs @@ -35,7 +35,7 @@ use itertools::Itertools; use reqwest::header::{ HeaderMap, HeaderName, HeaderValue, {self}, }; -use reqwest::{Client, Method, Response, StatusCode, Url}; +use reqwest::{Client, Method, StatusCode, Url}; use tokio::sync::OnceCell; use typed_builder::TypedBuilder; @@ -45,6 +45,7 @@ use crate::client::{ }; use crate::endpoint::{Endpoint, V1_NAMESPACE_EXISTS, V1_TABLE_EXISTS}; use crate::request::HttpRequest; +use crate::response::HttpResponse; use crate::types::{ CatalogConfig, CommitTableRequest, CommitTableResponse, CreateNamespaceRequest, CreateTableRequest, ListNamespaceResponse, ListTablesResponse, LoadTableResult, @@ -339,7 +340,7 @@ impl RestCatalogConfig { /// Merge the `RestCatalogConfig` with the a [`CatalogConfig`] (fetched from the REST server). pub(crate) fn merge_with_config(mut self, mut config: CatalogConfig) -> Self { - if let Some(uri) = config.overrides.remove("uri") { + if let Some(uri) = config.overrides.remove(REST_CATALOG_PROP_URI) { self.uri = uri; } @@ -479,7 +480,7 @@ impl RestClient { } /// Sends `request`, authenticated by the client's session. - async fn query_catalog(&self, request: HttpRequest) -> Result { + async fn query_catalog(&self, request: HttpRequest) -> Result { self.http_client.query_catalog(request).await } } @@ -488,7 +489,8 @@ impl RestClient { #[derive(Debug)] pub struct RestCatalog { /// Injected through [`RestCatalogBuilder::with_auth_manager`]; otherwise - /// one is resolved from `rest.auth.type` when the context is built. + /// one is resolved from `rest.auth.type` when [`RestClient`] is + /// initialized. auth_manager: Option>, /// User config is stored as-is and never be changed. /// @@ -545,8 +547,7 @@ impl RestCatalog { _ => Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await), + )), } } @@ -691,8 +692,7 @@ impl RestCatalog { _ => Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await), + )), } } @@ -714,12 +714,11 @@ impl RestCatalog { let http_response = http_client.query_catalog(request).await?; match http_response.status() { - StatusCode::OK => deserialize_catalog_response(http_response).await, + StatusCode::OK => deserialize_catalog_response(http_response), _ => Err(deserialize_unexpected_catalog_error( http_response, http_client.disable_header_redaction(), - ) - .await), + )), } } @@ -766,7 +765,7 @@ impl RestCatalog { } /// All requests and expected responses are derived from the REST catalog API spec: -/// https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml +/// #[async_trait] impl Catalog for RestCatalog { async fn list_namespaces( @@ -795,8 +794,7 @@ impl Catalog for RestCatalog { match http_response.status() { StatusCode::OK => { let response = - deserialize_catalog_response::(http_response) - .await?; + deserialize_catalog_response::(http_response)?; namespaces.extend(response.namespaces); @@ -815,8 +813,7 @@ impl Catalog for RestCatalog { return Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await); + )); } } } @@ -845,8 +842,7 @@ impl Catalog for RestCatalog { match http_response.status() { StatusCode::OK => { - let response = - deserialize_catalog_response::(http_response).await?; + let response = deserialize_catalog_response::(http_response)?; Ok(Namespace::from(response)) } StatusCode::CONFLICT => Err(Error::new( @@ -856,8 +852,7 @@ impl Catalog for RestCatalog { _ => Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await), + )), } } @@ -874,8 +869,7 @@ impl Catalog for RestCatalog { match http_response.status() { StatusCode::OK => { - let response = - deserialize_catalog_response::(http_response).await?; + let response = deserialize_catalog_response::(http_response)?; Ok(Namespace::from(response)) } StatusCode::NOT_FOUND => Err(Error::new( @@ -885,8 +879,7 @@ impl Catalog for RestCatalog { _ => Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await), + )), } } @@ -939,8 +932,7 @@ impl Catalog for RestCatalog { _ => Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await), + )), } } @@ -962,7 +954,7 @@ impl Catalog for RestCatalog { match http_response.status() { StatusCode::OK => { let response = - deserialize_catalog_response::(http_response).await?; + deserialize_catalog_response::(http_response)?; identifiers.extend(response.identifiers); @@ -981,8 +973,7 @@ impl Catalog for RestCatalog { return Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await); + )); } } } @@ -1023,9 +1014,7 @@ impl Catalog for RestCatalog { let http_response = client.query_catalog(request).await?; let response = match http_response.status() { - StatusCode::OK => { - deserialize_catalog_response::(http_response).await? - } + StatusCode::OK => deserialize_catalog_response::(http_response)?, StatusCode::NOT_FOUND => { return Err(Error::new( ErrorKind::NamespaceNotFound, @@ -1042,8 +1031,7 @@ impl Catalog for RestCatalog { return Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await); + )); } }; @@ -1096,7 +1084,7 @@ impl Catalog for RestCatalog { let response = match http_response.status() { StatusCode::OK | StatusCode::NOT_MODIFIED => { - deserialize_catalog_response::(http_response).await? + deserialize_catalog_response::(http_response)? } StatusCode::NOT_FOUND => { return Err(Error::new( @@ -1108,8 +1096,7 @@ impl Catalog for RestCatalog { return Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await); + )); } }; @@ -1197,8 +1184,7 @@ impl Catalog for RestCatalog { _ => Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await), + )), } } @@ -1228,9 +1214,7 @@ impl Catalog for RestCatalog { let http_response = client.query_catalog(request).await?; let response: LoadTableResult = match http_response.status() { - StatusCode::OK => { - deserialize_catalog_response::(http_response).await? - } + StatusCode::OK => deserialize_catalog_response::(http_response)?, StatusCode::NOT_FOUND => { return Err(Error::new( ErrorKind::NamespaceNotFound, @@ -1247,8 +1231,7 @@ impl Catalog for RestCatalog { return Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await); + )); } }; @@ -1291,7 +1274,7 @@ impl Catalog for RestCatalog { let http_response = client.query_catalog(request).await?; let response: CommitTableResponse = match http_response.status() { - StatusCode::OK => deserialize_catalog_response(http_response).await?, + StatusCode::OK => deserialize_catalog_response(http_response)?, StatusCode::NOT_FOUND => { return Err(Error::new( ErrorKind::TableNotFound, @@ -1327,8 +1310,7 @@ impl Catalog for RestCatalog { return Err(deserialize_unexpected_catalog_error( http_response, client.http_client.disable_header_redaction(), - ) - .await); + )); } }; diff --git a/crates/catalog/rest/src/client.rs b/crates/catalog/rest/src/client.rs index 4ed3d6d023..4a4c49a19f 100644 --- a/crates/catalog/rest/src/client.rs +++ b/crates/catalog/rest/src/client.rs @@ -21,12 +21,13 @@ use std::sync::Arc; use iceberg::{Error, ErrorKind, Result}; use reqwest::header::HeaderMap; -use reqwest::{Client, IntoUrl, Method, RequestBuilder, Response, StatusCode}; +use reqwest::{Client, IntoUrl, Method, RequestBuilder}; use serde::de::DeserializeOwned; use crate::RestCatalogConfig; use crate::auth::{AuthSession, NoopSession}; use crate::request::HttpRequest; +use crate::response::HttpResponse; /// The catalog's HTTP client, handed to an [`AuthManager`] so its own /// requests share the catalog's connection pool and configuration. @@ -86,7 +87,7 @@ impl HttpClient { /// not merged in. /// /// Like every request, it carries this client's session; call - /// [`Self::without_session`] first to send it unauthenticated. + /// [`Self::without_auth_session`] first to send it unauthenticated. /// /// [`AuthManager`]: crate::auth::AuthManager pub async fn post_form( @@ -94,7 +95,7 @@ impl HttpClient { url: &str, headers: &HeaderMap, form: &HashMap<&str, &str>, - ) -> Result<(StatusCode, Vec)> { + ) -> Result { let mut request = HttpRequest::build( self.client .request(Method::POST, url) @@ -109,12 +110,7 @@ impl HttpClient { ); self.auth_session.authenticate(&mut request).await?; let response = self.client.execute(request.into_inner()).await?; - let status = response.status(); - let body = response - .bytes() - .await - .map_err(|err| err.with_url(url.parse().unwrap_or_else(|_| "/".parse().unwrap())))?; - Ok((status, body.to_vec())) + HttpResponse::read(response).await } /// Create a new http client. @@ -182,13 +178,13 @@ impl HttpClient { // Queries the Iceberg REST catalog after authentication with the given `Request` and // returns a `Response`. - pub(crate) async fn query_catalog(&self, mut request: HttpRequest) -> Result { + pub(crate) async fn query_catalog(&self, mut request: HttpRequest) -> Result { // Authenticate first, then apply extra headers, so a configured // `header.authorization` keeps overriding a token (unchanged behavior). self.auth_session.authenticate(&mut request).await?; let mut request = request.into_inner(); request.headers_mut().extend(self.extra_headers.clone()); - Ok(self.client.execute(request).await?) + HttpResponse::read(self.client.execute(request).await?).await } /// Returns whether header redaction is disabled for this client. @@ -197,20 +193,20 @@ impl HttpClient { } } -/// Deserializes a catalog response into the given [`DeserializedOwned`] type. +/// Deserializes a catalog response into the given [`DeserializeOwned`] type. /// /// Returns an error if unable to parse the response bytes. -pub(crate) async fn deserialize_catalog_response( - response: Response, +pub(crate) fn deserialize_catalog_response( + response: HttpResponse, ) -> Result { - let bytes = response.bytes().await?; + let bytes = response.body(); - serde_json::from_slice::(&bytes).map_err(|e| { + serde_json::from_slice::(bytes).map_err(|e| { Error::new( ErrorKind::Unexpected, "Failed to parse response from rest catalog server", ) - .with_context("json", String::from_utf8_lossy(&bytes)) + .with_context("json", String::from_utf8_lossy(bytes)) .with_source(e) }) } @@ -235,8 +231,8 @@ fn is_sensitive_header(name: &str) -> bool { /// Redacts sensitive headers and returns a debug-formatted string. /// /// If `disable_redaction` is true, returns all headers without redaction. -/// Otherwise, replaces sensitive header values with "[REDACTED]". -fn format_headers_redacted(headers: &HeaderMap, disable_redaction: bool) -> String { +/// Otherwise, replaces sensitive header values with `[REDACTED]`. +pub(crate) fn format_headers_redacted(headers: &HeaderMap, disable_redaction: bool) -> String { if disable_redaction { // Return all headers as-is without redaction let all: HashMap<&str, &str> = headers @@ -261,8 +257,8 @@ fn format_headers_redacted(headers: &HeaderMap, disable_redaction: bool) -> Stri } /// Deserializes a unexpected catalog response into an error. -pub(crate) async fn deserialize_unexpected_catalog_error( - response: Response, +pub(crate) fn deserialize_unexpected_catalog_error( + response: HttpResponse, disable_header_redaction: bool, ) -> Error { let err = Error::new( @@ -275,15 +271,11 @@ pub(crate) async fn deserialize_unexpected_catalog_error( format_headers_redacted(response.headers(), disable_header_redaction), ); - let bytes = match response.bytes().await { - Ok(bytes) => bytes, - Err(err) => return err.into(), - }; - + let bytes = response.body(); if bytes.is_empty() { return err; } - err.with_context("json", String::from_utf8_lossy(&bytes)) + err.with_context("json", String::from_utf8_lossy(bytes)) } #[cfg(test)] @@ -303,6 +295,86 @@ mod tests { } } + #[tokio::test] + async fn test_a_truncated_body_error_names_the_url() { + // `bytes()` builds its error without a URL, so `read` attaches the one + // the response came from; otherwise a failure can't be attributed to a + // catalog. Needs a raw socket: the body has to be cut short. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let _ = std::io::Read::read(&mut stream, &mut buf); + // Promise more than is sent, then hang up. + let _ = std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nshort", + ); + }); + + let url = format!("http://{addr}/token"); + let err = HttpClient::new(&RestCatalogConfig::builder().uri(url.clone()).build()) + .unwrap() + .post_form(&url, &HeaderMap::new(), &HashMap::new()) + .await + .unwrap_err(); + + assert!(format!("{err:?}").contains(&url), "{err:?}"); + } + + #[tokio::test] + async fn test_reading_a_response_keeps_status_headers_and_body() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/token") + .with_status(418) + .with_header("x-request-id", "abc123") + .with_body("brewing") + .create_async() + .await; + + let response = HttpClient::new(&RestCatalogConfig::builder().uri(server.url()).build()) + .unwrap() + .post_form( + &format!("{}/token", server.url()), + &HeaderMap::new(), + &HashMap::new(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 418); + assert_eq!(response.headers().get("x-request-id").unwrap(), "abc123"); + assert_eq!(response.body(), b"brewing"); + mock.assert_async().await; + } + + #[test] + fn test_unexpected_error_carries_status_headers_and_body() { + // Everything a user needs to diagnose an unexpected status, with the + // sensitive headers held back. + let mut headers = HeaderMap::new(); + headers.insert("authorization", "Bearer leaked".parse().unwrap()); + headers.insert("x-request-id", "abc123".parse().unwrap()); + let response = HttpResponse::new( + http::StatusCode::IM_A_TEAPOT, + headers, + br#"{"error": "nope"}"#.to_vec(), + ); + + let err = format!( + "{:?}", + deserialize_unexpected_catalog_error(response, false) + ); + + assert!(err.contains("418"), "{err}"); + assert!(err.contains("x-request-id"), "{err}"); + assert!(err.contains("abc123"), "{err}"); + assert!(err.contains("nope"), "{err}"); + assert!(!err.contains("leaked"), "{err}"); + } + #[tokio::test] async fn test_post_form_carries_the_session_until_it_is_removed() { // Every request a client sends carries its session; a caller that diff --git a/crates/catalog/rest/src/lib.rs b/crates/catalog/rest/src/lib.rs index 5670ef3c9c..be828ee859 100644 --- a/crates/catalog/rest/src/lib.rs +++ b/crates/catalog/rest/src/lib.rs @@ -57,6 +57,8 @@ mod client; pub use client::HttpClient; mod request; pub use request::{HttpRequest, HttpRequestBody}; +mod response; +pub use response::HttpResponse; mod endpoint; mod types; diff --git a/crates/catalog/rest/src/response.rs b/crates/catalog/rest/src/response.rs new file mode 100644 index 0000000000..3158851b57 --- /dev/null +++ b/crates/catalog/rest/src/response.rs @@ -0,0 +1,139 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! The response type REST catalog requests come back as. + +use std::fmt::{Debug, Formatter}; + +use http::{HeaderMap, StatusCode}; +use iceberg::Result; +use reqwest::Response; + +use crate::client::format_headers_redacted; + +/// A REST catalog response, read into memory. +/// +/// The counterpart of [`HttpRequest`](crate::HttpRequest): it keeps the +/// concrete client type inside [`HttpClient`](crate::HttpClient) so callers +/// work with the stable `http` crate types instead. Catalog responses are +/// small JSON documents that every caller reads in full, so the body is +/// buffered rather than streamed. +#[derive(Clone)] +pub struct HttpResponse { + status: StatusCode, + headers: HeaderMap, + body: Vec, +} + +impl Debug for HttpResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + // A token exchange answers with a credential in the body, and headers + // may carry `set-cookie`, so both are held back. + f.debug_struct("HttpResponse") + .field("status", &self.status) + // Always redacted: a response carries no config, and the + // `disable-header-redaction` escape hatch is for the request side. + .field("headers", &format_headers_redacted(&self.headers, false)) + .field("body", &format_args!("{} bytes", self.body.len())) + .finish() + } +} + +impl HttpResponse { + /// Reads `response` into memory. + pub(crate) async fn read(response: Response) -> Result { + let status = response.status(); + let headers = response.headers().clone(); + // `bytes()` builds its error without a URL, so keep the one the + // response came from. + let url = response.url().clone(); + Ok(Self { + status, + headers, + body: response + .bytes() + .await + .map_err(|err| err.with_url(url))? + .to_vec(), + }) + } + + /// Builds a response, e.g. to unit-test code that consumes one. + pub fn new(status: StatusCode, headers: HeaderMap, body: Vec) -> Self { + Self { + status, + headers, + body, + } + } + + /// The response status. + pub fn status(&self) -> StatusCode { + self.status + } + + /// The response headers. + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// The response body. + pub fn body(&self) -> &[u8] { + &self.body + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_debug_holds_back_secrets() { + // The body of a token exchange is a credential, and `set-cookie` is + // redacted everywhere else in this crate. + let mut headers = HeaderMap::new(); + headers.insert("set-cookie", "session=secret".parse().unwrap()); + headers.insert("content-type", "application/json".parse().unwrap()); + let debug = format!( + "{:?}", + HttpResponse::new( + StatusCode::OK, + headers, + br#"{"access_token": "tok"}"#.to_vec() + ) + ); + + assert!(!debug.contains("secret"), "{debug}"); + assert!(!debug.contains("tok"), "{debug}"); + assert!(debug.contains("content-type"), "{debug}"); + assert!(debug.contains("200"), "{debug}"); + } + + #[test] + fn test_new_exposes_what_it_was_built_from() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + let response = HttpResponse::new(StatusCode::OK, headers, "{}".as_bytes().to_vec()); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json" + ); + assert_eq!(response.body(), b"{}"); + } +}