diff --git a/http/deno.json b/http/deno.json index 46dd1db52643..bae0e77dfb7b 100644 --- a/http/deno.json +++ b/http/deno.json @@ -16,6 +16,7 @@ "./unstable-server-sent-event-stream": "./unstable_server_sent_event_stream.ts", "./status": "./status.ts", "./unstable-signed-cookie": "./unstable_signed_cookie.ts", + "./unstable-structured-fields": "./unstable_structured_fields.ts", "./user-agent": "./user_agent.ts", "./unstable-route": "./unstable_route.ts" } diff --git a/http/testdata/structured_fields/binary.json b/http/testdata/structured_fields/binary.json new file mode 100644 index 000000000000..e1d412fc375f --- /dev/null +++ b/http/testdata/structured_fields/binary.json @@ -0,0 +1,100 @@ +[ + { + "name": "basic binary", + "raw": [":aGVsbG8=:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "NBSWY3DP"}, + []] + }, + { + "name": "empty binary", + "raw": ["::"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": ""}, + []] + }, + { + "name": "padding at beginning", + "raw": [":=aGVsbG8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "padding in middle", + "raw": [":a=GVsbG8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad padding", + "raw": [":aGVsbG8:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "NBSWY3DP"}, + []], + "can_fail": true, + "canonical": [":aGVsbG8=:"] + }, + { + "name": "bad padding dot", + "raw": [":aGVsbG8.:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad end delimiter", + "raw": [":aGVsbG8="], + "header_type": "item", + "must_fail": true + }, + { + "name": "extra whitespace", + "raw": [":aGVsb G8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "all whitespace", + "raw": [": :"], + "header_type": "item", + "must_fail": true + }, + { + "name": "extra chars", + "raw": [":aGVsbG!8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "suffix chars", + "raw": [":aGVsbG8=!:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "non-zero pad bits", + "raw": [":iZ==:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "RE======"}, + []], + "can_fail": true, + "canonical": [":iQ==:"] + }, + { + "name": "non-ASCII binary", + "raw": [":/+Ah:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "77QCC==="}, + []] + }, + { + "name": "base64url binary", + "raw": [":_-Ah:"], + "header_type": "item", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/boolean.json b/http/testdata/structured_fields/boolean.json new file mode 100644 index 000000000000..2ec379f19e08 --- /dev/null +++ b/http/testdata/structured_fields/boolean.json @@ -0,0 +1,69 @@ +[ + { + "name": "basic true boolean", + "raw": ["?1"], + "header_type": "item", + "expected": [true, []] + }, + { + "name": "basic false boolean", + "raw": ["?0"], + "header_type": "item", + "expected": [false, []] + }, + { + "name": "unknown boolean", + "raw": ["?Q"], + "header_type": "item", + "must_fail": true + }, + { + "name": "whitespace boolean", + "raw": ["? 1"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative zero boolean", + "raw": ["?-0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "T boolean", + "raw": ["?T"], + "header_type": "item", + "must_fail": true + }, + { + "name": "F boolean", + "raw": ["?F"], + "header_type": "item", + "must_fail": true + }, + { + "name": "t boolean", + "raw": ["?t"], + "header_type": "item", + "must_fail": true + }, + { + "name": "f boolean", + "raw": ["?f"], + "header_type": "item", + "must_fail": true + }, + { + "name": "spelled-out True boolean", + "raw": ["?True"], + "header_type": "item", + "must_fail": true + }, + { + "name": "spelled-out False boolean", + "raw": ["?False"], + "header_type": "item", + "must_fail": true + } +] + diff --git a/http/testdata/structured_fields/date.json b/http/testdata/structured_fields/date.json new file mode 100644 index 000000000000..15da76a605e5 --- /dev/null +++ b/http/testdata/structured_fields/date.json @@ -0,0 +1,50 @@ +[ + { + "name": "date - 1970-01-01 00:00:00", + "raw": ["@0"], + "header_type": "item", + "expected": [{"__type": "date", "value": 0}, []] + }, + { + "name": "date - 2022-08-04 01:57:13", + "raw": ["@1659578233"], + "header_type": "item", + "expected": [{"__type": "date", "value": 1659578233}, []] + }, + { + "name": "date - 1917-05-30 22:02:47", + "raw": ["@-1659578233"], + "header_type": "item", + "expected": [{"__type": "date", "value": -1659578233}, []] + }, + { + "name": "date - 2^31", + "raw": ["@2147483648"], + "header_type": "item", + "expected": [{"__type": "date", "value": 2147483648}, []] + }, + { + "name": "date - 2^32", + "raw": ["@4294967296"], + "header_type": "item", + "expected": [{"__type": "date", "value": 4294967296}, []] + }, + { + "name": "large date - 9999-12-31 00:00:00", + "raw": ["@253402214400"], + "header_type": "item", + "expected": [{"__type": "date", "value": 253402214400}, []] + }, + { + "name": "small date - 0001-01-01 00:00:00", + "raw": ["@-62135596800"], + "header_type": "item", + "expected": [{"__type": "date", "value": -62135596800}, []] + }, + { + "name": "date - decimal", + "raw": ["@1659578233.12"], + "header_type": "item", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/dictionary.json b/http/testdata/structured_fields/dictionary.json new file mode 100644 index 000000000000..92d37c03577d --- /dev/null +++ b/http/testdata/structured_fields/dictionary.json @@ -0,0 +1,163 @@ +[ + { + "name": "basic dictionary", + "raw": ["en=\"Applepie\", da=:w4ZibGV0w6ZydGUK:"], + "header_type": "dictionary", + "expected": [["en", ["Applepie", []]], ["da", [ + {"__type": "binary", "value": "YODGE3DFOTB2M4TUMUFA===="}, + []] + ]] + }, + { + "name": "empty dictionary", + "raw": [""], + "header_type": "dictionary", + "expected": [], + "canonical": [] + }, + { + "name": "single item dictionary", + "raw": ["a=1"], + "header_type": "dictionary", + "expected": [["a", [1, []]]] + }, + { + "name": "list item dictionary", + "raw": ["a=(1 2)"], + "header_type": "dictionary", + "expected": [["a", [[[1, []], [2, []]], []]]] + }, + { + "name": "single list item dictionary", + "raw": ["a=(1)"], + "header_type": "dictionary", + "expected": [["a", [[[1, []]], []]]] + }, + { + "name": "empty list item dictionary", + "raw": ["a=()"], + "header_type": "dictionary", + "expected": [["a", [[], []]]] + }, + { + "name": "no whitespace dictionary", + "raw": ["a=1,b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "extra whitespace dictionary", + "raw": ["a=1 , b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "tab separated dictionary", + "raw": ["a=1\t,\tb=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "leading whitespace dictionary", + "raw": [" a=1 , b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "whitespace before = dictionary", + "raw": ["a =1, b=2"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace after = dictionary", + "raw": ["a=1, b= 2"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "two lines dictionary", + "raw": ["a=1", "b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "missing value dictionary", + "raw": ["a=1, b, c=3"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, []]], ["c", [3, []]]] + }, + { + "name": "all missing value dictionary", + "raw": ["a, b, c"], + "header_type": "dictionary", + "expected": [["a", [true, []]], ["b", [true, []]], ["c", [true, []]]] + }, + { + "name": "start missing value dictionary", + "raw": ["a, b=2"], + "header_type": "dictionary", + "expected": [["a", [true, []]], ["b", [2, []]]] + }, + { + "name": "end missing value dictionary", + "raw": ["a=1, b"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, []]]] + }, + { + "name": "missing value with params dictionary", + "raw": ["a=1, b;foo=9, c=3"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, [["foo", 9]]]], ["c", [3, []]]] + }, + { + "name": "explicit true value with params dictionary", + "raw": ["a=1, b=?1;foo=9, c=3"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, [["foo", 9]]]], ["c", [3, []]]], + "canonical": ["a=1, b;foo=9, c=3"] + }, + { + "name": "trailing comma dictionary", + "raw": ["a=1, b=2,"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "empty item dictionary", + "raw": ["a=1,,b=2,"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "duplicate key dictionary", + "raw": ["a=1,b=2,a=3"], + "header_type": "dictionary", + "expected": [["a", [3, []]], ["b", [2, []]]], + "canonical": ["a=3, b=2"] + }, + { + "name": "numeric key dictionary", + "raw": ["a=1,1b=2,a=1"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "uppercase key dictionary", + "raw": ["a=1,B=2,a=1"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "bad key dictionary", + "raw": ["a=1,b!=2,a=1"], + "header_type": "dictionary", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/display-string.json b/http/testdata/structured_fields/display-string.json new file mode 100644 index 000000000000..29f674e029b2 --- /dev/null +++ b/http/testdata/structured_fields/display-string.json @@ -0,0 +1,123 @@ +[ + { + "name": "basic display string (ascii content)", + "raw": ["%\"foo bar\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "foo bar"}, []] + }, + { + "name": "all printable ascii", + "raw": ["%\" !%22#$%25&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"}, []] + }, + { + "name": "non-ascii display string (uppercase escaping)", + "raw": ["%\"f%C3%BC%C3%BC\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "non-ascii display string (lowercase escaping)", + "raw": ["%\"f%c3%bc%c3%bc\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "füü"}, []] + }, + { + "name": "non-ascii display string (unescaped)", + "raw": ["%\"füü\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "tab in display string", + "raw": ["%\"\t\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "newline in display string", + "raw": ["%\"\n\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "single quoted display string", + "raw": ["%'foo'"], + "header_type": "item", + "must_fail": true + }, + { + "name": "unquoted display string", + "raw": ["%foo"], + "header_type": "item", + "must_fail": true + }, + { + "name": "display string missing initial quote", + "raw": ["%foo\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "unbalanced display string", + "raw": ["%\"foo"], + "header_type": "item", + "must_fail": true + }, + { + "name": "display string quoting", + "raw": ["%\"foo %22bar%22 \\ baz\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "foo \"bar\" \\ baz"}, []] + }, + { + "name": "bad display string escaping", + "raw": ["%\"foo %a"], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid 2-byte seq)", + "raw": ["%\"%c3%28\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid sequence id)", + "raw": ["%\"%a0%a1\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid hex)", + "raw": ["%\"%g0%1w\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid 3-byte seq)", + "raw": ["%\"%e2%28%a1\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid 4-byte seq)", + "raw": ["%\"%f0%28%8c%28\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "BOM in display string", + "raw": ["%\"BOM: %ef%bb%bf\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "BOM: \uFEFF"}, []] + }, + { + "name": "two lines display string", + "raw": ["%\"foo", "bar\""], + "header_type": "item", + "can_fail": true, + "expected": [{"__type": "displaystring", "value": "foo, bar"}, []] + } +] diff --git a/http/testdata/structured_fields/examples.json b/http/testdata/structured_fields/examples.json new file mode 100644 index 000000000000..eb7dc349fddd --- /dev/null +++ b/http/testdata/structured_fields/examples.json @@ -0,0 +1,208 @@ +[ + { + "name": "Foo-Example", + "raw": ["2; foourl=\"https://foo.example.com/\""], + "header_type": "item", + "expected": [2, [["foourl", "https://foo.example.com/"]]], + "canonical": ["2;foourl=\"https://foo.example.com/\""] + }, + { + "name": "Example-StrListHeader", + "raw": ["\"foo\", \"bar\", \"It was the best of times.\""], + "header_type": "list", + "expected": [ + ["foo", []], + ["bar", []], + ["It was the best of times.", []] + ] + }, + { + "name": "Example-Hdr (list on one line)", + "raw": ["foo, bar"], + "header_type": "list", + "expected": [ + [{"__type":"token", "value":"foo"}, []], + [{"__type":"token", "value":"bar"}, []] + ] + }, + { + "name": "Example-Hdr (list on two lines)", + "raw": ["foo", "bar"], + "header_type": "list", + "expected": [ + [{"__type":"token", "value":"foo"}, []], + [{"__type":"token", "value":"bar"}, []] + ], + "canonical": ["foo, bar"] + }, + { + "name": "Example-StrListListHeader", + "raw": ["(\"foo\" \"bar\"), (\"baz\"), (\"bat\" \"one\"), ()"], + "header_type": "list", + "expected": [ + [[ + ["foo", []], + ["bar", []] + ], []], + [[ + ["baz", []] + ], []], + [[ + ["bat", []], + ["one", []] + ], []], + [[], []] + ] + }, + { + "name": "Example-ListListParam", + "raw": ["(\"foo\"; a=1;b=2);lvl=5, (\"bar\" \"baz\");lvl=1"], + "header_type": "list", + "expected": [ + [[ + ["foo", [["a", 1], ["b", 2]]] + ], [["lvl", 5]]], + [[ + ["bar", []], ["baz", []] + ], [["lvl", 1]]] + ], + "canonical": ["(\"foo\";a=1;b=2);lvl=5, (\"bar\" \"baz\");lvl=1"] + }, + + { + "name": "Example-ParamListHeader", + "raw": ["abc;a=1;b=2; cde_456, (ghi;jk=4 l);q=\"9\";r=w"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "abc"}, [["a", 1], ["b", 2], ["cde_456", true]]], + [ + [ + [{"__type": "token", "value": "ghi"}, [["jk", 4]]], + [{"__type": "token", "value": "l"}, []] + ], + [["q", "9"], ["r", {"__type": "token", "value": "w"}]] + ] + ], + "canonical": ["abc;a=1;b=2;cde_456, (ghi;jk=4 l);q=\"9\";r=w"] + }, + { + "name": "Example-IntHeader", + "raw": ["1; a; b=?0"], + "header_type": "item", + "expected": [1, [["a", true], ["b", false]]], + "canonical": ["1;a;b=?0"] + }, + { + "name": "Example-DictHeader", + "raw": ["en=\"Applepie\", da=:w4ZibGV0w6ZydGU=:"], + "header_type": "dictionary", + "expected": [ + ["en", ["Applepie", []]], + ["da", [{"__type": "binary", "value": "YODGE3DFOTB2M4TUMU======"}, []]] + ] + }, + { + "name": "Example-DictHeader (boolean values)", + "raw": ["a=?0, b, c; foo=bar"], + "header_type": "dictionary", + "expected": [ + ["a", [false, []]], + ["b", [true, []]], + ["c", [true, [["foo", {"__type": "token", "value": "bar"}]]]] + ], + "canonical": ["a=?0, b, c;foo=bar"] + }, + { + "name": "Example-DictListHeader", + "raw": ["rating=1.5, feelings=(joy sadness)"], + "header_type": "dictionary", + "expected": [ + ["rating", [1.5, []]], + ["feelings", [[ + [{"__type": "token", "value": "joy"}, []], + [{"__type": "token", "value": "sadness"}, []] + ], []]] + ] + }, + { + "name": "Example-MixDict", + "raw": ["a=(1 2), b=3, c=4;aa=bb, d=(5 6);valid"], + "header_type": "dictionary", + "expected": [ + ["a", [[ + [1, []], + [2, []] + ], []]], + ["b", [3, []]], + ["c", [4, [["aa", {"__type": "token", "value": "bb"}]]]], + ["d", [[ + [5, []], + [6, []] + ], [["valid", true]]]] + ], + "canonical": ["a=(1 2), b=3, c=4;aa=bb, d=(5 6);valid"] + }, + { + "name": "Example-Hdr (dictionary on one line)", + "raw": ["foo=1, bar=2"], + "header_type": "dictionary", + "expected": [ + ["foo", [1, []]], + ["bar", [2, []]] + ] + }, + { + "name": "Example-Hdr (dictionary on two lines)", + "raw": ["foo=1", "bar=2"], + "header_type": "dictionary", + "expected": [ + ["foo", [1, []]], + ["bar", [2, []]] + ], + "canonical": ["foo=1, bar=2"] + }, + + { + "name": "Example-IntItemHeader", + "raw": ["5"], + "header_type": "item", + "expected": [5, []] + }, + { + "name": "Example-IntItemHeader (params)", + "raw": ["5; foo=bar"], + "header_type": "item", + "expected": [5, [["foo", {"__type": "token", "value": "bar"}]]], + "canonical": ["5;foo=bar"] + }, + { + "name": "Example-IntegerHeader", + "raw": ["42"], + "header_type": "item", + "expected": [42, []] + }, + { + "name": "Example-FloatHeader", + "raw": ["4.5"], + "header_type": "item", + "expected": [4.5, []] + }, + { + "name": "Example-StringHeader", + "raw": ["\"hello world\""], + "header_type": "item", + "expected": ["hello world", []] + }, + { + "name": "Example-BinaryHdr", + "raw": [":cHJldGVuZCB0aGlzIGlzIGJpbmFyeSBjb250ZW50Lg==:"], + "header_type": "item", + "expected": [{"__type": "binary", "value": "OBZGK5DFNZSCA5DINFZSA2LTEBRGS3TBOJ4SAY3PNZ2GK3TUFY======"}, []] + }, + { + "name": "Example-BoolHdr", + "raw": ["?1"], + "header_type": "item", + "expected": [true, []] + } +] diff --git a/http/testdata/structured_fields/item.json b/http/testdata/structured_fields/item.json new file mode 100644 index 000000000000..e86cea0e9a6b --- /dev/null +++ b/http/testdata/structured_fields/item.json @@ -0,0 +1,34 @@ +[ + { + "name": "empty item", + "raw": [""], + "header_type": "item", + "must_fail": true + }, + { + "name": "leading space", + "raw": [" \t 1"], + "header_type": "item", + "must_fail": true + }, + { + "name": "trailing space", + "raw": ["1 \t "], + "header_type": "item", + "must_fail": true + }, + { + "name": "leading and trailing space", + "raw": [" 1 "], + "header_type": "item", + "expected": [1, []], + "canonical": ["1"] + }, + { + "name": "leading and trailing whitespace", + "raw": [" 1 "], + "header_type": "item", + "expected": [1, []], + "canonical": ["1"] + } +] diff --git a/http/testdata/structured_fields/list.json b/http/testdata/structured_fields/list.json new file mode 100644 index 000000000000..c1d047d0fc8e --- /dev/null +++ b/http/testdata/structured_fields/list.json @@ -0,0 +1,74 @@ +[ + { + "name": "basic list", + "raw": ["1, 42"], + "header_type": "list", + "expected": [[1, []], [42, []]] + }, + { + "name": "empty list", + "raw": [""], + "header_type": "list", + "expected": [], + "canonical": [] + }, + { + "name": "leading SP list", + "raw": [" 42, 43"], + "canonical": ["42, 43"], + "header_type": "list", + "expected": [[42, []], [43, []]] + }, + { + "name": "single item list", + "raw": ["42"], + "header_type": "list", + "expected": [[42, []]] + }, + { + "name": "no whitespace list", + "raw": ["1,42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "extra whitespace list", + "raw": ["1 , 42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "tab separated list", + "raw": ["1\t,\t42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "two line list", + "raw": ["1", "42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "trailing comma list", + "raw": ["1, 42,"], + "header_type": "list", + "must_fail": true + }, + { + "name": "empty item list", + "raw": ["1,,42"], + "header_type": "list", + "must_fail": true + }, + { + "name": "empty item list (multiple field lines)", + "raw": ["1","","42"], + "header_type": "list", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/number.json b/http/testdata/structured_fields/number.json new file mode 100644 index 000000000000..43ae9601b983 --- /dev/null +++ b/http/testdata/structured_fields/number.json @@ -0,0 +1,249 @@ +[ + { + "name": "basic integer", + "raw": ["42"], + "header_type": "item", + "expected": [42, []] + }, + { + "name": "zero integer", + "raw": ["0"], + "header_type": "item", + "expected": [0, []] + }, + { + "name": "negative zero", + "raw": ["-0"], + "header_type": "item", + "expected": [0, []], + "canonical": ["0"] + }, + { + "name": "double negative zero", + "raw": ["--0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative integer", + "raw": ["-42"], + "header_type": "item", + "expected": [-42, []] + }, + { + "name": "leading 0 integer", + "raw": ["042"], + "header_type": "item", + "expected": [42, []], + "canonical": ["42"] + }, + { + "name": "leading 0 negative integer", + "raw": ["-042"], + "header_type": "item", + "expected": [-42, []], + "canonical": ["-42"] + }, + { + "name": "leading 0 zero", + "raw": ["00"], + "header_type": "item", + "expected": [0, []], + "canonical": ["0"] + }, + { + "name": "comma", + "raw": ["2,3"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative non-DIGIT first character", + "raw": ["-a23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "sign out of place", + "raw": ["4-2"], + "header_type": "item", + "must_fail": true + }, + { + "name": "whitespace after sign", + "raw": ["- 42"], + "header_type": "item", + "must_fail": true + }, + { + "name": "long integer", + "raw": ["123456789012345"], + "header_type": "item", + "expected": [123456789012345, []] + }, + { + "name": "long integer followed by comma", + "raw": [ + "123456789012345, 1" + ], + "header_type": "list", + "expected": [ + [ + 123456789012345, + [] + ], + [ + 1, + [] + ] + ] + }, + { + "name": "long negative integer", + "raw": ["-123456789012345"], + "header_type": "item", + "expected": [-123456789012345, []] + }, + { + "name": "too long integer", + "raw": ["1234567890123456"], + "header_type": "item", + "must_fail": true + }, + { + "name": "too long integer followed by comma", + "raw": [ + "1234567890123456, 1" + ], + "header_type": "list", + "must_fail": true + }, + { + "name": "negative too long integer", + "raw": ["-1234567890123456"], + "header_type": "item", + "must_fail": true + }, + { + "name": "simple decimal", + "raw": ["1.23"], + "header_type": "item", + "expected": [1.23, []] + }, + { + "name": "negative decimal", + "raw": ["-1.23"], + "header_type": "item", + "expected": [-1.23, []] + }, + { + "name": "decimal, whitespace after decimal", + "raw": ["1. 23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal, whitespace before decimal", + "raw": ["1 .23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative decimal, whitespace after sign", + "raw": ["- 1.23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal, followed by comma", + "raw": [ + "123456789012.123, 1.1" + ], + "header_type": "list", + "expected": [ + [ + 123456789012.123, + [] + ], + [ + 1.1, + [] + ] + ] + }, + { + "name": "tricky precision decimal", + "raw": ["123456789012.1"], + "header_type": "item", + "expected": [123456789012.1, []] + }, + { + "name": "double decimal decimal", + "raw": ["1.5.4"], + "header_type": "item", + "must_fail": true + }, + { + "name": "adjacent double decimal decimal", + "raw": ["1..4"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal with three fractional digits", + "raw": ["1.123"], + "header_type": "item", + "expected": [1.123, []] + }, + { + "name": "negative decimal with three fractional digits", + "raw": ["-1.123"], + "header_type": "item", + "expected": [-1.123, []] + }, + { + "name": "decimal with four fractional digits", + "raw": ["1.1234"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative decimal with four fractional digits", + "raw": ["-1.1234"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal with thirteen integer digits", + "raw": ["1234567890123.0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative decimal with thirteen integer digits", + "raw": ["-1234567890123.0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal with 1 significant digit and 1 insignificant digit", + "raw": ["1.20"], + "header_type": "item", + "expected": [1.2, []], + "canonical": ["1.2"] + }, + { + "name": "decimal with 1 significant digit and 2 insignificant digits", + "raw": ["1.200"], + "header_type": "item", + "expected": [1.2, []], + "canonical": ["1.2"] + }, + { + "name": "decimal with 2 significant digits and 1 insignificant digit", + "raw": ["1.230"], + "header_type": "item", + "expected": [1.23, []], + "canonical": ["1.23"] + } +] diff --git a/http/testdata/structured_fields/param-dict.json b/http/testdata/structured_fields/param-dict.json new file mode 100644 index 000000000000..5d03c8dcaa3f --- /dev/null +++ b/http/testdata/structured_fields/param-dict.json @@ -0,0 +1,113 @@ +[ + { + "name": "basic parameterised dict", + "raw": ["abc=123;a=1;b=2, def=456, ghi=789;q=9;r=\"+w\""], + "header_type": "dictionary", + "expected": [ + ["abc", [123, [["a", 1], ["b", 2]]]], + ["def", [456, []]], + ["ghi", [789, [["q", 9], ["r", "+w"]]]] + ] + }, + { + "name": "single item parameterised dict", + "raw": ["a=b; q=1.0"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["q", 1.0]]]] + ], + "canonical": ["a=b;q=1.0"] + }, + { + "name": "list item parameterised dictionary", + "raw": ["a=(1 2); q=1.0"], + "header_type": "dictionary", + "expected": [["a", [[[1, []], [2, []]], [["q", 1.0]]]]], + "canonical": ["a=(1 2);q=1.0"] + }, + { + "name": "missing parameter value parameterised dict", + "raw": ["a=3;c;d=5"], + "header_type": "dictionary", + "expected": [ + ["a", [3, [["c", true], ["d", 5]]]] + ] + }, + { + "name": "terminal missing parameter value parameterised dict", + "raw": ["a=3;c=5;d"], + "header_type": "dictionary", + "expected": [ + ["a", [3, [["c", 5], ["d", true]]]] + ] + }, + { + "name": "no whitespace parameterised dict", + "raw": ["a=b;c=1,d=e;f=2"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["c", 1]]]], + ["d", [{"__type": "token", "value": "e"}, [["f", 2]]]] + ], + "canonical": ["a=b;c=1, d=e;f=2"] + }, + { + "name": "whitespace before = parameterised dict", + "raw": ["a=b;q =0.5"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace after = parameterised dict", + "raw": ["a=b;q= 0.5"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace before ; parameterised dict", + "raw": ["a=b ;q=0.5"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace after ; parameterised dict", + "raw": ["a=b; q=0.5"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["q", 0.5]]]] + ], + "canonical": ["a=b;q=0.5"] + }, + { + "name": "extra whitespace parameterised dict", + "raw": ["a=b; c=1 , d=e; f=2; g=3"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["c", 1]]]], + ["d", [{"__type": "token", "value": "e"}, [["f", 2], ["g", 3]]]] + ], + "canonical": ["a=b;c=1, d=e;f=2;g=3"] + }, + { + "name": "two lines parameterised list", + "raw": ["a=b;c=1", "d=e;f=2"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["c", 1]]]], + ["d", [{"__type": "token", "value": "e"}, [["f", 2]]]] + ], + "canonical": ["a=b;c=1, d=e;f=2"] + }, + { + "name": "trailing comma parameterised list", + "raw": ["a=b; q=1.0,"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "empty item parameterised list", + "raw": ["a=b; q=1.0,,c=d"], + "header_type": "dictionary", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/param-list.json b/http/testdata/structured_fields/param-list.json new file mode 100644 index 000000000000..5c00be6a265e --- /dev/null +++ b/http/testdata/structured_fields/param-list.json @@ -0,0 +1,108 @@ +[ + { + "name": "basic parameterised list", + "raw": ["abc_123;a=1;b=2; cdef_456, ghi;q=9;r=\"+w\""], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "abc_123"}, [["a", 1], ["b", 2], ["cdef_456", true]]], + [{"__type": "token", "value": "ghi"}, [["q", 9], ["r", "+w"]]] + ], + "canonical": ["abc_123;a=1;b=2;cdef_456, ghi;q=9;r=\"+w\""] + }, + { + "name": "single item parameterised list", + "raw": ["text/html;q=1.0"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, [["q", 1.0]]] + ] + }, + { + "name": "missing parameter value parameterised list", + "raw": ["text/html;a;q=1.0"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, [["a", true], ["q", 1.0]]] + ] + }, + { + "name": "missing terminal parameter value parameterised list", + "raw": ["text/html;q=1.0;a"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, [["q", 1.0], ["a", true]]] + ] + }, + { + "name": "no whitespace parameterised list", + "raw": ["text/html,text/plain;q=0.5"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, [["q", 0.5]]] + ], + "canonical": ["text/html, text/plain;q=0.5"] + }, + { + "name": "whitespace before = parameterised list", + "raw": ["text/html, text/plain;q =0.5"], + "header_type": "list", + "must_fail": true + }, + { + "name": "whitespace after = parameterised list", + "raw": ["text/html, text/plain;q= 0.5"], + "header_type": "list", + "must_fail": true + }, + { + "name": "whitespace before ; parameterised list", + "raw": ["text/html, text/plain ;q=0.5"], + "header_type": "list", + "must_fail": true + }, + { + "name": "whitespace after ; parameterised list", + "raw": ["text/html, text/plain; q=0.5"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, [["q", 0.5]]] + ], + "canonical": ["text/html, text/plain;q=0.5"] + }, + { + "name": "extra whitespace parameterised list", + "raw": ["text/html , text/plain; q=0.5; charset=utf-8"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, + [["q", 0.5], ["charset", {"__type": "token", "value": "utf-8"} + ]]] + ], + "canonical": ["text/html, text/plain;q=0.5;charset=utf-8"] + }, + { + "name": "two lines parameterised list", + "raw": ["text/html", "text/plain;q=0.5"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, [["q", 0.5]]] + ], + "canonical": ["text/html, text/plain;q=0.5"] + }, + { + "name": "trailing comma parameterised list", + "raw": ["text/html,text/plain;q=0.5,"], + "header_type": "list", + "must_fail": true + }, + { + "name": "empty item parameterised list", + "raw": ["text/html,,text/plain;q=0.5,"], + "header_type": "list", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/param-listlist.json b/http/testdata/structured_fields/param-listlist.json new file mode 100644 index 000000000000..937fa4a1b2d8 --- /dev/null +++ b/http/testdata/structured_fields/param-listlist.json @@ -0,0 +1,36 @@ +[ + { + "name": "parameterised inner list", + "raw": ["(abc_123);a=1;b=2, cdef_456"], + "header_type": "list", + "expected": [ + [ + [[{"__type": "token", "value": "abc_123"}, []]], + [["a", 1], ["b", 2]] + ], + [{"__type": "token", "value": "cdef_456"}, []] + ] + }, + { + "name": "parameterised inner list item", + "raw": ["(abc_123;a=1;b=2;cdef_456)"], + "header_type": "list", + "expected": [ + [ + [[{"__type": "token", "value": "abc_123"}, [["a", 1], ["b", 2], ["cdef_456", true]]]], + [] + ] + ] + }, + { + "name": "parameterised inner list with parameterised item", + "raw": ["(abc_123;a=1;b=2);cdef_456"], + "header_type": "list", + "expected": [ + [ + [[{"__type": "token", "value": "abc_123"}, [["a", 1], ["b", 2]]]], + [["cdef_456", true]] + ] + ] + } +] diff --git a/http/testdata/structured_fields/string.json b/http/testdata/structured_fields/string.json new file mode 100644 index 000000000000..e768ed43a535 --- /dev/null +++ b/http/testdata/structured_fields/string.json @@ -0,0 +1,87 @@ +[ + { + "name": "basic string", + "raw": ["\"foo bar\""], + "header_type": "item", + "expected": ["foo bar", []] + }, + { + "name": "empty string", + "raw": ["\"\""], + "header_type": "item", + "expected": ["", []] + }, + { + "name": "long string", + "raw": ["\"foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo \""], + "header_type": "item", + "expected": ["foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo ", []] + }, + { + "name": "whitespace string", + "raw": ["\" \""], + "header_type": "item", + "expected": [" ", []] + }, + { + "name": "non-ascii string", + "raw": ["\"füü\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "tab in string", + "raw": ["\"\t\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "newline in string", + "raw": ["\" \n \""], + "header_type": "item", + "must_fail": true + }, + { + "name": "single quoted string", + "raw": ["'foo'"], + "header_type": "item", + "must_fail": true + }, + { + "name": "unbalanced string", + "raw": ["\"foo"], + "header_type": "item", + "must_fail": true + }, + { + "name": "string quoting", + "raw": ["\"foo \\\"bar\\\" \\\\ baz\""], + "header_type": "item", + "expected": ["foo \"bar\" \\ baz", []] + }, + { + "name": "bad string quoting", + "raw": ["\"foo \\,\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "ending string quote", + "raw": ["\"foo \\\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "abruptly ending string quote", + "raw": ["\"foo \\"], + "header_type": "item", + "must_fail": true + }, + { + "name": "two lines string", + "raw": ["\"foo", "bar\""], + "header_type": "item", + "can_fail": true, + "expected": ["foo, bar", []] + } +] diff --git a/http/testdata/structured_fields/token.json b/http/testdata/structured_fields/token.json new file mode 100644 index 000000000000..001327ba6728 --- /dev/null +++ b/http/testdata/structured_fields/token.json @@ -0,0 +1,38 @@ +[ + { + "name": "basic token - item", + "raw": ["a_b-c.d3:f%00/*"], + "header_type": "item", + "expected": [{"__type": "token", "value": "a_b-c.d3:f%00/*"}, []] + }, + { + "name": "token with capitals - item", + "raw": ["fooBar"], + "header_type": "item", + "expected": [{"__type": "token", "value": "fooBar"}, []] + }, + { + "name": "token starting with capitals - item", + "raw": ["FooBar"], + "header_type": "item", + "expected": [{"__type": "token", "value": "FooBar"}, []] + }, + { + "name": "basic token - list", + "raw": ["a_b-c3/*"], + "header_type": "list", + "expected": [[{"__type": "token", "value": "a_b-c3/*"}, []]] + }, + { + "name": "token with capitals - list", + "raw": ["fooBar"], + "header_type": "list", + "expected": [[{"__type": "token", "value": "fooBar"}, []]] + }, + { + "name": "token starting with capitals - list", + "raw": ["FooBar"], + "header_type": "list", + "expected": [[{"__type": "token", "value": "FooBar"}, []]] + } +] diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts new file mode 100644 index 000000000000..8b7bde78c4fb --- /dev/null +++ b/http/unstable_structured_fields.ts @@ -0,0 +1,1432 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * Utilities for parsing and serializing + * {@link https://www.rfc-editor.org/rfc/rfc9651 | RFC 9651} Structured Field Values for HTTP. + * + * Structured Fields provide a standardized way to define HTTP header and trailer + * field values using common data types (Lists, Dictionaries, Items) with strict + * parsing and serialization rules. + * + * @example Parsing a Dictionary (e.g., UCP-Agent header) + * ```ts + * import { isItem, parseDictionary } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const header = 'profile="https://example.com/profile.json"'; + * const dict = parseDictionary(header); + * const profile = dict.get("profile"); + * + * if (profile && isItem(profile)) { + * assertEquals(profile.value, { + * type: "string", + * value: "https://example.com/profile.json", + * }); + * } + * ``` + * + * @example Serializing a Dictionary + * ```ts + * import { item, serializeDictionary, string } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const dict = new Map([ + * ["profile", item(string("https://example.com/profile.json"))], + * ]); + * + * assertEquals( + * serializeDictionary(dict), + * 'profile="https://example.com/profile.json"' + * ); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @module + */ + +import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; + +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); +const UTF8_ENCODER = new TextEncoder(); + +// ============================================================================= +// Type Definitions (RFC 9651 Section 3) +// ============================================================================= + +/** + * A Bare Item value in a Structured Field. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.3} + */ +export type BareItem = + | { type: "integer"; value: number } + | { type: "decimal"; value: number } + | { type: "string"; value: string } + | { type: "token"; value: string } + | { type: "binary"; value: Uint8Array } + | { type: "boolean"; value: boolean } + | { type: "date"; value: Date } + | { type: "displaystring"; value: string }; + +/** + * Parameters attached to an Item or Inner List. + * + * Returned parameters are immutable. When building parameters for serialization, + * you can pass a mutable `Map` as it is assignable to `ReadonlyMap`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1.2} + */ +export type Parameters = ReadonlyMap; + +/** + * An Item in a Structured Field, consisting of a Bare Item and Parameters. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.3} + */ +export interface Item { + /** The bare item value. */ + value: BareItem; + /** Parameters associated with this item. */ + parameters: Parameters; +} + +/** + * An Inner List in a Structured Field. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1.1} + */ +export interface InnerList { + /** The items in the inner list. */ + items: Item[]; + /** Parameters associated with the inner list. */ + parameters: Parameters; +} + +/** + * A List Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1} + */ +export type List = Array; + +/** + * A Dictionary Structured Field value. + * + * Returned dictionaries are immutable. When building dictionaries for serialization, + * you can pass a mutable `Map` as it is assignable to `ReadonlyMap`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.2} + */ +export type Dictionary = ReadonlyMap; + +// ============================================================================= +// Convenience Builders +// ============================================================================= + +/** + * Creates an integer Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The integer value. Must be in the range -999999999999999 to + * 999999999999999; validated during serialization. + * @returns A Bare Item of type integer. + * + * @example Usage + * ```ts + * import { integer } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(integer(42), { type: "integer", value: 42 }); + * ``` + */ +export function integer(value: number): Extract { + return { type: "integer", value }; +} + +/** + * Creates a decimal Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The decimal value. Must be finite with an integer part of at + * most 12 digits; validated during serialization. + * @returns A Bare Item of type decimal. + * + * @example Usage + * ```ts + * import { decimal } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(decimal(3.14), { type: "decimal", value: 3.14 }); + * ``` + */ +export function decimal(value: number): Extract { + return { type: "decimal", value }; +} + +/** + * Creates a string Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The string value. Must contain only ASCII printable characters + * (0x20-0x7E); validated during serialization. + * @returns A Bare Item of type string. + * + * @example Usage + * ```ts + * import { string } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(string("hello"), { type: "string", value: "hello" }); + * ``` + */ +export function string(value: string): Extract { + return { type: "string", value }; +} + +/** + * Creates a token Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The token value. Must start with ALPHA or '*' and contain only + * token characters; validated during serialization. + * @returns A Bare Item of type token. + * + * @example Usage + * ```ts + * import { token } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(token("foo"), { type: "token", value: "foo" }); + * ``` + */ +export function token(value: string): Extract { + return { type: "token", value }; +} + +/** + * Creates a binary Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The binary value as a Uint8Array. + * @returns A Bare Item of type binary. + * + * @example Usage + * ```ts + * import { binary } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const result = binary(new Uint8Array([1, 2, 3])); + * assertEquals(result.type, "binary"); + * ``` + */ +export function binary( + value: Uint8Array, +): Extract { + return { type: "binary", value }; +} + +/** + * Creates a boolean Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The boolean value. + * @returns A Bare Item of type boolean. + * + * @example Usage + * ```ts + * import { boolean } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(boolean(true), { type: "boolean", value: true }); + * ``` + */ +export function boolean( + value: boolean, +): Extract { + return { type: "boolean", value }; +} + +/** + * Creates a date Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The date value. Must represent a valid date whose Unix timestamp + * is in the integer range; validated during serialization. + * @returns A Bare Item of type date. + * + * @example Usage + * ```ts + * import { date } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const d = new Date("2022-08-04T00:00:00Z"); + * assertEquals(date(d), { type: "date", value: d }); + * ``` + */ +export function date(value: Date): Extract { + return { type: "date", value }; +} + +/** + * Creates a display string Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The display string value (can contain Unicode). + * @returns A Bare Item of type displaystring. + * + * @example Usage + * ```ts + * import { displayString } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(displayString("héllo"), { type: "displaystring", value: "héllo" }); + * ``` + */ +export function displayString( + value: string, +): Extract { + return { type: "displaystring", value }; +} + +/** + * Creates an Item from a Bare Item and optional Parameters. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The Bare Item value. + * @param parameters Optional parameters as a `Map` or iterable of key-value pairs. + * @returns An Item with the given value and parameters. + * + * @example Usage + * ```ts + * import { item, integer, token } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(item(integer(42)), { + * value: { type: "integer", value: 42 }, + * parameters: new Map(), + * }); + * + * assertEquals(item(integer(42), [["q", token("fast")]]), { + * value: { type: "integer", value: 42 }, + * parameters: new Map([["q", token("fast")]]), + * }); + * ``` + */ +export function item( + value: BareItem, + parameters?: Iterable<[string, BareItem]>, +): Item { + return { value, parameters: new Map(parameters) }; +} + +/** + * Creates an Inner List from Items and optional Parameters. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param items The items in the inner list. + * @param parameters Optional parameters as a `Map` or iterable of key-value pairs. + * @returns An InnerList with the given items and parameters. + * + * @example Usage + * ```ts + * import { innerList, item, integer } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const list = innerList([item(integer(1)), item(integer(2))]); + * + * assertEquals(list.items.length, 2); + * assertEquals(list.parameters.size, 0); + * ``` + */ +export function innerList( + items: Item[], + parameters?: Iterable<[string, BareItem]>, +): InnerList { + return { items, parameters: new Map(parameters) }; +} + +// ============================================================================= +// Type Guards +// ============================================================================= + +/** + * Checks if a list member is an Item (not an Inner List). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param member The list member to check. + * @returns `true` if the member is an Item, `false` if it's an Inner List. + * + * @example Usage + * ```ts + * import { parseList, isItem } from "@std/http/unstable-structured-fields"; + * import { assert } from "@std/assert"; + * + * const list = parseList("1, (2 3)"); + * assert(isItem(list[0]!)); // true - integer item + * assert(!isItem(list[1]!)); // false - inner list + * ``` + */ +export function isItem(member: Item | InnerList): member is Item { + return !("items" in member); +} + +/** + * Checks if a list member is an Inner List. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param member The list member to check. + * @returns `true` if the member is an Inner List, `false` if it's an Item. + * + * @example Usage + * ```ts + * import { parseList, isInnerList } from "@std/http/unstable-structured-fields"; + * import { assert } from "@std/assert"; + * + * const list = parseList("1, (2 3)"); + * assert(!isInnerList(list[0]!)); // false - integer item + * assert(isInnerList(list[1]!)); // true - inner list + * ``` + */ +export function isInnerList( + member: Item | InnerList, +): member is InnerList { + return "items" in member; +} + +// ============================================================================= +// Parsing (RFC 9651 Section 4.2) +// ============================================================================= + +/** Parser state holding input string and current position. */ +interface ParserState { + input: string; + pos: number; +} + +// Character code constants for ASCII ranges +const CHAR_CODE_0 = 48; // '0' +const CHAR_CODE_9 = 57; // '9' +const CHAR_CODE_UPPER_A = 65; // 'A' +const CHAR_CODE_UPPER_Z = 90; // 'Z' +const CHAR_CODE_LOWER_A = 97; // 'a' +const CHAR_CODE_LOWER_Z = 122; // 'z' + +// RFC 9651 numeric limits +const MAX_INTEGER_DIGITS = 15; +const MAX_INTEGER = 999_999_999_999_999; +const MAX_DECIMAL_INTEGER_DIGITS = 12; +const MAX_DECIMAL_FRACTIONAL_DIGITS = 3; +const MAX_DECIMAL_INTEGER_PART = 999_999_999_999; + +/** Check if character is alphabetic (A-Z or a-z) */ +function isAlpha(c: string): boolean { + const code = c.charCodeAt(0); + return (code >= CHAR_CODE_UPPER_A && code <= CHAR_CODE_UPPER_Z) || + (code >= CHAR_CODE_LOWER_A && code <= CHAR_CODE_LOWER_Z); +} + +/** Check if character is a digit (0-9) */ +function isDigit(c: string): boolean { + const code = c.charCodeAt(0); + return code >= CHAR_CODE_0 && code <= CHAR_CODE_9; +} + +/** Check if character is lowercase alphabetic (a-z) */ +function isLcalpha(c: string): boolean { + const code = c.charCodeAt(0); + return code >= CHAR_CODE_LOWER_A && code <= CHAR_CODE_LOWER_Z; +} + +/** Check if character is valid at the start of a key (lcalpha or "*") */ +function isKeyStart(c: string): boolean { + return isLcalpha(c) || c === "*"; +} + +/** Check if character is valid within a key (lcalpha / DIGIT / "_" / "-" / "." / "*") */ +function isKeyChar(c: string): boolean { + return isLcalpha(c) || isDigit(c) || c === "_" || c === "-" || c === "." || + c === "*"; +} + +// Pre-computed lookup table for tchar (RFC 9110 token characters) +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / +// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +const TCHAR_LOOKUP: boolean[] = (() => { + const table: boolean[] = new Array(128).fill(false); + const tchars = "!#$%&'*+-.^_`|~0123456789" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + for (const c of tchars) { + table[c.charCodeAt(0)] = true; + } + return table; +})(); + +// Pre-computed lookup table for base64 characters (A-Z, a-z, 0-9, +, /, =) +const BASE64_LOOKUP: boolean[] = (() => { + const table: boolean[] = new Array(128).fill(false); + for (let i = CHAR_CODE_UPPER_A; i <= CHAR_CODE_UPPER_Z; i++) table[i] = true; + for (let i = CHAR_CODE_LOWER_A; i <= CHAR_CODE_LOWER_Z; i++) table[i] = true; + for (let i = CHAR_CODE_0; i <= CHAR_CODE_9; i++) table[i] = true; + table[43] = true; // + + table[47] = true; // / + table[61] = true; // = + return table; +})(); + +/** Check if character is a valid base64 character */ +function isBase64Char(c: string): boolean { + const code = c.charCodeAt(0); + return code < 128 && BASE64_LOOKUP[code]!; +} + +/** Check if character is a lowercase hex digit (0-9, a-f) */ +function isLcHexDigit(c: string): boolean { + const code = c.charCodeAt(0); + return (code >= CHAR_CODE_0 && code <= CHAR_CODE_9) || + (code >= CHAR_CODE_LOWER_A && code <= 102); // 'f' = 102 +} + +/** RFC 9110 tchar: token characters */ +function isTchar(c: string): boolean { + const code = c.charCodeAt(0); + return code < 128 && TCHAR_LOOKUP[code]!; +} + +/** Check if at end of input */ +function isEof(state: ParserState): boolean { + return state.pos >= state.input.length; +} + +/** Peek current character */ +function peek(state: ParserState): string { + return state.input[state.pos] ?? ""; +} + +/** Consume current character and advance */ +function consume(state: ParserState): string { + return state.input[state.pos++] ?? ""; +} + +/** Skip optional whitespace (SP or HTAB) */ +function skipOWS(state: ParserState): void { + while (!isEof(state) && (peek(state) === " " || peek(state) === "\t")) { + state.pos++; + } +} + +/** Skip required whitespace (at least one SP) */ +function skipSP(state: ParserState): void { + while (!isEof(state) && peek(state) === " ") { + state.pos++; + } +} + +/** + * Parses a List Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param input The string to parse. + * @returns The parsed List. + * @throws {SyntaxError} If the input is invalid. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.2.1} + * + * @example Usage + * ```ts + * import { parseList } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const list = parseList("1, 42"); + * assertEquals(list.length, 2); + * ``` + */ +export function parseList(input: string): List { + const state: ParserState = { input, pos: 0 }; + skipSP(state); + const list = parseListInternal(state); + skipSP(state); + if (!isEof(state)) { + throw new SyntaxError( + `Invalid structured field: unexpected character at position ${state.pos}`, + ); + } + return list; +} + +function parseListInternal(state: ParserState): List { + const members: List = []; + + while (!isEof(state)) { + const member = parseItemOrInnerList(state); + members.push(member); + skipOWS(state); + if (isEof(state)) { + return members; + } + if (peek(state) !== ",") { + throw new SyntaxError( + `Invalid structured field: expected ',' at position ${state.pos}`, + ); + } + consume(state); // consume ',' + skipOWS(state); + if (isEof(state)) { + throw new SyntaxError( + "Invalid structured field: trailing comma in list", + ); + } + } + + return members; +} + +/** + * Parses a Dictionary Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param input The string to parse. + * @returns The parsed Dictionary. + * @throws {SyntaxError} If the input is invalid. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.2.2} + * + * @example Usage + * ```ts + * import { parseDictionary } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const dict = parseDictionary('profile="https://example.com"'); + * assertEquals(dict.has("profile"), true); + * ``` + */ +export function parseDictionary(input: string): Dictionary { + const state: ParserState = { input, pos: 0 }; + skipSP(state); + const dict = parseDictionaryInternal(state); + skipSP(state); + if (!isEof(state)) { + throw new SyntaxError( + `Invalid structured field: unexpected character at position ${state.pos}`, + ); + } + return dict; +} + +function parseDictionaryInternal(state: ParserState): Dictionary { + const dict: Map = new Map(); + + while (!isEof(state)) { + const key = parseKey(state); + let member: Item | InnerList; + + if (peek(state) === "=") { + consume(state); // consume '=' + member = parseItemOrInnerList(state); + } else { + // Bare key means boolean true with parameters + const parameters = parseParameters(state); + member = { + value: { type: "boolean", value: true }, + parameters, + }; + } + + dict.set(key, member); + skipOWS(state); + if (isEof(state)) { + return dict; + } + if (peek(state) !== ",") { + throw new SyntaxError( + `Invalid structured field: expected ',' at position ${state.pos}`, + ); + } + consume(state); // consume ',' + skipOWS(state); + if (isEof(state)) { + throw new SyntaxError( + "Invalid structured field: trailing comma in dictionary", + ); + } + } + + return dict; +} + +/** + * Parses an Item Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param input The string to parse. + * @returns The parsed Item. + * @throws {SyntaxError} If the input is invalid. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.2.3} + * + * @example Usage + * ```ts + * import { parseItem } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const item = parseItem("42"); + * assertEquals(item.value, { type: "integer", value: 42 }); + * ``` + */ +export function parseItem(input: string): Item { + const state: ParserState = { input, pos: 0 }; + skipSP(state); + const item = parseItemInternal(state); + skipSP(state); + if (!isEof(state)) { + throw new SyntaxError( + `Invalid structured field: unexpected character at position ${state.pos}`, + ); + } + return item; +} + +function parseItemOrInnerList(state: ParserState): Item | InnerList { + if (peek(state) === "(") { + return parseInnerList(state); + } + return parseItemInternal(state); +} + +function parseInnerList(state: ParserState): InnerList { + if (consume(state) !== "(") { + throw new SyntaxError( + `Invalid structured field: expected '(' at position ${state.pos - 1}`, + ); + } + + const items: Item[] = []; + + while (!isEof(state)) { + skipSP(state); + if (peek(state) === ")") { + consume(state); + const parameters = parseParameters(state); + return { items, parameters }; + } + const item = parseItemInternal(state); + items.push(item); + + const c = peek(state); + if (c !== " " && c !== ")") { + throw new SyntaxError( + `Invalid structured field: expected SP or ')' at position ${state.pos}`, + ); + } + } + + throw new SyntaxError( + "Invalid structured field: unterminated inner list", + ); +} + +function parseItemInternal(state: ParserState): Item { + const value = parseBareItem(state); + const parameters = parseParameters(state); + return { value, parameters }; +} + +function parseBareItem(state: ParserState): BareItem { + const c = peek(state); + + if (c === "-" || isDigit(c)) { + return parseIntegerOrDecimal(state); + } + if (c === '"') { + return parseString(state); + } + if (c === ":") { + return parseBinary(state); + } + if (c === "?") { + return parseBoolean(state); + } + if (c === "@") { + return parseDate(state); + } + if (c === "%") { + return parseDisplayString(state); + } + if (isAlpha(c) || c === "*") { + return parseToken(state); + } + + throw new SyntaxError( + `Invalid structured field: unexpected character '${c}' at position ${state.pos}`, + ); +} + +function parseIntegerOrDecimal(state: ParserState): BareItem { + const { input } = state; + let sign = 1; + if (peek(state) === "-") { + state.pos++; + sign = -1; + } + + if (!isDigit(peek(state))) { + throw new SyntaxError( + `Invalid structured field: expected digit at position ${state.pos}`, + ); + } + + const intStart = state.pos; + while (isDigit(peek(state))) { + state.pos++; + } + const intLen = state.pos - intStart; + if (intLen > MAX_INTEGER_DIGITS) { + throw new SyntaxError( + "Invalid structured field: integer too long", + ); + } + + if (peek(state) === ".") { + state.pos++; // consume '.' + if (intLen > MAX_DECIMAL_INTEGER_DIGITS) { + throw new SyntaxError( + "Invalid structured field: decimal integer part too long", + ); + } + + const fracStart = state.pos; + while (isDigit(peek(state))) { + state.pos++; + } + const fracLen = state.pos - fracStart; + if (fracLen > MAX_DECIMAL_FRACTIONAL_DIGITS) { + throw new SyntaxError( + "Invalid structured field: decimal fractional part too long", + ); + } + + if (fracLen === 0) { + throw new SyntaxError( + "Invalid structured field: decimal requires fractional digits", + ); + } + + const value = sign * parseFloat(input.slice(intStart, state.pos)); + return { type: "decimal", value }; + } + + const value = sign * parseInt(input.slice(intStart, state.pos), 10); + + if (value < -MAX_INTEGER || value > MAX_INTEGER) { + throw new SyntaxError( + "Invalid structured field: integer out of range", + ); + } + + return { type: "integer", value }; +} + +function parseString(state: ParserState): BareItem { + if (consume(state) !== '"') { + throw new SyntaxError( + `Invalid structured field: expected '"' at position ${state.pos - 1}`, + ); + } + + const { input } = state; + const startPos = state.pos; + + // Fast path: find first special character (\ or ") + let firstSpecial = startPos; + while (firstSpecial < input.length) { + const c = input[firstSpecial]!; + if (c === '"' || c === "\\") break; + // Validate printable ASCII + const code = c.charCodeAt(0); + if (code < 0x20 || code > 0x7e) { + throw new SyntaxError( + `Invalid structured field: invalid character in string at position ${firstSpecial}`, + ); + } + firstSpecial++; + } + + // If we hit end of string without finding closing quote + if (firstSpecial >= input.length) { + throw new SyntaxError("Invalid structured field: unterminated string"); + } + + // Fast path: no escapes, just a closing quote + if (input[firstSpecial] === '"') { + state.pos = firstSpecial + 1; + return { type: "string", value: input.slice(startPos, firstSpecial) }; + } + + // Slow path: has escapes, need to process character by character + let value = input.slice(startPos, firstSpecial); + state.pos = firstSpecial; + + while (!isEof(state)) { + const c = consume(state); + + if (c === "\\") { + const escaped = consume(state); + if (escaped !== '"' && escaped !== "\\") { + throw new SyntaxError( + `Invalid structured field: invalid escape sequence at position ${ + state.pos - 1 + }`, + ); + } + value += escaped; + } else if (c === '"') { + return { type: "string", value }; + } else { + // Must be printable ASCII (0x20-0x7E) excluding 0x22 (") and 0x5C (\) + const code = c.charCodeAt(0); + if (code < 0x20 || code > 0x7e) { + throw new SyntaxError( + `Invalid structured field: invalid character in string at position ${ + state.pos - 1 + }`, + ); + } + value += c; + } + } + + throw new SyntaxError( + "Invalid structured field: unterminated string", + ); +} + +function parseToken(state: ParserState): BareItem { + const first = peek(state); + if (!isAlpha(first) && first !== "*") { + throw new SyntaxError( + `Invalid structured field: invalid token start at position ${state.pos}`, + ); + } + + const startPos = state.pos; + state.pos++; // Skip validated first char + while (!isEof(state)) { + const c = peek(state); + if (isTchar(c) || c === ":" || c === "/") { + state.pos++; + } else { + break; + } + } + + return { type: "token", value: state.input.slice(startPos, state.pos) }; +} + +function parseBinary(state: ParserState): BareItem { + if (consume(state) !== ":") { + throw new SyntaxError( + `Invalid structured field: expected ':' at position ${state.pos - 1}`, + ); + } + + const { input } = state; + const startPos = state.pos; + + // Find the closing colon while validating base64 characters + while (state.pos < input.length && input[state.pos] !== ":") { + if (!isBase64Char(input[state.pos]!)) { + throw new SyntaxError( + `Invalid structured field: invalid base64 character at position ${state.pos}`, + ); + } + state.pos++; + } + + if (state.pos >= input.length) { + throw new SyntaxError( + "Invalid structured field: unterminated binary", + ); + } + + const base64 = input.slice(startPos, state.pos); + state.pos++; // consume closing ':' + + try { + const value = decodeBase64(base64); + return { type: "binary", value }; + } catch { + throw new SyntaxError( + "Invalid structured field: invalid base64 encoding", + ); + } +} + +function parseBoolean(state: ParserState): BareItem { + if (consume(state) !== "?") { + throw new SyntaxError( + `Invalid structured field: expected '?' at position ${state.pos - 1}`, + ); + } + + const c = consume(state); + if (c === "1") { + return { type: "boolean", value: true }; + } + if (c === "0") { + return { type: "boolean", value: false }; + } + + throw new SyntaxError( + `Invalid structured field: expected '0' or '1' at position ${ + state.pos - 1 + }`, + ); +} + +function parseDate(state: ParserState): BareItem { + if (consume(state) !== "@") { + throw new SyntaxError( + `Invalid structured field: expected '@' at position ${state.pos - 1}`, + ); + } + + const intItem = parseIntegerOrDecimal(state); + if (intItem.type !== "integer") { + throw new SyntaxError( + "Invalid structured field: date must be an integer", + ); + } + + const value = new Date(intItem.value * 1000); + return { type: "date", value }; +} + +function parseDisplayString(state: ParserState): BareItem { + if (consume(state) !== "%") { + throw new SyntaxError( + `Invalid structured field: expected '%' at position ${state.pos - 1}`, + ); + } + if (consume(state) !== '"') { + throw new SyntaxError( + `Invalid structured field: expected '"' at position ${state.pos - 1}`, + ); + } + + const bytes: number[] = []; + while (!isEof(state)) { + const c = consume(state); + + if (c === '"') { + // Decode UTF-8 bytes to string + try { + const value = UTF8_DECODER.decode(new Uint8Array(bytes)); + return { type: "displaystring", value }; + } catch { + throw new SyntaxError( + "Invalid structured field: invalid UTF-8 in display string", + ); + } + } else if (c === "%") { + // Percent-encoded byte + const hex1 = consume(state); + const hex2 = consume(state); + if (!isLcHexDigit(hex1) || !isLcHexDigit(hex2)) { + throw new SyntaxError( + `Invalid structured field: invalid percent encoding at position ${ + state.pos - 2 + }`, + ); + } + bytes.push(parseInt(hex1 + hex2, 16)); + } else { + // Must be allowed unescaped character per RFC 9651: + // unescaped = %x20-21 / %x23-24 / %x26-5B / %x5D-7E + // (space, !, #, $, &-[, ]-~) + // Note: " (0x22) and % (0x25) must be percent-encoded + // Note: Per conformance tests, \ (0x5C) is also allowed + const code = c.charCodeAt(0); + const isAllowed = code === 0x20 || code === 0x21 || // space, ! + code === 0x23 || code === 0x24 || // #, $ + (code >= 0x26 && code <= 0x5b) || // &-[ + (code >= 0x5c && code <= 0x7e); // \-~ (includes \ per conformance tests) + if (!isAllowed) { + throw new SyntaxError( + `Invalid structured field: invalid character in display string at position ${ + state.pos - 1 + }`, + ); + } + bytes.push(code); + } + } + + throw new SyntaxError( + "Invalid structured field: unterminated display string", + ); +} + +function parseKey(state: ParserState): string { + if (!isKeyStart(peek(state))) { + throw new SyntaxError( + `Invalid structured field: invalid key start at position ${state.pos}`, + ); + } + + const startPos = state.pos; + state.pos++; // Skip validated first char + while (!isEof(state) && isKeyChar(peek(state))) { + state.pos++; + } + + return state.input.slice(startPos, state.pos); +} + +function parseParameters(state: ParserState): Parameters { + const parameters: Map = new Map(); + + while (peek(state) === ";") { + consume(state); // consume ';' + skipSP(state); + const key = parseKey(state); + + let value: BareItem; + if (peek(state) === "=") { + consume(state); // consume '=' + value = parseBareItem(state); + } else { + value = { type: "boolean", value: true }; + } + + parameters.set(key, value); + } + + return parameters; +} + +// ============================================================================= +// Serialization (RFC 9651 Section 4.1) +// ============================================================================= + +/** + * Serializes a List to a string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param list The List to serialize. + * @returns The serialized string. + * @throws {TypeError} If the list contains invalid values. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.1.1} + * + * @example Usage + * ```ts + * import { item, serializeList, integer } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const list = [item(integer(1)), item(integer(42))]; + * + * assertEquals(serializeList(list), "1, 42"); + * ``` + */ +export function serializeList(list: List): string { + const parts: string[] = []; + + for (const member of list) { + if ("items" in member) { + parts.push(serializeInnerList(member)); + } else { + parts.push(serializeItemInternal(member)); + } + } + + return parts.join(", "); +} + +/** + * Serializes a Dictionary to a string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param dict The Dictionary to serialize. + * @returns The serialized string. + * @throws {TypeError} If the dictionary contains invalid values. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.1.2} + * + * @example Usage + * ```ts + * import { item, serializeDictionary, string } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const dict = new Map([ + * ["key", item(string("value"))], + * ]); + * + * assertEquals(serializeDictionary(dict), 'key="value"'); + * ``` + */ +export function serializeDictionary(dict: Dictionary): string { + const parts: string[] = []; + + for (const [key, member] of dict) { + validateKey(key); + + if ("items" in member) { + // Inner list + parts.push(`${key}=${serializeInnerList(member)}`); + } else { + // Item + if (member.value.type === "boolean" && member.value.value === true) { + // Omit =?1 for true boolean + parts.push(key + serializeParameters(member.parameters)); + } else { + parts.push(`${key}=${serializeItemInternal(member)}`); + } + } + } + + return parts.join(", "); +} + +/** + * Serializes an Item to a string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The Item to serialize. + * @returns The serialized string. + * @throws {TypeError} If the item contains invalid values. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.1.3} + * + * @example Usage + * ```ts + * import { item, serializeItem, integer } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(serializeItem(item(integer(42))), "42"); + * ``` + */ +export function serializeItem(value: Item): string { + return serializeItemInternal(value); +} + +function serializeInnerList(innerList: InnerList): string { + const items = innerList.items.map(serializeItemInternal).join(" "); + return `(${items})${serializeParameters(innerList.parameters)}`; +} + +function serializeItemInternal(item: Item): string { + return serializeBareItem(item.value) + serializeParameters(item.parameters); +} + +function serializeBareItem(bareItem: BareItem): string { + switch (bareItem.type) { + case "integer": + return serializeInteger(bareItem.value); + case "decimal": + return serializeDecimal(bareItem.value); + case "string": + return serializeString(bareItem.value); + case "token": + return serializeToken(bareItem.value); + case "binary": + return serializeBinary(bareItem.value); + case "boolean": + return serializeBoolean(bareItem.value); + case "date": + return serializeDate(bareItem.value); + case "displaystring": + return serializeDisplayString(bareItem.value); + } +} + +function serializeInteger(value: number): string { + if (!Number.isInteger(value)) { + throw new TypeError("Integer must be a whole number"); + } + if (value < -MAX_INTEGER || value > MAX_INTEGER) { + throw new TypeError("Integer out of range"); + } + return String(value); +} + +function serializeDecimal(value: number): string { + if (!Number.isFinite(value)) { + throw new TypeError("Decimal must be finite"); + } + + // Round to MAX_DECIMAL_FRACTIONAL_DIGITS decimal places + const scale = 10 ** MAX_DECIMAL_FRACTIONAL_DIGITS; + const rounded = Math.round(value * scale) / scale; + + // Check integer part (max MAX_DECIMAL_INTEGER_DIGITS digits) + const intPart = Math.trunc(Math.abs(rounded)); + if (intPart > MAX_DECIMAL_INTEGER_PART) { + throw new TypeError("Decimal integer part too large"); + } + + // Format with MAX_DECIMAL_FRACTIONAL_DIGITS fractional digits + const str = rounded.toFixed(MAX_DECIMAL_FRACTIONAL_DIGITS); + + // Remove trailing zeros but keep at least one digit after decimal + let end = str.length; + while (end > 0 && str[end - 1] === "0") { + end--; + } + // Keep at least one digit after decimal point + const dotIndex = str.indexOf("."); + if (end <= dotIndex + 1) { + end = dotIndex + 2; + } + + return str.slice(0, end); +} + +function serializeString(value: string): string { + // Validate ASCII printable and check if escaping needed + let needsEscape = false; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + throw new TypeError(`Invalid character in string at position ${i}`); + } + if (code === 0x22 || code === 0x5c) { // " or \ + needsEscape = true; + } + } + + // Fast path: no escaping needed + if (!needsEscape) { + return `"${value}"`; + } + + // Slow path: escape \ and " + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +function serializeToken(value: string): string { + if (value.length === 0) { + throw new TypeError("Token cannot be empty"); + } + + const first = value[0]!; + if (!isAlpha(first) && first !== "*") { + throw new TypeError("Token must start with ALPHA or '*'"); + } + + for (let i = 1; i < value.length; i++) { + const c = value[i]!; + if (!isTchar(c) && c !== ":" && c !== "/") { + throw new TypeError(`Invalid character in token at position ${i}`); + } + } + + return value; +} + +function serializeBinary(value: Uint8Array): string { + return `:${encodeBase64(value)}:`; +} + +function serializeBoolean(value: boolean): string { + return value ? "?1" : "?0"; +} + +function serializeDate(value: Date): string { + const timestamp = Math.floor(value.getTime() / 1000); + if (!Number.isFinite(timestamp)) { + throw new TypeError("Invalid date"); + } + if (timestamp < -MAX_INTEGER || timestamp > MAX_INTEGER) { + throw new TypeError("Date out of range"); + } + return `@${timestamp}`; +} + +function serializeDisplayString(value: string): string { + const bytes = UTF8_ENCODER.encode(value); + + let result = '%"'; + for (const byte of bytes) { + if (byte === 0x25) { + // % -> %25 + result += "%25"; + } else if (byte === 0x22) { + // " -> %22 + result += "%22"; + } else if (byte >= 0x20 && byte <= 0x7e) { + // Printable ASCII + result += String.fromCharCode(byte); + } else { + // Percent-encode non-ASCII + result += "%" + byte.toString(16).padStart(2, "0"); + } + } + result += '"'; + + return result; +} + +function serializeParameters(parameters: Parameters): string { + let result = ""; + + for (const [key, value] of parameters) { + validateKey(key); + result += ";"; + result += key; + + if (value.type !== "boolean" || value.value !== true) { + result += "="; + result += serializeBareItem(value); + } + } + + return result; +} + +function validateKey(key: string): void { + if (key.length === 0) { + throw new TypeError("Key cannot be empty"); + } + + if (!isKeyStart(key[0]!)) { + throw new TypeError("Key must start with lowercase letter or '*'"); + } + + for (let i = 1; i < key.length; i++) { + if (!isKeyChar(key[i]!)) { + throw new TypeError(`Invalid character in key at position ${i}`); + } + } +} diff --git a/http/unstable_structured_fields_test.ts b/http/unstable_structured_fields_test.ts new file mode 100644 index 000000000000..0ccb31e5a5d8 --- /dev/null +++ b/http/unstable_structured_fields_test.ts @@ -0,0 +1,1119 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals, assertThrows } from "@std/assert"; +import { + type BareItem, + binary, + boolean, + date, + decimal, + type Dictionary, + displayString, + type InnerList, + innerList, + integer, + isInnerList, + isItem, + type Item, + item, + type List, + parseDictionary, + parseItem, + parseList, + serializeDictionary, + serializeItem, + serializeList, + string, + token, +} from "./unstable_structured_fields.ts"; + +// ============================================================================= +// Parsing Tests (edge cases NOT covered by conformance tests) +// Note: Basic parsing is covered by HTTPWG conformance tests below. +// These tests verify specific error messages and edge cases. +// ============================================================================= + +Deno.test({ + name: "parseList() rejects trailing garbage", + fn() { + assertThrows( + () => parseList("1, 2 garbage"), + SyntaxError, + "expected ','", + ); + }, +}); + +Deno.test({ + name: "parseList() rejects unterminated inner list", + fn() { + assertThrows( + () => parseList("(1 2"), + SyntaxError, + "expected SP or ')'", + ); + }, +}); + +Deno.test({ + name: "parseList() rejects invalid inner list separator", + fn() { + assertThrows( + () => parseList("(1,2)"), + SyntaxError, + "expected SP or ')'", + ); + }, +}); + +Deno.test({ + name: "parseList() parses whitespace-only as empty list", + fn() { + const list = parseList(" "); + assertEquals(list.length, 0); + }, +}); + +Deno.test({ + name: "parseList() rejects unterminated inner list at EOF", + fn() { + assertThrows( + () => parseList("("), + SyntaxError, + "unterminated inner list", + ); + }, +}); + +Deno.test({ + name: "parseDictionary() rejects trailing garbage", + fn() { + assertThrows( + () => parseDictionary("a=1 garbage"), + SyntaxError, + "expected ','", + ); + }, +}); + +Deno.test({ + name: "parseDictionary() parses whitespace-only as empty dictionary", + fn() { + const dict = parseDictionary(" "); + assertEquals(dict.size, 0); + }, +}); + +Deno.test({ + name: "parseDictionary() handles key starting with *", + fn() { + const dict = parseDictionary("*key=1"); + assertEquals(dict.has("*key"), true); + assertEquals((dict.get("*key") as Item).value, { + type: "integer", + value: 1, + }); + }, +}); + +Deno.test({ + name: "parseItem() parses token starting with *", + fn() { + const item = parseItem("*foo"); + assertEquals(item.value, { type: "token", value: "*foo" }); + }, +}); + +Deno.test({ + name: "parseItem() rejects trailing garbage", + fn() { + assertThrows( + () => parseItem("42 garbage"), + SyntaxError, + "unexpected character", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects whitespace-only input", + fn() { + assertThrows( + () => parseItem(" "), + SyntaxError, + "unexpected character", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects decimal with no fractional digits", + fn() { + assertThrows( + () => parseItem("1."), + SyntaxError, + "requires fractional digits", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects unterminated binary", + fn() { + assertThrows( + () => parseItem(":aGVsbG8="), + SyntaxError, + "unterminated binary", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects invalid char in string after escape", + fn() { + // String with escape sequence followed by invalid character (NUL) + // This tests the slow path in parseString (after processing escapes) + assertThrows( + () => parseItem('"test\\\\\x00"'), + SyntaxError, + "invalid character", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects invalid key start in parameters", + fn() { + assertThrows( + () => parseItem("foo;1invalid=1"), + SyntaxError, + "invalid key start", + ); + }, +}); + +Deno.test({ + name: "parseItem() handles token with all valid chars", + fn() { + const item = parseItem("a0_-.*/+!#$%&'^`|~:"); + assertEquals(item.value.type, "token"); + }, +}); + +Deno.test({ + name: "parseDictionary() handles key with all valid chars", + fn() { + const dict = parseDictionary("a0_-.*=1"); + assertEquals(dict.has("a0_-.*"), true); + }, +}); + +// ============================================================================= +// Serialization Tests (RFC 9651 Section 4.1) +// Note: Serialization is NOT covered by conformance tests +// ============================================================================= + +Deno.test({ + name: "serializeList() serializes basic list", + fn() { + assertEquals(serializeList([item(integer(1)), item(integer(42))]), "1, 42"); + }, +}); + +Deno.test({ + name: "serializeList() serializes empty list", + fn() { + assertEquals(serializeList([]), ""); + }, +}); + +Deno.test({ + name: "serializeList() serializes inner list", + fn() { + assertEquals( + serializeList([innerList([item(integer(1)), item(integer(2))])]), + "(1 2)", + ); + }, +}); + +Deno.test({ + name: "serializeList() serializes inner list with parameters", + fn() { + assertEquals( + serializeList([ + innerList([item(integer(1))], [["param", token("value")]]), + ]), + "(1);param=value", + ); + }, +}); + +Deno.test({ + name: "serializeDictionary() serializes basic dictionary", + fn() { + const dict: Dictionary = new Map([ + ["a", item(integer(1))], + ["b", item(integer(2))], + ]); + assertEquals(serializeDictionary(dict), "a=1, b=2"); + }, +}); + +Deno.test({ + name: "serializeDictionary() omits =?1 for true boolean", + fn() { + const dict: Dictionary = new Map([["a", item(boolean(true))]]); + assertEquals(serializeDictionary(dict), "a"); + }, +}); + +Deno.test({ + name: "serializeDictionary() includes =?0 for false boolean", + fn() { + const dict: Dictionary = new Map([["a", item(boolean(false))]]); + assertEquals(serializeDictionary(dict), "a=?0"); + }, +}); + +Deno.test({ + name: "serializeDictionary() handles key starting with *", + fn() { + const dict: Dictionary = new Map([["*key", item(integer(1))]]); + assertEquals(serializeDictionary(dict), "*key=1"); + }, +}); + +Deno.test({ + name: "serializeDictionary() serializes inner list value", + fn() { + const dict: Dictionary = new Map([ + ["a", innerList([item(integer(1)), item(integer(2))])], + ]); + assertEquals(serializeDictionary(dict), "a=(1 2)"); + }, +}); + +Deno.test({ + name: "serializeItem() covers all bare item types", + fn() { + assertEquals(serializeItem(item(token("foo"))), "foo"); + assertEquals( + serializeItem(item(binary(new Uint8Array([1, 2, 3])))), + ":AQID:", + ); + assertEquals(serializeItem(item(boolean(true))), "?1"); + assertEquals(serializeItem(item(boolean(false))), "?0"); + const d = new Date(1659578233000); + assertEquals(serializeItem(item(date(d))), "@1659578233"); + assertEquals( + serializeItem(item(displayString("héllo"))), + '%"h%c3%a9llo"', + ); + }, +}); + +Deno.test({ + name: "serializeItem() escapes quotes and backslash in string", + fn() { + assertEquals( + serializeItem(item(string('hello "world"'))), + '"hello \\"world\\""', + ); + assertEquals( + serializeItem(item(string("hello\\world"))), + '"hello\\\\world"', + ); + }, +}); + +Deno.test({ + name: "serializeItem() serializes item with parameters", + fn() { + assertEquals( + serializeItem( + item(token("foo"), [["a", integer(1)], ["b", boolean(true)]]), + ), + "foo;a=1;b", + ); + }, +}); + +Deno.test({ + name: "serializeItem() handles boundary integers", + fn() { + assertEquals( + serializeItem(item(integer(-999_999_999_999_999))), + "-999999999999999", + ); + assertEquals( + serializeItem(item(integer(999_999_999_999_999))), + "999999999999999", + ); + }, +}); + +Deno.test({ + name: "serializeItem() handles decimal edge cases", + fn() { + assertEquals(serializeItem(item(decimal(1.0))), "1.0"); + assertEquals(serializeItem(item(decimal(-3.14))), "-3.14"); + }, +}); + +Deno.test({ + name: "serializeItem() handles token starting with *", + fn() { + assertEquals(serializeItem(item(token("*foo"))), "*foo"); + }, +}); + +Deno.test({ + name: 'serializeItem() encodes % and " in display string', + fn() { + assertEquals( + serializeItem(item(displayString('hello % and "'))), + '%"hello %25 and %22"', + ); + }, +}); + +// ============================================================================= +// Serialization Error Tests +// ============================================================================= + +Deno.test({ + name: "serializeItem() rejects out-of-range integer", + fn() { + assertThrows( + () => serializeItem(item(integer(1e16))), + TypeError, + "out of range", + ); + assertThrows( + () => serializeItem(item(integer(-1e16))), + TypeError, + "out of range", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-integer value for integer type", + fn() { + assertThrows( + () => serializeItem(item(integer(3.14))), + TypeError, + "must be a whole number", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-finite decimal", + fn() { + assertThrows( + () => serializeItem(item(decimal(Infinity))), + TypeError, + "must be finite", + ); + assertThrows( + () => serializeItem(item(decimal(NaN))), + TypeError, + "must be finite", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects decimal with integer part too large", + fn() { + assertThrows( + () => serializeItem(item(decimal(1_000_000_000_000.1))), + TypeError, + "integer part too large", + ); + assertThrows( + () => serializeItem(item(decimal(-1_000_000_000_000.1))), + TypeError, + "integer part too large", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects invalid date", + fn() { + assertThrows( + () => serializeItem(item(date(new Date("invalid")))), + TypeError, + "Invalid date", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-printable ASCII in string", + fn() { + assertThrows( + () => serializeItem(item(string("\x00"))), + TypeError, + "Invalid character", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-ASCII in string", + fn() { + assertThrows( + () => serializeItem(item(string("héllo"))), + TypeError, + "Invalid character", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects empty token", + fn() { + assertThrows( + () => serializeItem(item(token(""))), + TypeError, + "cannot be empty", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects token with invalid start character", + fn() { + assertThrows( + () => serializeItem(item(token("1foo"))), + TypeError, + "must start with ALPHA", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects token with invalid character", + fn() { + assertThrows( + () => serializeItem(item(token("foo bar"))), + TypeError, + "Invalid character in token", + ); + }, +}); + +Deno.test({ + name: "serializeDictionary() rejects invalid key", + fn() { + const dict: Dictionary = new Map([["INVALID", item(integer(1))]]); + assertThrows( + () => serializeDictionary(dict), + TypeError, + "must start with lowercase", + ); + }, +}); + +Deno.test({ + name: "serializeDictionary() rejects empty key", + fn() { + const dict: Dictionary = new Map([["", item(integer(1))]]); + assertThrows( + () => serializeDictionary(dict), + TypeError, + "cannot be empty", + ); + }, +}); + +Deno.test({ + name: "serializeDictionary() rejects key with invalid character", + fn() { + const dict: Dictionary = new Map([["a!b", item(integer(1))]]); + assertThrows( + () => serializeDictionary(dict), + TypeError, + "Invalid character in key", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects invalid parameter key", + fn() { + assertThrows( + () => serializeItem(item(token("foo"), [["INVALID", integer(1)]])), + TypeError, + "must start with lowercase", + ); + }, +}); + +// ============================================================================= +// Round-trip Tests +// ============================================================================= + +Deno.test({ + name: "round-trip: list with inner list", + fn() { + const input = "1, 42, (a b)"; + const parsed = parseList(input); + const serialized = serializeList(parsed); + const reparsed = parseList(serialized); + assertEquals(parsed.length, reparsed.length); + }, +}); + +Deno.test({ + name: "round-trip: dictionary with mixed values", + fn() { + const input = 'a=1, b="hello", c'; + const parsed = parseDictionary(input); + const serialized = serializeDictionary(parsed); + const reparsed = parseDictionary(serialized); + assertEquals(parsed.size, reparsed.size); + }, +}); + +Deno.test({ + name: "round-trip: item with parameters", + fn() { + const input = "foo;a=1;b"; + const parsed = parseItem(input); + const serialized = serializeItem(parsed); + const reparsed = parseItem(serialized); + assertEquals(parsed.value, reparsed.value); + assertEquals(parsed.parameters.size, reparsed.parameters.size); + }, +}); + +// ============================================================================= +// Convenience Builder Tests +// ============================================================================= + +Deno.test({ + name: "factory functions create correct bare items", + fn() { + assertEquals(integer(42), { type: "integer", value: 42 }); + assertEquals(decimal(3.14), { type: "decimal", value: 3.14 }); + assertEquals(string("hello"), { type: "string", value: "hello" }); + assertEquals(token("foo"), { type: "token", value: "foo" }); + assertEquals(binary(new Uint8Array([1, 2, 3])).type, "binary"); + assertEquals(boolean(true), { type: "boolean", value: true }); + assertEquals(boolean(false), { type: "boolean", value: false }); + const d = new Date(); + assertEquals(date(d), { type: "date", value: d }); + assertEquals(displayString("héllo"), { + type: "displaystring", + value: "héllo", + }); + }, +}); + +Deno.test({ + name: "item() creates item with empty parameters by default", + fn() { + const i = item(integer(42)); + assertEquals(i.value, { type: "integer", value: 42 }); + assertEquals(i.parameters.size, 0); + }, +}); + +Deno.test({ + name: "item() creates item with parameters from iterable", + fn() { + const i = item(token("foo"), [["a", integer(1)], ["b", boolean(true)]]); + assertEquals(i.value, { type: "token", value: "foo" }); + assertEquals(i.parameters.size, 2); + assertEquals(i.parameters.get("a"), integer(1)); + assertEquals(i.parameters.get("b"), boolean(true)); + }, +}); + +Deno.test({ + name: "item() creates item with parameters from Map", + fn() { + const params = new Map([["q", decimal(0.9)]]); + const i = item(token("text/html"), params); + assertEquals(i.parameters.get("q"), decimal(0.9)); + }, +}); + +Deno.test({ + name: "innerList() creates inner list with empty parameters by default", + fn() { + const il = innerList([item(integer(1)), item(integer(2))]); + assertEquals(il.items.length, 2); + assertEquals(il.parameters.size, 0); + }, +}); + +Deno.test({ + name: "innerList() creates inner list with parameters", + fn() { + const il = innerList( + [item(integer(1))], + [["param", token("value")]], + ); + assertEquals(il.items.length, 1); + assertEquals(il.parameters.get("param"), token("value")); + }, +}); + +// ============================================================================= +// Type Guard Tests +// ============================================================================= + +Deno.test({ + name: "isItem() distinguishes items from inner lists", + fn() { + const list = parseList("1, (2 3)"); + assertEquals(isItem(list[0]!), true); + assertEquals(isItem(list[1]!), false); + }, +}); + +Deno.test({ + name: "isInnerList() distinguishes inner lists from items", + fn() { + const list = parseList("1, (2 3)"); + assertEquals(isInnerList(list[0]!), false); + assertEquals(isInnerList(list[1]!), true); + }, +}); + +Deno.test({ + name: "type guards work with dictionary values", + fn() { + const dict = parseDictionary("a=1, b=(1 2)"); + const a = dict.get("a")!; + const b = dict.get("b")!; + + assertEquals(isItem(a), true); + assertEquals(isInnerList(a), false); + assertEquals(isItem(b), false); + assertEquals(isInnerList(b), true); + }, +}); + +// ============================================================================= +// HTTPWG Official Conformance Tests (https://github.com/httpwg/structured-field-tests) +// ============================================================================= + +// Base32 alphabet for decoding test vectors +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function decodeBase32(input: string): Uint8Array { + // Remove padding + input = input.replace(/=+$/, "").toUpperCase(); + if (input.length === 0) return new Uint8Array(0); + + const output: number[] = []; + let bits = 0; + let value = 0; + + for (const char of input) { + const idx = BASE32_ALPHABET.indexOf(char); + if (idx === -1) throw new Error(`Invalid base32 character: ${char}`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bits -= 8; + output.push((value >> bits) & 0xff); + } + } + return new Uint8Array(output); +} + +// Convert test suite expected value to our internal format +// deno-lint-ignore no-explicit-any +function convertExpectedBareItem(expected: any): BareItem { + if (expected === null || expected === undefined) { + throw new Error("Unexpected null/undefined bare item"); + } + if (typeof expected === "boolean") { + return boolean(expected); + } + if (typeof expected === "number") { + if (Number.isInteger(expected)) { + return integer(expected); + } + return decimal(expected); + } + if (typeof expected === "string") { + return string(expected); + } + if (typeof expected === "object" && "__type" in expected) { + switch (expected.__type) { + case "token": + return token(expected.value); + case "binary": + return binary(decodeBase32(expected.value)); + case "date": + return date(new Date(expected.value * 1000)); + case "displaystring": + return displayString(expected.value); + default: + throw new Error(`Unknown __type: ${expected.__type}`); + } + } + throw new Error(`Cannot convert expected value: ${JSON.stringify(expected)}`); +} + +// Convert test suite expected params array to Map +// deno-lint-ignore no-explicit-any +function convertExpectedParams(params: any[]): Map { + const map = new Map(); + for (const [key, value] of params) { + map.set(key, convertExpectedBareItem(value)); + } + return map; +} + +// Convert test suite expected item [bareItem, params] to Item +// deno-lint-ignore no-explicit-any +function convertExpectedItem(expected: any[]): Item { + const [bareItem, params] = expected; + return { + value: convertExpectedBareItem(bareItem), + parameters: convertExpectedParams(params), + }; +} + +// Convert test suite expected inner list [[items...], params] to InnerList +// deno-lint-ignore no-explicit-any +function convertExpectedInnerList(expected: any[]): InnerList { + const [items, params] = expected; + return { + items: items.map(convertExpectedItem), + parameters: convertExpectedParams(params), + }; +} + +// Check if expected value represents an inner list +// deno-lint-ignore no-explicit-any +function isExpectedInnerList(expected: any[]): boolean { + // Inner list: [[item1, item2, ...], params] + // Item: [bareItem, params] + // The first element of an inner list is an array of items (arrays) + // The first element of an item is a bare item (not an array, unless it's broken) + if (!Array.isArray(expected) || expected.length !== 2) return false; + const [first] = expected; + if (!Array.isArray(first)) return false; + // If first element is empty array, it's an empty inner list + if (first.length === 0) return true; + // If first element is an array of arrays, it's an inner list + return Array.isArray(first[0]); +} + +// Convert test suite expected list member +// deno-lint-ignore no-explicit-any +function convertExpectedListMember(expected: any[]): Item | InnerList { + if (isExpectedInnerList(expected)) { + return convertExpectedInnerList(expected); + } + return convertExpectedItem(expected); +} + +// Compare bare items for equality +function bareItemsEqual(a: BareItem, b: BareItem): boolean { + // Handle integer/decimal comparison - JSON loses distinction between 1 and 1.0 + if ( + (a.type === "integer" || a.type === "decimal") && + (b.type === "integer" || b.type === "decimal") + ) { + return (a.value as number) === (b.value as number); + } + if (a.type !== b.type) return false; + if (a.type === "binary" && b.type === "binary") { + const aVal = a.value as Uint8Array; + const bVal = b.value as Uint8Array; + if (aVal.length !== bVal.length) return false; + return aVal.every((v, i) => v === bVal[i]); + } + if (a.type === "date" && b.type === "date") { + return (a.value as Date).getTime() === (b.value as Date).getTime(); + } + return a.value === b.value; +} + +// Compare parameters for equality +function paramsEqual( + a: ReadonlyMap, + b: ReadonlyMap, +): boolean { + if (a.size !== b.size) return false; + for (const [key, val] of a) { + const bVal = b.get(key); + if (!bVal || !bareItemsEqual(val, bVal)) return false; + } + return true; +} + +// Compare items for equality +function itemsEqual(a: Item, b: Item): boolean { + return bareItemsEqual(a.value, b.value) && + paramsEqual(a.parameters, b.parameters); +} + +// Compare inner lists for equality +function innerListsEqual(a: InnerList, b: InnerList): boolean { + if (a.items.length !== b.items.length) return false; + for (let i = 0; i < a.items.length; i++) { + if (!itemsEqual(a.items[i]!, b.items[i]!)) return false; + } + return paramsEqual(a.parameters, b.parameters); +} + +// Compare list members for equality +function listMembersEqual( + a: Item | InnerList, + b: Item | InnerList, +): boolean { + const aIsInner = "items" in a; + const bIsInner = "items" in b; + if (aIsInner !== bIsInner) return false; + if (aIsInner && bIsInner) { + return innerListsEqual(a as InnerList, b as InnerList); + } + return itemsEqual(a as Item, b as Item); +} + +// Interface for test case from JSON files +interface ConformanceTestCase { + name: string; + raw: string[]; + // deno-lint-ignore camelcase + header_type?: "item" | "list" | "dictionary"; + // deno-lint-ignore no-explicit-any + expected?: any; + canonical?: string[]; + // deno-lint-ignore camelcase + must_fail?: boolean; + // deno-lint-ignore camelcase + can_fail?: boolean; +} + +// Load and run tests from a JSON file +async function runConformanceTests( + t: Deno.TestContext, + filename: string, +): Promise { + const testData = await import(`./testdata/structured_fields/${filename}`, { + with: { type: "json" }, + }); + const tests: ConformanceTestCase[] = testData.default; + + for (const test of tests) { + // Skip tests marked can_fail (implementation-dependent) + if (test.can_fail) continue; + + const rawInput = test.raw.join(", "); + const headerType = test.header_type ?? "item"; + + await t.step(test.name, () => { + if (test.must_fail) { + // Test should fail parsing + assertThrows( + () => { + switch (headerType) { + case "item": + parseItem(rawInput); + break; + case "list": + parseList(rawInput); + break; + case "dictionary": + parseDictionary(rawInput); + break; + } + }, + Error, + ); + } else { + // Test should succeed + switch (headerType) { + case "item": { + const parsed = parseItem(rawInput); + const expected = convertExpectedItem(test.expected); + assertEquals( + itemsEqual(parsed, expected), + true, + `Mismatch for "${test.name}": got ${ + JSON.stringify(parsed) + }, expected ${JSON.stringify(expected)}`, + ); + // Test canonical serialization if provided + if (test.canonical) { + const serialized = serializeItem(parsed); + assertEquals(serialized, test.canonical[0]); + } + break; + } + case "list": { + const parsed = parseList(rawInput); + const expected: List = test.expected.map( + convertExpectedListMember, + ); + assertEquals(parsed.length, expected.length); + for (let i = 0; i < parsed.length; i++) { + assertEquals( + listMembersEqual(parsed[i]!, expected[i]!), + true, + `List member ${i} mismatch for "${test.name}"`, + ); + } + // Test canonical serialization if provided + if (test.canonical) { + const serialized = serializeList(parsed); + assertEquals( + serialized, + test.canonical[0] ?? test.canonical.join(", "), + ); + } + break; + } + case "dictionary": { + const parsed = parseDictionary(rawInput); + // Convert expected dictionary format [[key, value], ...] + const expectedEntries: Array<[string, Item | InnerList]> = + // deno-lint-ignore no-explicit-any + (test.expected as any[]).map( + ([key, value]: [string, unknown]) => [ + key, + convertExpectedListMember(value as unknown[]), + ], + ); + assertEquals(parsed.size, expectedEntries.length); + for (const [key, expectedValue] of expectedEntries) { + const parsedValue = parsed.get(key); + assertEquals( + parsedValue !== undefined, + true, + `Missing key "${key}" in parsed dictionary`, + ); + assertEquals( + listMembersEqual(parsedValue!, expectedValue), + true, + `Dictionary value mismatch for key "${key}" in "${test.name}"`, + ); + } + // Test canonical serialization if provided + if (test.canonical) { + const serialized = serializeDictionary(parsed); + assertEquals( + serialized, + test.canonical[0] ?? test.canonical.join(", "), + ); + } + break; + } + } + } + }); + } +} + +// Run all conformance test files +Deno.test({ + name: "HTTPWG Conformance Tests: binary.json", + async fn(t) { + await runConformanceTests(t, "binary.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: boolean.json", + async fn(t) { + await runConformanceTests(t, "boolean.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: date.json", + async fn(t) { + await runConformanceTests(t, "date.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: dictionary.json", + async fn(t) { + await runConformanceTests(t, "dictionary.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: display-string.json", + async fn(t) { + await runConformanceTests(t, "display-string.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: examples.json", + async fn(t) { + await runConformanceTests(t, "examples.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: item.json", + async fn(t) { + await runConformanceTests(t, "item.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: list.json", + async fn(t) { + await runConformanceTests(t, "list.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: number.json", + async fn(t) { + await runConformanceTests(t, "number.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: param-dict.json", + async fn(t) { + await runConformanceTests(t, "param-dict.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: param-list.json", + async fn(t) { + await runConformanceTests(t, "param-list.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: param-listlist.json", + async fn(t) { + await runConformanceTests(t, "param-listlist.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: string.json", + async fn(t) { + await runConformanceTests(t, "string.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: token.json", + async fn(t) { + await runConformanceTests(t, "token.json"); + }, +});