server-timing 0.1.0

A parser for the server-timing header
Documentation
//! Follows the W3C Server Timing spec and RFC 7230 for definitions of the Server Timing header and its grammar.
//! https://w3c.github.io/server-timing/#the-server-timing-header-field
//! https://httpwg.org/specs/rfc7230.html

use std::borrow::Cow;

use nom::{
    branch::alt,
    bytes::complete::tag,
    character::complete::{anychar, char as nchar, one_of, space0, tab},
    combinator::{recognize, verify},
    multi::{many0, many1, separated_list1},
    sequence::{delimited, pair, preceded, separated_pair},
    IResult, Parser as _,
};

/// optional whitespace
#[inline(always)]
fn ows(input: &str) -> IResult<&str, &str> {
    space0(input)
}

/// Any visible USASCII character
#[inline]
fn vchar(input: &str) -> IResult<&str, char> {
    verify(anychar, |c| c.is_ascii_graphic())(input)
}

/// A string of text is parsed as a single value if it is quoted using double-quote marks.
#[inline]
fn quoted_string(input: &str) -> IResult<&str, String> {
    let (input, chars) = delimited(tag("\""), many0(alt((qdtext, quoted_pair))), tag("\""))(input)?;
    Ok((input, chars.into_iter().collect()))
}

/// HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
#[inline]
fn qdtext(input: &str) -> IResult<&str, char> {
    alt((
        tab,
        nchar(' '),
        verify(anychar, |c| {
            let c = *c as u32;
            c == 0x21 || (c >= 0x23 && c <= 0x5B) || (c >= 0x5D && c <= 0x7E)
        }),
        obs_text,
    ))(input)
}

/// The backslash octet ("\") can be used as a single-octet quoting mechanism within quoted-string and comment
/// constructs. Recipients that process the value of a quoted-string MUST handle a quoted-pair as if it were replaced by
/// the octet following the backslash.
#[inline]
fn quoted_pair(input: &str) -> IResult<&str, char> {
    preceded(nchar('\\'), alt((tab, nchar(' '), vchar, obs_text)))(input)
}

/// %x80-FF (obsolete)
#[inline]
fn obs_text(input: &str) -> IResult<&str, char> {
    verify(anychar, |c| {
        let c = *c as u32;
        c >= 0x80 && c <= 0xFF
    })(input)
}

/// any VCHAR, except delimiters
#[inline]
fn tchar(input: &str) -> IResult<&str, char> {
    alt((
        (one_of("!#$'*+-.^_`|~")),
        verify(anychar, |c| c.is_ascii_alphanumeric()),
    ))(input)
}

/// 1*tchar
#[inline]
fn token(input: &str) -> IResult<&str, &str> {
    recognize(many1(tchar))(input)
}

///  server-timing-param       = server-timing-param-name OWS "=" OWS server-timing-param-value
///  server-timing-param-name  = token
///  server-timing-param-value = token / quoted-string
fn server_timing_param(input: &str) -> IResult<&str, (&str, Cow<str>)> {
    let server_timing_param_name = token;
    let server_timing_param_value = alt((token.map(Cow::Borrowed), quoted_string.map(Cow::Owned)));
    separated_pair(
        server_timing_param_name,
        delimited(ows, tag("="), ows),
        server_timing_param_value,
    )(input)
}

pub type Metric<'a> = (&'a str, Vec<(&'a str, Cow<'a, str>)>);

///  Server-Timing             = #server-timing-metric
///  server-timing-metric      = metric-name *( OWS ";" OWS server-timing-param )
///  metric-name               = token
fn server_timing_metric(input: &str) -> IResult<&str, Metric> {
    let metric_name = token;
    let params = many0(preceded(delimited(ows, tag(";"), ows), server_timing_param));
    pair(metric_name, params)(input)
}

pub fn server_timing(input: &str) -> IResult<&str, Vec<Metric>> {
    separated_list1(delimited(ows, tag(","), ows), server_timing_metric)(input)
}

