iceberg_catalog_rest/response.rs
1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! The response type REST catalog requests come back as.
19
20use std::fmt::{Debug, Formatter};
21
22use http::{HeaderMap, StatusCode};
23use iceberg::Result;
24use reqwest::Response;
25
26use crate::client::format_headers_redacted;
27
28/// A REST catalog response, read into memory.
29///
30/// The counterpart of [`HttpRequest`](crate::HttpRequest): it keeps the
31/// concrete client type inside [`HttpClient`](crate::HttpClient) so callers
32/// work with the stable `http` crate types instead. Catalog responses are
33/// small JSON documents that every caller reads in full, so the body is
34/// buffered rather than streamed.
35#[derive(Clone)]
36pub struct HttpResponse {
37 status: StatusCode,
38 headers: HeaderMap,
39 body: Vec<u8>,
40}
41
42impl Debug for HttpResponse {
43 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
44 // A token exchange answers with a credential in the body, and headers
45 // may carry `set-cookie`, so both are held back.
46 f.debug_struct("HttpResponse")
47 .field("status", &self.status)
48 // Always redacted: a response carries no config, and the
49 // `disable-header-redaction` escape hatch is for the request side.
50 .field("headers", &format_headers_redacted(&self.headers, false))
51 .field("body", &format_args!("{} bytes", self.body.len()))
52 .finish()
53 }
54}
55
56impl HttpResponse {
57 /// Reads `response` into memory.
58 pub(crate) async fn read(response: Response) -> Result<Self> {
59 let status = response.status();
60 let headers = response.headers().clone();
61 // `bytes()` builds its error without a URL, so keep the one the
62 // response came from.
63 let url = response.url().clone();
64 Ok(Self {
65 status,
66 headers,
67 body: response
68 .bytes()
69 .await
70 .map_err(|err| err.with_url(url))?
71 .to_vec(),
72 })
73 }
74
75 /// Builds a response, e.g. to unit-test code that consumes one.
76 pub fn new(status: StatusCode, headers: HeaderMap, body: Vec<u8>) -> Self {
77 Self {
78 status,
79 headers,
80 body,
81 }
82 }
83
84 /// The response status.
85 pub fn status(&self) -> StatusCode {
86 self.status
87 }
88
89 /// The response headers.
90 pub fn headers(&self) -> &HeaderMap {
91 &self.headers
92 }
93
94 /// The response body.
95 pub fn body(&self) -> &[u8] {
96 &self.body
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn test_debug_holds_back_secrets() {
106 // The body of a token exchange is a credential, and `set-cookie` is
107 // redacted everywhere else in this crate.
108 let mut headers = HeaderMap::new();
109 headers.insert("set-cookie", "session=secret".parse().unwrap());
110 headers.insert("content-type", "application/json".parse().unwrap());
111 let debug = format!(
112 "{:?}",
113 HttpResponse::new(
114 StatusCode::OK,
115 headers,
116 br#"{"access_token": "tok"}"#.to_vec()
117 )
118 );
119
120 assert!(!debug.contains("secret"), "{debug}");
121 assert!(!debug.contains("tok"), "{debug}");
122 assert!(debug.contains("content-type"), "{debug}");
123 assert!(debug.contains("200"), "{debug}");
124 }
125
126 #[test]
127 fn test_new_exposes_what_it_was_built_from() {
128 let mut headers = HeaderMap::new();
129 headers.insert("content-type", "application/json".parse().unwrap());
130 let response = HttpResponse::new(StatusCode::OK, headers, "{}".as_bytes().to_vec());
131
132 assert_eq!(response.status(), StatusCode::OK);
133 assert_eq!(
134 response.headers().get("content-type").unwrap(),
135 "application/json"
136 );
137 assert_eq!(response.body(), b"{}");
138 }
139}