Skip to main content

rama_http/layer/har/spec/
mod.rs

1// NOTE: spec can be found in ../spec.md
2
3use std::fmt::Debug;
4use std::net::IpAddr;
5use std::str::FromStr;
6
7use crate::layer::har::extensions::RequestComment;
8use crate::layer::remove_header::{
9    remove_sensitive_request_headers, remove_sensitive_response_headers,
10};
11use crate::proto::HeaderByteLength;
12use crate::request::Parts as ReqParts;
13use crate::response::Parts as RespParts;
14use crate::service::web::extract::Query;
15
16use rama_core::error::{BoxError, ErrorContext};
17use rama_core::extensions::ExtensionsRef;
18use rama_core::telemetry::tracing;
19use rama_http_headers::{ContentType, Cookie as RamaCookie, HeaderMapExt, Location};
20use rama_http_headers::{HeaderEncode, SetCookie};
21use rama_http_types::mime::Mime;
22use rama_http_types::proto::h1::ext::ReasonPhrase;
23use rama_http_types::{HeaderMap, HeaderName, Version as RamaHttpVersion};
24
25use base64::Engine;
26use base64::engine::general_purpose::STANDARD as ENGINE;
27use jiff::Timestamp;
28use rama_utils::str::arcstr::ArcStr;
29use rama_utils::str::smol_str::{ToSmolStr, format_smolstr};
30use rama_utils::str::{NonEmptyStr, non_empty_str};
31use serde::{Deserialize, Serialize};
32
33mod websocket;
34pub use websocket::{WebSocketMessage, WebSocketMessageOpcode, WebSocketMessageType};
35
36mod mime_serde {
37    use rama_http_types::mime::Mime;
38    use serde::{Deserialize, Deserializer, Serializer, de::Error};
39    use std::{borrow::Cow, str::FromStr};
40
41    #[expect(clippy::ref_option)]
42    pub(super) fn serialize<S>(mime: &Option<Mime>, serializer: S) -> Result<S::Ok, S::Error>
43    where
44        S: Serializer,
45    {
46        if let Some(mime) = mime {
47            serializer.serialize_str(mime.as_ref())
48        } else {
49            serializer.serialize_none()
50        }
51    }
52
53    pub(super) fn deserialize<'de, D>(d: D) -> Result<Option<Mime>, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        let opt = <Option<Cow<'de, str>>>::deserialize(d)?;
58        if let Some(s) = opt {
59            Mime::from_str(&s).map_err(Error::custom).map(Some)
60        } else {
61            Ok(None)
62        }
63    }
64}
65
66rama_utils::macros::enums::enum_builder! {
67    @String
68    pub enum HttpVersion {
69        Http09 => "HTTP/0.9" | "0.9",
70        Http10 => "HTTP/1.0" | "1.0" | "HTTP/1",
71        Http11 => "HTTP/1.1" | "1.1",
72        Http2 => "HTTP/2" | "2" | "h2",
73        Http3 => "HTTP/3" | "3" | "h3",
74    }
75}
76
77impl From<RamaHttpVersion> for HttpVersion {
78    fn from(rhv: RamaHttpVersion) -> Self {
79        match rhv {
80            RamaHttpVersion::HTTP_09 => Self::Http09,
81            RamaHttpVersion::HTTP_10 => Self::Http10,
82            RamaHttpVersion::HTTP_11 => Self::Http11,
83            RamaHttpVersion::HTTP_2 => Self::Http2,
84            RamaHttpVersion::HTTP_3 => Self::Http3,
85        }
86    }
87}
88
89impl TryFrom<HttpVersion> for RamaHttpVersion {
90    type Error = HttpVersion;
91
92    fn try_from(rhv: HttpVersion) -> Result<Self, Self::Error> {
93        match rhv {
94            HttpVersion::Http09 => Ok(Self::HTTP_09),
95            HttpVersion::Http10 => Ok(Self::HTTP_10),
96            HttpVersion::Http11 => Ok(Self::HTTP_11),
97            HttpVersion::Http2 => Ok(Self::HTTP_2),
98            HttpVersion::Http3 => Ok(Self::HTTP_3),
99            v @ HttpVersion::Unknown(_) => Err(v),
100        }
101    }
102}
103
104fn into_query_string(parts: &ReqParts) -> Vec<QueryStringPair> {
105    let query = parts.uri.query_or_empty();
106    match Query::<Vec<(ArcStr, ArcStr)>>::parse_query_str(query.as_ref()) {
107        Ok(Query(v)) => v
108            .into_iter()
109            .map(|(name, value)| QueryStringPair {
110                name,
111                value,
112                comment: None,
113            })
114            .collect(),
115        Err(err) => {
116            tracing::debug!("failure to parse query string: {err:?}");
117            vec![]
118        }
119    }
120}
121
122pub(super) fn get_mime(headers: &HeaderMap) -> Option<Mime> {
123    headers.typed_get::<ContentType>().map(|ct| ct.into_mime())
124}
125
126fn parse_cookie_part(part: &str) -> Option<Cookie> {
127    let trimmed = part.trim();
128    if trimmed.is_empty() {
129        return None;
130    }
131    let mut split = trimmed.splitn(2, '=');
132    let name = split.next()?.trim().into();
133    let value = split.next()?.trim().into();
134    Some(Cookie {
135        name,
136        value,
137        ..Default::default()
138    })
139}
140
141fn into_har_headers(header_map: HeaderMap) -> Vec<Header> {
142    header_map
143        .into_ordered_iter()
144        .map(|(name, value)| Header {
145            name: name.as_original_str().into_owned().into(),
146            value: match value.to_str() {
147                Ok(s) => s.into(),
148                Err(_) => format_smolstr!("{value:x?}").into(),
149            },
150            comment: None,
151        })
152        .collect()
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156/// This object represents the exported data structure.
157pub struct LogFile {
158    /// The HAR log data.
159    pub log: Log,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163/// This object represents the root of exported data.
164pub struct Log {
165    /// Version number of the format. If empty, string "1.1" is assumed by default.
166    pub version: NonEmptyStr,
167    /// Name and version info of the log creator application.
168    pub creator: Creator,
169    /// Name and version info of used browser.
170    pub browser: Option<Browser>,
171    /// List of all exported (tracked) pages.
172    ///
173    /// Leave out this field if the application does not support grouping by pages.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub pages: Option<Vec<Page>>,
176    /// List of all exported (tracked) requests.
177    pub entries: Vec<Entry>,
178    /// A comment provided by the user or the application.
179    pub comment: Option<ArcStr>,
180}
181
182impl Default for Log {
183    fn default() -> Self {
184        Self {
185            version: non_empty_str!("1.2"),
186            creator: Creator {
187                name: rama_utils::info::NAME.into(),
188                version: rama_utils::info::VERSION.into(),
189                comment: None,
190            },
191            browser: None,
192            pages: None,
193            entries: vec![],
194            comment: None,
195        }
196    }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200/// Creator and browser objects share the same structure.
201pub struct Creator {
202    pub name: ArcStr,
203    pub version: ArcStr,
204    pub comment: Option<ArcStr>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Browser {
209    /// Name of the application/browser used to export the log.
210    pub name: ArcStr,
211    /// Version of the application/browser used to export the log.
212    pub version: Option<ArcStr>,
213    /// A comment provided by the user or the application.
214    pub comment: Option<ArcStr>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct Page {
219    /// Date and time stamp of the request start (ISO 8601 - YYYY-MM-DDThh:mm:ss.sTZD)
220    #[serde(rename = "startedDateTime")]
221    pub started_date_time: Timestamp,
222    /// Unique identifier of a page within the [Log]. Entries use it to refer the parent page.
223    pub id: ArcStr,
224    /// Page title
225    pub title: ArcStr,
226    /// Detailed timing info about page load.
227    #[serde(rename = "pageTimings")]
228    pub page_timings: PageTimings,
229    /// A comment provided by the user or the application.
230    pub comment: Option<ArcStr>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234/// This object describes timings for various events (states) fired during the page load.
235///
236/// All times are specified in milliseconds.
237/// If a time info is not available appropriate field is set to -1.
238pub struct PageTimings {
239    /// Content of the page loaded.
240    ///
241    /// Number of milliseconds since page load started (page.startedDateTime).
242    /// Use -1 if the timing does not apply to the current request.
243    #[serde(rename = "onContentLoad")]
244    pub on_content_load: Option<i64>,
245    /// Page is loaded (onLoad event fired).
246    ///
247    /// Number of milliseconds since page load started (page.startedDateTime).
248    /// Use -1 if the timing does not apply to the current request.
249    #[serde(rename = "onLoad")]
250    pub on_load: Option<i64>,
251    /// A comment provided by the user or the application.
252    pub comment: Option<ArcStr>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256/// This object represents a single exportred request with its response and metadata.
257pub struct Entry {
258    /// Reference to the parent page.
259    ///
260    /// Leave out this field if the application does not support grouping by pages.
261    #[serde(rename = "pageref")]
262    pub page_ref: Option<ArcStr>,
263    /// Date and time stamp of the request start (ISO 8601 - YYYY-MM-DDThh:mm:ss.sTZD)
264    #[serde(rename = "startedDateTime")]
265    pub started_date_time: Timestamp,
266    /// Total elapsed time of the request in milliseconds.
267    ///
268    /// This is the sum of all timings available in the timings object (i.e. not including -1 values).
269    pub time: i64,
270    /// Detailed info about the request.
271    pub request: Request,
272    /// Detailed info about the response.
273    pub response: Response,
274    /// Info about cache usage.
275    pub cache: Cache,
276    /// Detailed timing info about request/response round trip.
277    pub timings: Timings,
278    /// IP address of the server that was connected
279    ///
280    /// (result of DNS resolution).
281    #[serde(rename = "serverIPAddress")]
282    pub server_ip_address: Option<IpAddr>, // TODO: be able to provide for client middleware
283    /// Unique ID of the parent TCP/IP connection,
284    /// can be the client or server port number.
285    ///
286    /// Note that a port number doesn't have to be unique identifier
287    /// in cases where the port is shared for more connections.
288    /// If the port isn't available for the application,
289    /// any other unique connection ID can be used instead (e.g. connection index).
290    ///
291    /// Leave out this field if the application doesn't support this info.
292    pub connection: Option<ArcStr>,
293    /// A comment provided by the user or the application.
294    pub comment: Option<ArcStr>,
295    /// Chromium's custom resource classification for this entry.
296    ///
297    /// Chrome DevTools uses `_resourceType: "websocket"` to classify imported
298    /// entries as WebSockets and expose their message view. Use `None` when no
299    /// Chromium-specific classification is available.
300    #[serde(rename = "_resourceType", skip_serializing_if = "Option::is_none")]
301    pub resource_type: Option<ArcStr>,
302    /// WebSocket messages associated with this entry.
303    ///
304    /// A custom field, in the sense HAR 1.2 defines one: the spec reserves a leading
305    /// underscore for extensions and requires parsers to ignore what they do not know.
306    /// The shape follows Chrome DevTools, which has exported `_webSocketMessages`
307    /// since Chrome 76.
308    ///
309    /// Chrome emits `Some(Vec::new())` for a WebSocket request with no captured
310    /// messages. Use `None` for non-WebSocket entries so the custom key is omitted.
311    #[serde(rename = "_webSocketMessages", skip_serializing_if = "Option::is_none")]
312    pub web_socket_messages: Option<Vec<WebSocketMessage>>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316/// This object contains detailed info about performed request.
317pub struct Request {
318    /// Request method (GET, POST, ...).
319    pub method: ArcStr,
320    /// Absolute URL of the request (fragments are not included).
321    pub url: ArcStr,
322    /// Request HTTP Version.
323    #[serde(rename = "httpVersion")]
324    pub http_version: HttpVersion,
325    /// List of cookie objects.
326    pub cookies: Vec<Cookie>,
327    /// List of header objects.
328    pub headers: Vec<Header>,
329    /// List of query parameter objects.
330    #[serde(rename = "queryString")]
331    pub query_string: Vec<QueryStringPair>,
332    /// Posted data info.
333    #[serde(rename = "postData")]
334    pub post_data: Option<PostData>,
335    /// Total number of bytes from the start of the HTTP request message
336    ///
337    /// Until (and including) the double CRLF before the body.
338    ///
339    /// Set to -1 if the info is not available.
340    #[serde(rename = "headersSize")]
341    pub headers_size: i64,
342    /// Size of the request body (POST data payload) in bytes.
343    ///
344    /// Set to -1 if the info is not available.
345    #[serde(rename = "bodySize")]
346    pub body_size: i64,
347    /// A comment provided by the user or the application.
348    pub comment: Option<ArcStr>,
349}
350
351impl TryFrom<Request> for crate::Request {
352    type Error = BoxError;
353
354    fn try_from(har_request: Request) -> Result<Self, Self::Error> {
355        let body = if let Some(text) = har_request.post_data.and_then(|pd| pd.text) {
356            if let Ok(bin) = ENGINE.decode(&text) {
357                crate::Body::from(bin)
358            } else {
359                crate::Body::from(text)
360            }
361        } else {
362            crate::Body::empty()
363        };
364
365        let mut headers = HeaderMap::with_capacity(har_request.headers.len());
366        for header in har_request.headers {
367            headers.append(
368                HeaderName::from_str(&header.name).context("convert http header name")?,
369                crate::HeaderValue::from_maybe_shared(header.value)
370                    .context("convert http header value")?,
371            );
372        }
373
374        let builder = crate::Request::builder()
375            .method(
376                har_request
377                    .method
378                    .parse::<crate::Method>()
379                    .context("parse HAR HTTP Method")?,
380            )
381            .uri(har_request.url.as_str());
382
383        let builder = if let Ok(ver) = har_request.http_version.try_into() {
384            builder.version(ver)
385        } else {
386            builder
387        };
388
389        let mut req = builder
390            .body(body)
391            .context("build http request from HAR data")?;
392
393        *req.headers_mut() = headers;
394
395        if let Some(comment) = har_request.comment {
396            req.extensions().insert(RequestComment(comment));
397        }
398
399        Ok(req)
400    }
401}
402
403impl Request {
404    pub fn from_http_request_parts(
405        parts: &ReqParts,
406        payload: &[u8],
407        preserve_sensitive: bool,
408    ) -> Result<Self, BoxError> {
409        let post_data = if !payload.is_empty() {
410            let mime_type = get_mime(&parts.headers);
411            let params = if mime_type
412                .as_ref()
413                .map(|m| m.subtype() == crate::mime::WWW_FORM_URLENCODED)
414                .unwrap_or_default()
415            {
416                Some(serde_html_form::from_bytes(payload).context("decode form body payload")?)
417            } else {
418                None
419            };
420
421            let text = match std::str::from_utf8(payload) {
422                Ok(s) => s.into(),
423                Err(_) => ENGINE.encode(payload).into(),
424            };
425
426            Some(PostData {
427                mime_type,
428                params,
429                text: Some(text),
430                comment: None,
431            })
432        } else {
433            None
434        };
435
436        let comment = parts
437            .extensions
438            .get_ref::<RequestComment>()
439            .map(|req_comment| req_comment.0.clone());
440
441        let cookies = parts
442            .headers
443            .typed_get::<RamaCookie>()
444            .map(|c| {
445                c.iter()
446                    .map(|(k, v)| Cookie {
447                        name: k.into(),
448                        value: v.into(),
449                        path: None,
450                        domain: None,
451                        expires: None,
452                        http_only: None,
453                        secure: None,
454                        comment: None,
455                    })
456                    .collect()
457            })
458            .unwrap_or_default();
459
460        let query_string = into_query_string(parts);
461        let mut headers = parts.headers.clone();
462        if !preserve_sensitive {
463            remove_sensitive_request_headers(&mut headers);
464        }
465
466        let headers_size_ext = parts.extensions.get_ref::<HeaderByteLength>();
467        let headers_size = headers_size_ext.map(|v| v.0 as i64).unwrap_or(-1);
468
469        Ok(Self {
470            method: parts.method.to_smolstr().into(),
471            url: parts.uri.to_string().into(),
472            http_version: parts.version.into(),
473            cookies,
474            headers: into_har_headers(headers),
475            query_string,
476            post_data,
477            headers_size,
478            body_size: payload.len() as i64,
479            comment,
480        })
481    }
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
485/// This object contains detailed info about the response.
486pub struct Response {
487    /// Response status.
488    pub status: u16,
489    /// Response status description.
490    #[serde(rename = "statusText")]
491    pub status_text: Option<ArcStr>,
492    /// Response HTTP Version.
493    #[serde(rename = "httpVersion")]
494    pub http_version: HttpVersion,
495    /// List of cookie objects.
496    pub cookies: Vec<Cookie>,
497    /// List of header objects.
498    pub headers: Vec<Header>,
499    /// Details about the response body.
500    pub content: Content,
501    /// Redirection target URL from the Location response header.
502    #[serde(rename = "redirectURL")]
503    pub redirect_url: Option<ArcStr>,
504    /// Total number of bytes from the start of the HTTP response message
505    ///
506    /// Until (and including) the double CRLF before the body.
507    ///
508    /// Set to -1 if the info is not available.
509    #[serde(rename = "headersSize")]
510    pub headers_size: i64,
511    /// Size of the received response body in bytes.
512    ///
513    /// Set to zero in case of responses coming from the cache (304). Set to -1 if the info is not available.
514    ///
515    /// The size of received response-headers is computed only from headers
516    /// that are really received from the server. Additional headers appended
517    /// by the browser are not included in this number,
518    /// but they appear in the list of header objects.
519    #[serde(rename = "bodySize")]
520    pub body_size: i64,
521    /// A comment provided by the user or the application.
522    pub comment: Option<ArcStr>,
523}
524
525impl TryFrom<Response> for crate::Response {
526    type Error = BoxError;
527
528    fn try_from(har_response: Response) -> Result<Self, Self::Error> {
529        let body = match har_response.content.text {
530            Some(s) if har_response.content.encoding.as_deref() == Some("base64") => {
531                crate::Body::from(
532                    ENGINE
533                        .decode(&s)
534                        .context("decode base64 HAR response body")?,
535                )
536            }
537            Some(s) => crate::Body::from(s),
538            None => crate::Body::empty(),
539        };
540
541        let mut headers = HeaderMap::with_capacity(har_response.headers.len());
542        for header in har_response.headers {
543            headers.append(
544                HeaderName::from_str(&header.name).context("convert http header name")?,
545                crate::HeaderValue::from_maybe_shared(header.value)
546                    .context("convert http header value")?,
547            );
548        }
549
550        let builder = crate::Response::builder().status(
551            crate::StatusCode::from_u16(har_response.status).context("convert HAR status code")?,
552        );
553
554        let builder = if let Ok(ver) = har_response.http_version.try_into() {
555            builder.version(ver)
556        } else {
557            builder
558        };
559
560        let mut res = builder
561            .body(body)
562            .context("build http response from HAR data")?;
563
564        *res.headers_mut() = headers;
565
566        Ok(res)
567    }
568}
569
570impl Response {
571    pub fn from_http_response_parts(
572        parts: &RespParts,
573        payload: &[u8],
574        preserve_sensitive: bool,
575    ) -> Result<Self, BoxError> {
576        let (text, encoding) = if payload.is_empty() {
577            (None, None)
578        } else {
579            match std::str::from_utf8(payload) {
580                Ok(s) => (Some(s.into()), None),
581                Err(_) => (Some(ENGINE.encode(payload).into()), Some("base64".into())),
582            }
583        };
584        let content = Content {
585            size: payload.len() as i64,
586            compression: None,
587            mime_type: get_mime(&parts.headers),
588            text,
589            encoding,
590            comment: None,
591        };
592
593        let redirect_url = parts
594            .headers
595            .typed_get::<Location>()
596            .and_then(|h| h.encode_to_value())
597            .and_then(|v| v.to_str().ok().map(Into::into));
598
599        let cookies = parts
600            .headers
601            .typed_get::<SetCookie>()
602            .map(|sc| {
603                sc.iter_header_values()
604                    .filter_map(|v| {
605                        v.to_str().ok().and_then(|s| {
606                            let raw = s.split(';').next()?;
607                            parse_cookie_part(raw)
608                        })
609                    })
610                    .collect()
611            })
612            .unwrap_or_default();
613
614        let mut headers = parts.headers.clone();
615        if !preserve_sensitive {
616            remove_sensitive_response_headers(&mut headers);
617        }
618
619        let headers_size_ext = parts.extensions.get_ref::<HeaderByteLength>();
620        let headers_size = headers_size_ext.map(|v| v.0 as i64).unwrap_or(-1);
621
622        Ok(Self {
623            status: parts.status.as_u16(),
624            status_text: match parts.extensions.get_ref::<ReasonPhrase>() {
625                Some(reason) => Some(
626                    String::from_utf8_lossy(reason.as_bytes())
627                        .into_owned()
628                        .into(),
629                ),
630                None => parts.status.canonical_reason().map(Into::into),
631            },
632            http_version: parts.version.into(),
633            cookies,
634            headers: into_har_headers(headers),
635            content,
636            redirect_url,
637            headers_size,
638            body_size: payload.len() as i64,
639            comment: None,
640        })
641    }
642}
643
644// TODO: https://github.com/plabayo/rama/issues/44
645// For now this will have to be manually parsed. Needs an http-cookie logic
646
647#[derive(Debug, Clone, Serialize, Deserialize, Default)]
648/// This object contains list of all cookies
649///
650/// (used in [Request] and [Response] objects).
651pub struct Cookie {
652    /// The name of the cookie.
653    pub name: ArcStr,
654    /// The cookie value.
655    pub value: ArcStr,
656    /// The path pertaining to the cookie.
657    pub path: Option<ArcStr>,
658    /// The host of the cookie.
659    pub domain: Option<ArcStr>,
660    /// Date and time stamp of the request start
661    ///
662    /// (ISO 8601 - YYYY-MM-DDThh:mm:ss.sTZD)
663    pub expires: Option<Timestamp>,
664    /// Set to true if the cookie is HTTP only, false otherwise.
665    #[serde(rename = "httpOnly")]
666    pub http_only: Option<bool>,
667    /// True if the cookie was transmitted over ssl, false otherwise.
668    pub secure: Option<bool>,
669    /// A comment provided by the user or the application.
670    pub comment: Option<ArcStr>,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
674/// Single HTTP Header.
675pub struct Header {
676    /// Name of header.
677    pub name: ArcStr,
678    /// Value of header.
679    pub value: ArcStr,
680    /// A comment provided by the user or the application.
681    pub comment: Option<ArcStr>,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
685/// This object contains list of all parameters & values parsed from a query string,
686/// if any (embedded in [Request] object).
687pub struct QueryStringPair {
688    /// Name of parameter.
689    pub name: ArcStr,
690    /// Value of parameter.
691    pub value: ArcStr,
692    /// A comment provided by the user or the application.
693    pub comment: Option<ArcStr>,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697/// This object describes posted data,
698///
699/// if any (embedded in [Request] object).
700pub struct PostData {
701    #[serde(with = "mime_serde", rename = "mimeType")]
702    /// Mime type of posted data.
703    pub mime_type: Option<Mime>,
704    /// List of posted parameters
705    ///
706    /// (in case of URL encoded parameters).
707    pub params: Option<Vec<PostParam>>,
708    /// Plain text posted data
709    pub text: Option<ArcStr>,
710    /// A comment provided by the user or the application.
711    pub comment: Option<ArcStr>,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct PostParam {
716    pub name: ArcStr,
717    pub value: Option<ArcStr>,
718    #[serde(rename = "fileName")]
719    pub file_name: Option<ArcStr>,
720    #[serde(rename = "contentType")]
721    pub content_type: Option<ArcStr>,
722    pub comment: Option<ArcStr>,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
726/// This object describes details about response content
727///
728/// (embedded in `<response>` object).
729///
730/// Before setting the text field,
731/// the HTTP response is decoded (decompressed & unchunked),
732/// than trans-coded from its original character set into UTF-8. Additionally,
733/// it can be encoded using e.g. base64. Ideally,
734/// the application should be able to unencode a
735/// base64 blob and get a byte-for-byte identical resource to what the browser operated on.
736pub struct Content {
737    /// Length of the returned content in bytes.
738    ///
739    /// Should be equal to response.bodySize if there is no compression
740    /// and bigger when the content has been compressed.
741    pub size: i64, // TODO: support
742    /// Number of bytes saved.
743    ///
744    /// Leave out this field if the information is not available.
745    pub compression: Option<i64>, // TODO: support
746    #[serde(with = "mime_serde", rename = "mimeType")]
747    /// MIME type of the response text
748    ///
749    /// (value of the Content-Type response header).
750    ///
751    /// The charset attribute of the MIME type is included
752    /// (if available).
753    pub mime_type: Option<Mime>,
754    pub text: Option<ArcStr>,
755    /// Response body sent from the server or loaded from the browser cache.
756    ///
757    /// This field is populated with textual content only.
758    /// The text field is either HTTP decoded text or a encoded
759    /// (e.g. "base64") representation of the response body.
760    ///
761    /// Leave out this field if the information is not available.
762    pub encoding: Option<ArcStr>,
763    /// A comment provided by the user or the application.
764    pub comment: Option<ArcStr>,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize, Default)]
768/// This objects contains info about a request coming from browser cache.
769pub struct Cache {
770    /// State of a cache entry before the request.
771    ///
772    /// Leave out this field if the information is not available.
773    #[serde(rename = "beforeRequest")]
774    pub before_request: Option<CacheState>,
775    /// State of a cache entry after the request.
776    ///
777    /// Leave out this field if the information is not available.
778    #[serde(rename = "afterRequest")]
779    pub after_request: Option<CacheState>,
780    /// A comment provided by the user or the application.
781    pub comment: Option<ArcStr>,
782} // TODO: support this once we have cache support in rama, e.g. based on extension info
783
784#[derive(Debug, Clone, Serialize, Deserialize)]
785pub struct CacheState {
786    /// Date and time stamp of the request start
787    ///
788    /// (ISO 8601 - YYYY-MM-DDThh:mm:ss.sTZD)
789    /// Expiration time of the cache entry.
790    pub expires: Timestamp,
791    /// The last time the cache entry was opened.
792    #[serde(rename = "lastAccess")]
793    pub last_access: Option<ArcStr>,
794    /// Etag
795    #[serde(rename = "eTag")]
796    pub e_tag: Option<ArcStr>,
797    /// The number of times the cache entry has been opened.
798    #[serde(rename = "hitCount")]
799    pub hit_count: Option<i64>,
800    /// A comment provided by the user or the application.
801    pub comment: Option<ArcStr>,
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize, Default)]
805/// This object describes various phases within request-response round trip.
806///
807/// All times are specified in milliseconds.
808pub struct Timings {
809    /// Time spent in a queue waiting for a network connection.
810    ///
811    /// Use -1 if the timing does not apply to the current request.
812    pub blocked: Option<i64>, // TODO
813    /// DNS resolution time.
814    ///
815    /// The time required to resolve a host name.
816    ///
817    /// Use -1 if the timing does not apply to the current request.
818    pub dns: Option<i64>, // TODO
819    /// Time required to create TCP connection.
820    ///
821    /// Use -1 if the timing does not apply to the current request.
822    pub connect: Option<i64>, // TODO
823    /// Time required to send HTTP request to the server.
824    pub send: i64, // TODO
825    /// Waiting for a response from the server.
826    pub wait: i64, // TODO
827    /// Time required to read entire response from the server (or cache).
828    pub receive: i64, // TODO
829    /// Time required for SSL/TLS negotiation.
830    ///
831    /// If this field is defined then the time is also included in the connect field
832    /// (to ensure backward compatibility with HAR 1.1).
833    ///
834    /// Use -1 if the timing does not apply to the current request.
835    pub ssl: Option<i64>, // TODO
836    /// A comment provided by the user or the application.
837    pub comment: Option<ArcStr>,
838}
839
840#[cfg(test)]
841mod tests {
842    use rama_http_types::body::util::BodyExt as _;
843
844    use super::*;
845
846    #[test]
847    fn into_har_headers_preserves_original_header_name_casing() {
848        let mut headers = HeaderMap::new();
849        headers.insert(
850            HeaderName::from_static("Content-Length"),
851            "42".parse().unwrap(),
852        );
853        headers.append(HeaderName::from_static("X-CuStOm"), "rama".parse().unwrap());
854
855        let har_headers = into_har_headers(headers);
856        assert_eq!("Content-Length", har_headers[0].name);
857        assert_eq!("X-CuStOm", har_headers[1].name);
858    }
859
860    #[test]
861    #[tracing_test::traced_test]
862    fn test_load_har_entries() {
863        let log_file: LogFile = serde_json::from_str(HAR_LOG_FILE_EXAMPLE).unwrap();
864
865        assert_eq!(6, log_file.log.entries.len());
866
867        let entry0 = &log_file.log.entries[0];
868        assert_eq!("http://www.igvita.com/", entry0.request.url);
869        assert_eq!("GET", entry0.request.method);
870        assert!(matches!(entry0.request.http_version, HttpVersion::Http11));
871        assert_eq!(0, entry0.request.query_string.len());
872
873        let entry1 = &log_file.log.entries[1];
874        assert_eq!(
875            "http://fonts.googleapis.com/css?family=Open+Sans:400,600",
876            entry1.request.url
877        );
878        assert_eq!(1, entry1.request.query_string.len());
879        assert_eq!("family", entry1.request.query_string[0].name);
880        assert_eq!("Open+Sans:400,600", entry1.request.query_string[0].value);
881
882        let entry5 = &log_file.log.entries[5];
883        assert_eq!(
884            "http://1-ps.googleusercontent.com/beacon?org=50_1_cn&ets=load:93&ifr=0&hft=32&url=http%3A%2F%2Fwww.igvita.com%2F",
885            entry5.request.url
886        );
887        assert_eq!(5, entry5.request.query_string.len());
888
889        // HAR Request to rama Request
890        let req0: crate::Request = entry0.request.clone().try_into().unwrap();
891        let (req0_parts, req0_body) = req0.into_parts();
892        drop(req0_body);
893
894        assert_eq!(crate::Method::GET, req0_parts.method);
895        assert_eq!(entry0.request.url, req0_parts.uri.to_string());
896        assert_eq!(RamaHttpVersion::HTTP_11, req0_parts.version);
897
898        let host = req0_parts
899            .headers
900            .get("Host")
901            .and_then(|v| v.to_str().ok())
902            .unwrap();
903        assert_eq!("www.igvita.com", host);
904
905        // rama Request to HAR Request
906        let req0_back = Request::from_http_request_parts(&req0_parts, &[], false).unwrap();
907        assert_eq!(entry0.request.method, req0_back.method);
908        assert_eq!(entry0.request.url, req0_back.url);
909        assert!(matches!(req0_back.http_version, HttpVersion::Http11));
910        assert_eq!(0, req0_back.body_size);
911
912        let ua = req0_back
913            .headers
914            .iter()
915            .find(|h| h.name.eq_ignore_ascii_case("User-Agent"))
916            .map(|h| h.value.as_str())
917            .unwrap();
918        assert!(ua.contains("Chrome/21.0.1180.82"));
919
920        // Query parsing sanity check when converting rama Request parts back into HAR Request
921        let req5: crate::Request = entry5.request.clone().try_into().unwrap();
922        let (req5_parts, req5_body) = req5.into_parts();
923        drop(req5_body);
924
925        let req5_back = Request::from_http_request_parts(&req5_parts, &[], false).unwrap();
926        assert_eq!(5, req5_back.query_string.len(), "req: {req5_back:?}");
927        assert!(
928            req5_back
929                .query_string
930                .iter()
931                .any(|p| p.name == "org" && p.value == "50_1_cn"),
932            "query string: {:?}",
933            req5_back.query_string
934        );
935        assert!(
936            req5_back
937                .query_string
938                .iter()
939                .any(|p| p.name == "ets" && p.value == "load:93"),
940            "query string: {:?}",
941            req5_back.query_string
942        );
943        assert!(
944            req5_back
945                .query_string
946                .iter()
947                .any(|p| p.name == "url" && p.value == "http://www.igvita.com/"),
948            "query string: {:?}",
949            req5_back.query_string
950        );
951
952        // HAR Response to rama Response
953        let har_res0 = entry0.response.clone();
954        let res0: crate::Response = har_res0.try_into().unwrap();
955        let (res0_parts, res0_body) = res0.into_parts();
956        drop(res0_body);
957
958        assert_eq!(crate::StatusCode::OK, res0_parts.status);
959        assert_eq!(RamaHttpVersion::HTTP_11, res0_parts.version);
960
961        let ct = res0_parts
962            .headers
963            .get("Content-Type")
964            .and_then(|v| v.to_str().ok())
965            .unwrap();
966        assert!(ct.starts_with("text/html"));
967
968        let ce = res0_parts
969            .headers
970            .get("Content-Encoding")
971            .and_then(|v| v.to_str().ok())
972            .unwrap();
973        assert_eq!("gzip", ce);
974
975        // rama Response to HAR Response
976        let res0_back = Response::from_http_response_parts(&res0_parts, &[], false).unwrap();
977        assert_eq!(200, res0_back.status);
978        assert_eq!(Some("OK"), res0_back.status_text.as_deref());
979        assert!(matches!(res0_back.http_version, HttpVersion::Http11));
980
981        let mime = res0_back.content.mime_type.unwrap();
982        assert_eq!("text/html; charset=utf-8", mime.as_ref());
983
984        assert!(res0_back.content.encoding.is_none());
985    }
986
987    #[tokio::test]
988    #[tracing_test::traced_test]
989    async fn test_load_har_entries_payload_roundtrip() {
990        let log_file: LogFile = serde_json::from_str(HAR_LOG_FILE_PAYLOAD_EXAMPLE).unwrap();
991        assert_eq!(1, log_file.log.entries.len());
992
993        let entry = &log_file.log.entries[0];
994
995        // HAR -> rama request payload
996        let req: crate::Request = entry.request.clone().try_into().unwrap();
997        let (req_parts, req_body) = req.into_parts();
998
999        let req_payload = vec![0u8, 255, 1, 2, 3];
1000        let req_bytes = req_body.collect().await.unwrap().to_bytes().to_vec();
1001        assert_eq!(req_payload, req_bytes);
1002
1003        // rama request parts + payload -> HAR request payload
1004        let har_req_back =
1005            Request::from_http_request_parts(&req_parts, &req_payload, false).unwrap();
1006        let post_data = har_req_back.post_data.unwrap();
1007        assert_eq!(
1008            Some(Mime::from_str("application/octet-stream").unwrap()),
1009            post_data.mime_type
1010        );
1011        assert_eq!(Some("AP8BAgM="), post_data.text.as_deref());
1012        assert_eq!(req_payload.len() as i64, har_req_back.body_size);
1013
1014        // HAR -> rama response payload
1015        let har_res = entry.response.clone();
1016        let res: crate::Response = har_res.try_into().unwrap();
1017        let (res_parts, res_body) = res.into_parts();
1018
1019        let res_payload = vec![10u8, 20, 30, 255, 0];
1020        let res_bytes = res_body.collect().await.unwrap().to_bytes().to_vec();
1021        assert_eq!(res_payload, res_bytes);
1022
1023        // rama response parts + payload -> HAR response payload
1024        let har_res_back =
1025            Response::from_http_response_parts(&res_parts, &res_payload, false).unwrap();
1026        assert_eq!(res_payload.len() as i64, har_res_back.content.size);
1027        assert_eq!(
1028            Some(Mime::from_str("application/octet-stream").unwrap()),
1029            har_res_back.content.mime_type
1030        );
1031        assert_eq!(Some("ChQe/wA="), har_res_back.content.text.as_deref());
1032        assert_eq!(Some("base64"), har_res_back.content.encoding.as_deref());
1033        assert_eq!(res_payload.len() as i64, har_res_back.body_size);
1034    }
1035
1036    #[test]
1037    fn unsupported_pages_are_omitted_instead_of_serialized_as_null() {
1038        let value = serde_json::to_value(Log::default()).unwrap();
1039        assert!(value.get("pages").is_none());
1040    }
1041
1042    const HAR_LOG_FILE_EXAMPLE: &str = r##"{"log":{"version":"1.2","creator":{"name":"WebInspector","version":"537.1"},"pages":[{"startedDateTime":"2012-08-28T05:14:24.803Z","id":"page_1","title":"http://www.igvita.com/","pageTimings":{"onContentLoad":299,"onLoad":301}}],"entries":[{"startedDateTime":"2012-08-28T05:14:24.803Z","time":121,"request":{"method":"GET","url":"http://www.igvita.com/","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"www.igvita.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},{"name":"Cache-Control","value":"max-age=0"}],"queryString":[],"cookies":[],"headersSize":678,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:24 GMT"},{"name":"Via","value":"HTTP/1.1 GWA"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"X-UA-Compatible","value":"IE=Edge,chrome=1"},{"name":"X-Page-Speed","value":"50_1_cn"},{"name":"Server","value":"nginx/1.0.11"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/html; charset=utf-8"},{"name":"Cache-Control","value":"max-age=0, no-cache"},{"name":"Expires","value":"Tue, 28 Aug 2012 05:14:24 GMT"}],"cookies":[],"content":{"size":9521,"mimeType":"text/html","compression":5896},"redirectURL":"","headersSize":379,"bodySize":3625},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":112,"receive":6,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.011Z","time":10,"request":{"method":"GET","url":"http://fonts.googleapis.com/css?family=Open+Sans:400,600","httpVersion":"HTTP/1.1","headers":[],"queryString":[{"name":"family","value":"Open+Sans:400,600"}],"cookies":[],"headersSize":71,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[],"cookies":[],"content":{"size":542,"mimeType":"text/css"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.017Z","time":31,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/css/style.css.pagespeed.ce.LzjUDNB25e.css","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Mon, 27 Aug 2012 15:28:34 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/css,*/*;q=0.1"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":539,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 06:01:49 GMT"},{"name":"Age","value":"83556"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Tue, 27 Aug 2013 06:01:49 GMT"}],"cookies":[],"content":{"size":14679,"mimeType":"text/css"},"redirectURL":"","headersSize":146,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":24,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.021Z","time":30,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/js/libs/modernizr.84728.js.pagespeed.jm._DgXLhVY42.js","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":536,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Age","value":"225828"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Sun, 25 Aug 2013 14:30:37 GMT"}],"cookies":[],"content":{"size":11831,"mimeType":"text/javascript"},"redirectURL":"","headersSize":147,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":0,"send":1,"wait":27,"receive":1,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.103Z","time":0,"request":{"method":"GET","url":"http://www.google-analytics.com/ga.js","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"cookies":[],"headersSize":52,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 21:57:00 GMT"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-Content-Type-Options","value":"nosniff, nosniff"},{"name":"Age","value":"23052"},{"name":"Last-Modified","value":"Thu, 16 Aug 2012 07:05:05 GMT"},{"name":"Server","value":"GFE/2.0"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/javascript"},{"name":"Expires","value":"Tue, 28 Aug 2012 09:57:00 GMT"},{"name":"Cache-Control","value":"max-age=43200, public"},{"name":"Content-Length","value":"14804"}],"cookies":[],"content":{"size":36893,"mimeType":"text/javascript"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":0,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.123Z","time":91,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/beacon?org=50_1_cn&ets=load:93&ifr=0&hft=32&url=http%3A%2F%2Fwww.igvita.com%2F","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[{"name":"org","value":"50_1_cn"},{"name":"ets","value":"load:93"},{"name":"ifr","value":"0"},{"name":"hft","value":"32"},{"name":"url","value":"http%3A%2F%2Fwww.igvita.com%2F"}],"cookies":[],"headersSize":448,"bodySize":0},"response":{"status":204,"statusText":"No Content","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:25 GMT"},{"name":"Content-Length","value":"0"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"Server","value":"PagespeedRewriteProxy 0.1"},{"name":"Content-Type","value":"text/plain"},{"name":"Cache-Control","value":"no-cache"}],"cookies":[],"content":{"size":0,"mimeType":"text/plain","compression":0},"redirectURL":"","headersSize":202,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":0,"wait":70,"receive":7,"ssl":-1},"pageref":"page_1"}]}}"##;
1043
1044    const HAR_LOG_FILE_PAYLOAD_EXAMPLE: &str = r##"{"log":{"version":"1.2","creator":{"name":"rama-test","version":"0.0"},"entries":[{"startedDateTime":"2012-08-28T05:14:24.803Z","time":1,"request":{"method":"POST","url":"http://example.test/upload","httpVersion":"HTTP/1.1","headers":[{"name":"Host","value":"example.test"},{"name":"Content-Type","value":"application/octet-stream"}],"queryString":[],"cookies":[],"postData":{"mimeType":"application/octet-stream","text":"AP8BAgM="},"headersSize":-1,"bodySize":5},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Content-Type","value":"application/octet-stream"}],"cookies":[],"content":{"size":5,"mimeType":"application/octet-stream","text":"ChQe/wA=","encoding":"base64"},"redirectURL":"","headersSize":-1,"bodySize":5},"cache":{},"timings":{"send":0,"wait":1,"receive":0}}]}}"##;
1045}