glean_core/upload/
request.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Ping request representation.
6
7use std::collections::HashMap;
8
9use chrono::prelude::{DateTime, Utc};
10use flate2::{read::GzDecoder, write::GzEncoder, Compression};
11use serde_json::Value as JsonValue;
12use std::io::prelude::*;
13
14use crate::error::{ErrorKind, Result};
15use crate::system;
16
17/// A representation for request headers.
18pub type HeaderMap = HashMap<String, String>;
19
20/// Creates a formatted date string that can be used with Date headers.
21pub(crate) fn create_date_header_value(current_time: DateTime<Utc>) -> String {
22    // Date headers are required to be in the following format:
23    //
24    // <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
25    //
26    // as documented here:
27    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date
28    // Unfortunately we can't use `current_time.to_rfc2822()` as it
29    // formats as "Mon, 22 Jun 2020 10:40:34 +0000", with an ending
30    // "+0000" instead of "GMT". That's why we need to go with manual
31    // formatting.
32    current_time.format("%a, %d %b %Y %T GMT").to_string()
33}
34
35fn create_x_telemetry_agent_header_value(
36    version: &str,
37    language_binding_name: &str,
38    system: &str,
39) -> String {
40    format!(
41        "Glean/{} ({} on {})",
42        version, language_binding_name, system
43    )
44}
45
46/// Attempt to gzip the contents of a ping.
47fn gzip_content(path: &str, content: &[u8]) -> Option<Vec<u8>> {
48    let mut gzipper = GzEncoder::new(Vec::new(), Compression::default());
49
50    // Attempt to add the content to the gzipper.
51    if let Err(e) = gzipper.write_all(content) {
52        log::warn!("Failed to write to the gzipper: {} - {:?}", path, e);
53        return None;
54    }
55
56    gzipper.finish().ok()
57}
58
59pub struct Builder {
60    document_id: Option<String>,
61    path: Option<String>,
62    body: Option<Vec<u8>>,
63    headers: HeaderMap,
64    body_max_size: usize,
65    body_has_info_sections: Option<bool>,
66    ping_name: Option<String>,
67    uploader_capabilities: Option<Vec<String>>,
68}
69
70impl Builder {
71    /// Creates a new builder for a PingRequest.
72    pub fn new(language_binding_name: &str, body_max_size: usize) -> Self {
73        let mut headers = HashMap::new();
74        headers.insert(
75            "X-Telemetry-Agent".to_string(),
76            create_x_telemetry_agent_header_value(
77                crate::GLEAN_VERSION,
78                language_binding_name,
79                system::OS,
80            ),
81        );
82        headers.insert(
83            "Content-Type".to_string(),
84            "application/json; charset=utf-8".to_string(),
85        );
86
87        Self {
88            document_id: None,
89            path: None,
90            body: None,
91            headers,
92            body_max_size,
93            body_has_info_sections: None,
94            ping_name: None,
95            uploader_capabilities: None,
96        }
97    }
98
99    /// Sets the document_id for this request.
100    pub fn document_id<S: Into<String>>(mut self, value: S) -> Self {
101        self.document_id = Some(value.into());
102        self
103    }
104
105    /// Sets the path for this request.
106    pub fn path<S: Into<String>>(mut self, value: S) -> Self {
107        self.path = Some(value.into());
108        self
109    }
110
111    /// Sets the body for this request.
112    ///
113    /// This method will also attempt to gzip the body contents
114    /// and add headers related to the body that was just added.
115    ///
116    /// Namely these headers are the "Content-Length" with the length of the body
117    /// and in case we are successfull on gzipping the contents, the "Content-Encoding"="gzip".
118    ///
119    /// **Important**
120    /// If we are unable to gzip we don't panic and instead just set the uncompressed body.
121    ///
122    /// # Panics
123    ///
124    /// This method will panic in case we try to set the body before setting the path.
125    pub fn body<S: Into<String>>(mut self, value: S) -> Self {
126        // Attempt to gzip the body contents.
127        let original_as_string = value.into();
128        let gzipped_content = gzip_content(
129            self.path
130                .as_ref()
131                .expect("Path must be set before attempting to set the body"),
132            original_as_string.as_bytes(),
133        );
134        let add_gzip_header = gzipped_content.is_some();
135        let body = gzipped_content.unwrap_or_else(|| original_as_string.into_bytes());
136
137        // Include headers related to body
138        self = self.header("Content-Length", &body.len().to_string());
139        if add_gzip_header {
140            self = self.header("Content-Encoding", "gzip");
141        }
142
143        self.body = Some(body);
144        self
145    }
146
147    /// Sets whether the request body has {client|ping}_info sections.
148    pub fn body_has_info_sections(mut self, body_has_info_sections: bool) -> Self {
149        self.body_has_info_sections = Some(body_has_info_sections);
150        self
151    }
152
153    /// Sets the ping's name aka doctype.
154    pub fn ping_name<S: Into<String>>(mut self, ping_name: S) -> Self {
155        self.ping_name = Some(ping_name.into());
156        self
157    }
158
159    /// Sets a header for this request.
160    pub fn header<S: Into<String>>(mut self, key: S, value: S) -> Self {
161        self.headers.insert(key.into(), value.into());
162        self
163    }
164
165    /// Sets multiple headers for this request at once.
166    pub fn headers(mut self, values: HeaderMap) -> Self {
167        self.headers.extend(values);
168        self
169    }
170
171    /// Sets the required uploader capabilities.
172    pub fn uploader_capabilities(mut self, uploader_capabilities: Vec<String>) -> Self {
173        self.uploader_capabilities = Some(uploader_capabilities);
174        self
175    }
176
177    /// Consumes the builder and create a PingRequest.
178    ///
179    /// # Panics
180    ///
181    /// This method will panic if any of the required fields are missing:
182    /// `document_id`, `path` and `body`.
183    pub fn build(self) -> Result<PingRequest> {
184        let body = self
185            .body
186            .expect("body must be set before attempting to build PingRequest");
187
188        if body.len() > self.body_max_size {
189            return Err(ErrorKind::PingBodyOverflow(body.len()).into());
190        }
191
192        Ok(PingRequest {
193            document_id: self
194                .document_id
195                .expect("document_id must be set before attempting to build PingRequest"),
196            path: self
197                .path
198                .expect("path must be set before attempting to build PingRequest"),
199            body,
200            headers: self.headers,
201            body_has_info_sections: self.body_has_info_sections.expect(
202                "body_has_info_sections must be set before attempting to build PingRequest",
203            ),
204            ping_name: self
205                .ping_name
206                .expect("ping_name must be set before attempting to build PingRequest"),
207            uploader_capabilities: self
208                .uploader_capabilities
209                .expect("uploader_capabilities must be set before attempting to build PingRequest"),
210        })
211    }
212}
213
214/// Represents a request to upload a ping.
215#[derive(PartialEq, Eq, Debug, Clone)]
216pub struct PingRequest {
217    /// The Job ID to identify this request,
218    /// this is the same as the ping UUID.
219    pub document_id: String,
220    /// The path for the server to upload the ping to.
221    pub path: String,
222    /// The body of the request, as a byte array. If gzip encoded, then
223    /// the `headers` list will contain a `Content-Encoding` header with
224    /// the value `gzip`.
225    pub body: Vec<u8>,
226    /// A map with all the headers to be sent with the request.
227    pub headers: HeaderMap,
228    /// Whether the body has {client|ping}_info sections.
229    pub body_has_info_sections: bool,
230    /// The ping's name. Likely also somewhere in `path`.
231    pub ping_name: String,
232    /// The capabilities required during this ping's upload.
233    pub uploader_capabilities: Vec<String>,
234}
235
236impl PingRequest {
237    /// Creates a new builder-style structure to help build a PingRequest.
238    ///
239    /// # Arguments
240    ///
241    /// * `language_binding_name` - The name of the language used by the binding that instantiated this Glean instance.
242    ///                             This is used to build the X-Telemetry-Agent header value.
243    /// * `body_max_size` - The maximum size in bytes the compressed ping body may have to be eligible for upload.
244    pub fn builder(language_binding_name: &str, body_max_size: usize) -> Builder {
245        Builder::new(language_binding_name, body_max_size)
246    }
247
248    /// Verifies if current request is for a deletion-request ping.
249    pub fn is_deletion_request(&self) -> bool {
250        self.ping_name == "deletion-request"
251    }
252
253    /// Decompresses and pretty-format the ping payload
254    ///
255    /// Should be used for logging when required.
256    /// This decompresses the payload in memory.
257    pub fn pretty_body(&self) -> Option<String> {
258        let mut gz = GzDecoder::new(&self.body[..]);
259        let mut s = String::with_capacity(self.body.len());
260
261        gz.read_to_string(&mut s)
262            .ok()
263            .map(|_| &s[..])
264            .or_else(|| std::str::from_utf8(&self.body).ok())
265            .and_then(|payload| serde_json::from_str::<JsonValue>(payload).ok())
266            .and_then(|json| serde_json::to_string_pretty(&json).ok())
267    }
268}
269
270#[cfg(test)]
271mod test {
272    use super::*;
273    use chrono::offset::TimeZone;
274
275    #[test]
276    fn date_header_resolution() {
277        let date: DateTime<Utc> = Utc.with_ymd_and_hms(2018, 2, 25, 11, 10, 37).unwrap();
278        let test_value = create_date_header_value(date);
279        assert_eq!("Sun, 25 Feb 2018 11:10:37 GMT", test_value);
280    }
281
282    #[test]
283    fn x_telemetry_agent_header_resolution() {
284        let test_value = create_x_telemetry_agent_header_value("0.0.0", "Rust", "Windows");
285        assert_eq!("Glean/0.0.0 (Rust on Windows)", test_value);
286    }
287
288    #[test]
289    fn correctly_builds_ping_request() {
290        let request = PingRequest::builder(/* language_binding_name */ "Rust", 1024 * 1024)
291            .document_id("woop")
292            .path("/random/path/doesnt/matter")
293            .body("{}")
294            .body_has_info_sections(false)
295            .ping_name("whatevs")
296            .uploader_capabilities(vec![])
297            .build()
298            .unwrap();
299
300        assert_eq!(request.document_id, "woop");
301        assert_eq!(request.path, "/random/path/doesnt/matter");
302        assert!(!request.body_has_info_sections);
303        assert_eq!(request.ping_name, "whatevs");
304
305        // Make sure all the expected headers were added.
306        assert!(request.headers.contains_key("X-Telemetry-Agent"));
307        assert!(request.headers.contains_key("Content-Type"));
308        assert!(request.headers.contains_key("Content-Length"));
309
310        // the `Date` header is added by the `get_upload_task` just before
311        // returning the upload request
312    }
313
314    #[test]
315    fn errors_when_request_body_exceeds_max_size() {
316        // Create a new builder with an arbitrarily small value,
317        // se we can test that the builder errors when body max size exceeds the expected.
318        let request = Builder::new(
319            /* language_binding_name */ "Rust", /* body_max_size */ 1,
320        )
321        .document_id("woop")
322        .path("/random/path/doesnt/matter")
323        .body("{}")
324        .build();
325
326        assert!(request.is_err());
327    }
328}