Skip to main content

postrust_response/
headers.rs

1//! Response header building.
2
3use http::{HeaderMap, HeaderValue};
4use postrust_core::ApiRequest;
5use std::fmt;
6
7/// Content-Range header value.
8///
9/// Rendered exactly as PostgREST's `contentRangeH`: no unit prefix, `*` for an
10/// unknown total, and `*` in place of the range itself when the response is
11/// empty. See `RangeQuery.hs` in the PostgREST source.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ContentRange {
14    /// Start of range (0-based)
15    pub start: i64,
16    /// End of range (inclusive)
17    pub end: i64,
18    /// Total count (or None if unknown)
19    pub total: Option<i64>,
20}
21
22impl ContentRange {
23    /// Create a new content range.
24    pub fn new(start: i64, end: i64, total: Option<i64>) -> Self {
25        Self { start, end, total }
26    }
27
28    /// Build the range for a response that returned `count` rows starting at
29    /// `offset`.
30    ///
31    /// `count` is what the query actually returned, so any limit has already
32    /// been applied; an empty result yields `end < start`, which renders as
33    /// `*` rather than a zero-length range.
34    pub fn from_pagination(offset: i64, count: i64, total: Option<i64>) -> Self {
35        Self::new(offset, offset + count - 1, total)
36    }
37
38    /// The HTTP status this range implies, following PostgREST's `rangeStatus`.
39    ///
40    /// Without a total there is nothing to be partial about, so the answer is
41    /// always 200.
42    pub fn status(&self) -> http::StatusCode {
43        match self.total {
44            None => http::StatusCode::OK,
45            Some(total) => {
46                if self.start > total {
47                    http::StatusCode::RANGE_NOT_SATISFIABLE
48                } else if (1 + self.end - self.start) < total {
49                    http::StatusCode::PARTIAL_CONTENT
50                } else {
51                    http::StatusCode::OK
52                }
53            }
54        }
55    }
56}
57
58impl fmt::Display for ContentRange {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        // An empty result, or a known-zero total, reports `*` for the range.
61        if self.total == Some(0) || self.start > self.end {
62            write!(f, "*")?;
63        } else {
64            write!(f, "{}-{}", self.start, self.end)?;
65        }
66        match self.total {
67            Some(total) => write!(f, "/{total}"),
68            None => write!(f, "/*"),
69        }
70    }
71}
72
73/// Build response headers based on request and result.
74///
75/// # Deprecated
76///
77/// Nothing in this workspace calls this. The headers a response actually
78/// carries are built by `add_common_headers`, and the two have already drifted:
79/// `Allow` is sent for an OPTIONS by that path and not by this one, and the
80/// `Content-Range` a rendered media type reports is likewise decided there.
81/// Keeping a second implementation means every header fix has to be made
82/// twice, and the one that is missed is the one nobody is calling.
83///
84/// Deprecated rather than removed because this crate is published to
85/// crates.io, so the name is public API somebody outside this repository may
86/// be using. It will be removed in a future breaking release.
87#[deprecated(
88    since = "0.4.0",
89    note = "unused within the workspace and drifted from the live header path; \
90            responses are built by postrust_response::format_response"
91)]
92pub fn build_response_headers(
93    request: &ApiRequest,
94    content_type: &str,
95    content_range: Option<&ContentRange>,
96    location: Option<&str>,
97) -> HeaderMap {
98    let mut headers = HeaderMap::new();
99
100    // Content-Type
101    if let Ok(v) = HeaderValue::from_str(content_type) {
102        headers.insert(http::header::CONTENT_TYPE, v);
103    }
104
105    // Content-Range
106    if let Some(range) = content_range {
107        if let Ok(v) = HeaderValue::from_str(&range.to_string()) {
108            headers.insert(http::header::CONTENT_RANGE, v);
109        }
110    }
111
112    // Location
113    if let Some(loc) = location {
114        if let Ok(v) = HeaderValue::from_str(loc) {
115            headers.insert(http::header::LOCATION, v);
116        }
117    }
118
119    // Content-Profile
120    if request.negotiated_by_profile {
121        if let Ok(v) = HeaderValue::from_str(&request.schema) {
122            headers.insert(http::header::HeaderName::from_static("content-profile"), v);
123        }
124    }
125
126    // Preference-Applied
127    if let Some(applied) = postrust_core::api_request::preferences::preference_applied(
128        &request.preferences,
129        crate::preference_scope(request),
130    ) {
131        if let Ok(v) = HeaderValue::from_str(&applied) {
132            headers.insert(
133                http::header::HeaderName::from_static("preference-applied"),
134                v,
135            );
136        }
137    }
138
139    headers
140}
141
142/// Parse the `response.headers` setting a function may have set.
143///
144/// PostgREST's shape: a JSON array of objects, each with exactly one key and
145/// a string value. An array rather than one object because a response may
146/// carry the same header twice -- two `Set-Cookie`s, say -- which an object
147/// cannot express.
148///
149/// `Err` when it is anything else, which is a fault in the function rather
150/// than in the request and is reported as one.
151pub fn parse_guc_headers(guc_headers: &str) -> Option<Vec<(String, String)>> {
152    let Ok(serde_json::Value::Array(entries)) = serde_json::from_str(guc_headers) else {
153        return None;
154    };
155
156    let mut headers = Vec::with_capacity(entries.len());
157    for entry in entries {
158        let serde_json::Value::Object(fields) = entry else {
159            return None;
160        };
161        if fields.len() != 1 {
162            return None;
163        }
164        for (name, value) in fields {
165            let serde_json::Value::String(value) = value else {
166                return None;
167            };
168            headers.push((name, value));
169        }
170    }
171
172    Some(headers)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_content_range_display() {
181        // No unit prefix, and `*` stands in for an unknown total.
182        assert_eq!(ContentRange::new(0, 9, Some(100)).to_string(), "0-9/100");
183        assert_eq!(ContentRange::new(10, 19, None).to_string(), "10-19/*");
184    }
185
186    #[test]
187    fn test_content_range_display_empty() {
188        // An empty result reports `*` for the range, not a zero-length one.
189        assert_eq!(ContentRange::from_pagination(0, 0, None).to_string(), "*/*");
190        assert_eq!(
191            ContentRange::from_pagination(0, 0, Some(0)).to_string(),
192            "*/0"
193        );
194    }
195
196    #[test]
197    fn test_content_range_from_pagination() {
198        // A full page: `count` is what came back, so the limit is already applied.
199        let range = ContentRange::from_pagination(0, 10, Some(100));
200        assert_eq!((range.start, range.end), (0, 9));
201
202        // Partial last page.
203        let range = ContentRange::from_pagination(90, 5, Some(95));
204        assert_eq!((range.start, range.end), (90, 94));
205    }
206
207    #[test]
208    fn test_content_range_status() {
209        // Without a total there is nothing to be partial about.
210        assert_eq!(
211            ContentRange::from_pagination(0, 10, None).status(),
212            http::StatusCode::OK
213        );
214        // A window narrower than the total is partial content.
215        assert_eq!(
216            ContentRange::from_pagination(0, 10, Some(100)).status(),
217            http::StatusCode::PARTIAL_CONTENT
218        );
219        // The whole set is a plain 200.
220        assert_eq!(
221            ContentRange::from_pagination(0, 15, Some(15)).status(),
222            http::StatusCode::OK
223        );
224        // Asking past the end is not satisfiable.
225        assert_eq!(
226            ContentRange::from_pagination(50, 0, Some(15)).status(),
227            http::StatusCode::RANGE_NOT_SATISFIABLE
228        );
229    }
230
231    #[test]
232    fn test_parse_guc_headers() {
233        let guc = r#"[{"X-Custom-Header": "value1"}, {"X-Another": "value2"}]"#;
234        let headers = parse_guc_headers(guc).unwrap();
235
236        assert_eq!(headers.len(), 2);
237        assert_eq!(headers[0], ("X-Custom-Header".into(), "value1".into()));
238        assert_eq!(headers[1], ("X-Another".into(), "value2".into()));
239    }
240
241    /// The same header twice is the reason it is an array.
242    #[test]
243    fn guc_headers_may_repeat_a_name() {
244        let guc = r#"[{"Set-Cookie": "a=1"}, {"Set-Cookie": "b=2"}]"#;
245        let headers = parse_guc_headers(guc).unwrap();
246
247        assert_eq!(headers.len(), 2);
248        assert!(headers.iter().all(|(name, _)| name == "Set-Cookie"));
249    }
250
251    /// Anything else is a fault in the function that set it.
252    #[test]
253    fn guc_headers_must_be_single_key_objects_of_strings() {
254        for bad in [
255            r#"{"X": "y"}"#,
256            r#"[{"X": "y", "Z": "w"}]"#,
257            r#"[{"X": 1}]"#,
258            r#"["X: y"]"#,
259            "not json",
260        ] {
261            assert!(parse_guc_headers(bad).is_none(), "accepted {bad}");
262        }
263    }
264}