#[cfg(test)]
mod test {
    use super::{server_timing, server_timing_metric, server_timing_param};

    #[test]
    fn test_server_timing_metric_param_order() {
        let input = "foo;dur=12.3;desc=bar";
        let (rest, (name, params)) = server_timing_metric(input).unwrap();
        assert_eq!(rest, "");
        assert_eq!(name, "foo");
        assert_eq!(params, vec![("dur", "12.3".into()), ("desc", "bar".into())]);
    }

    #[test]
    fn test_server_timing_param_bare_string() {
        let input = "desc=bar;dur=12.3";
        let (rest, (key, value)) = server_timing_param(input).unwrap();
        assert_eq!(rest, ";dur=12.3");
        assert_eq!(key, "desc");
        assert_eq!(value, "bar");
    }

    #[test]
    fn test_server_timing_param_bare_string_spaces() {
        let input = "desc=bar baz;dur=12.3";
        let (rest, (key, value)) = server_timing_param(input).unwrap();
        assert_eq!(rest, " baz;dur=12.3");
        assert_eq!(key, "desc");
        assert_eq!(value, "bar");
    }

    #[test]
    fn test_server_timing_param_quoted_string() {
        let input = "desc=\"hello=world;description\";dur=12.3";
        let (rest, (key, value)) = server_timing_param(input).unwrap();
        assert_eq!(rest, ";dur=12.3");
        assert_eq!(key, "desc");
        assert_eq!(value, "hello=world;description");
    }

    #[test]
    fn test_server_timing_param_quoted_string_empty() {
        let input = "desc=\"\";dur=12.3";
        let (rest, (key, value)) = server_timing_param(input).unwrap();
        assert_eq!(rest, ";dur=12.3");
        assert_eq!(key, "desc");
        assert_eq!(value, "");
    }

    #[test]
    fn test_server_timing_param_quoted_string_spaces() {
        let input = "desc=\"bar baz\";dur=12.3";
        let (rest, (key, value)) = server_timing_param(input).unwrap();
        assert_eq!(rest, ";dur=12.3");
        assert_eq!(key, "desc");
        assert_eq!(value, "bar baz");
    }

    #[test]
    fn test_server_timing_param_quoted_string_escaped() {
        let input = "desc=\"\\\"\";dur=12.3";
        let (rest, (key, value)) = server_timing_param(input).unwrap();
        assert_eq!(rest, ";dur=12.3");
        assert_eq!(key, "desc");
        assert_eq!(value, "\"");
    }

    #[test]
    fn test_server_timing_metric() {
        let input = "foo;desc=bar;dur=12.3";
        let (rest, (name, params)) = server_timing_metric(input).unwrap();
        assert_eq!(rest, "");
        assert_eq!(name, "foo");
        assert_eq!(params, vec![("desc", "bar".into()), ("dur", "12.3".into())]);
    }

    #[test]
    fn test_server_timing_metric_param_unknown() {
        let input = "foo;bar=baz";
        let (rest, (name, params)) = server_timing_metric(input).unwrap();
        assert_eq!(rest, "");
        assert_eq!(name, "foo");
        assert_eq!(params, vec![("bar", "baz".into())]);
    }

    #[test]
    fn test_server_timing() {
        let input = "foo;bar=baz, hello, db;dur=12.3;desc=mysql";
        let (rest, metrics) = server_timing(input).unwrap();
        let expected = vec![
            ("foo", vec![("bar", "baz".into())]),
            ("hello", vec![]),
            ("db", vec![("dur", "12.3".into()), ("desc", "mysql".into())]),
        ];
        assert_eq!(rest, "");
        assert_eq!(metrics, expected);
    }

    #[test]
    fn test_comma_in_quoted_string() {
        let input = "foo;desc=\"bar, baz\", hello";
        let (rest, metrics) = server_timing(input).unwrap();
        assert_eq!(rest, "");
        assert_eq!(
            metrics,
            vec![
                ("foo", vec![("desc", "bar, baz".into())]),
                ("hello", vec![])
            ]
        );
    }
}