DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

RFC 6570 URI Templates: the `?` That Must Not Appear, and the Name That Must Repeat

/users/{id}/repos{?page,per_page}. It looks like string formatting with a nicer syntax. It is a 40-page RFC because every one of the decisions you make by hand when you concatenate a URL has a wrong answer that ships.

I implemented all four levels from the grammar and the ยง3.2.1 expansion algorithm, not from a library: eight operators, both modifiers, strings, lists and associative arrays, with a per-character trace of which rule produced each run of output.

๐Ÿ‘‰ Live, expanding in your browser: https://dev48.in/solve/day83-uri-template.html

Every figure below is a count from the verifier or the in-page checks.

The whole specification is five columns

There is no branch anywhere in the engine on the operator character. There is one table.

operator prefix separator named ifemp kept unencoded
{var} โ€” , no โ€” unreserved only
{+var} โ€” , no โ€” unreserved + reserved
{#var} # , no โ€” unreserved + reserved
{.var} . . no โ€” unreserved only
{/var} / / no โ€” unreserved only
{;var} ; ; yes `` unreserved only
{?var} ? & yes = unreserved only
{&var} & & yes = unreserved only

Three of the eight are named. Two of the eight allow reserved characters. One column separates {var} from {+var}, and that column is the one people get wrong.

The prefix is emitted once, and only if something survived

This is the rule the spec exists for. The ? is not written when the expression is parsed โ€” it is written when the first defined variable is about to be.

variables defined {?a,b,c}
none ` (empty string, not ?`)
c only ?c=3
a and c ?a=1&c=3
all three ?a=1&b=2&c=3

No dangling ?, no &&, no leading &. There is nowhere to get it wrong because the flag lives in the loop.

And undefined is not the same as empty. An absent key and null are skipped entirely; an empty list and an empty map are also skipped, because there is nothing to join; an empty string is a value and keeps its name. A list holding one empty string has length 1, so it is defined:

expression "" [] [""]
{?v} ?v= `` ?v=
{;v} ;v `` ;v=
X{.v} X. X X.

Three operators, three different right answers for the same value, all from the ifemp column.

{+var} is not "the same but shorter"

Of the 128 ASCII code points, the default allow-set percent-encodes 62. The reserved allow-set encodes 44. The 18 characters in the gap are the URI structural punctuation : / ? # [ ] @ ! $ & ' ( ) * + , ; =.

{hello}   ->  Hello%20World%21
{+hello}  ->  Hello%20World!
Enter fullscreen mode Exit fullscreen mode

The ! is a sub-delim. It is data under {var} and punctuation under {+var}.

The consequence that actually bites is idempotence. + copies an existing %XX triplet through untouched; the default re-encodes the %:

value "/a b/" expanded twice
  {+p}  ->  /a%20b/            stable
  {p}   ->  %252Fa%2520b%252F  destroyed
Enter fullscreen mode Exit fullscreen mode

That is the right way round. The default's job is to make a value opaque, and running an opaque value through again should not be a no-op.

The star is not "spread". It is "use my separator."

Without *, a composite value is joined with commas, whatever operator you used. With *, the operator's own separator takes over.

op {op list} {op list*}
(none) red,green,blue red,green,blue
+ red,green,blue red,green,blue
# #red,green,blue #red,green,blue
. .red,green,blue .red.green.blue
/ /red,green,blue /red/green/blue
; ;list=red,green,blue ;list=red;list=green;list=blue
? ?list=red,green,blue ?list=red&list=green&list=blue
& &list=red,green,blue &list=red&list=green&list=blue

Read the bottom three rows twice. A named operator with an exploded list repeats the variable name. The tempting reading of * is "drop the name and spread the values", which gives ?red&green&blue โ€” three parameters named red, green and blue, with no values. It is not a list at all.

The five unnamed operators expand identically whether you get this right or wrong. A test suite built on {/list*} will never catch it.

An unexploded map loses the pairing entirely

keys = { semi: ";", dot: ".", comma: "," }

{keys}    ->  semi,%3B,dot,.,comma,%2C
{keys*}   ->  semi=%3B,dot=.,comma=%2C
{+keys}   ->  semi,;,dot,.,comma,,
Enter fullscreen mode Exit fullscreen mode

{keys} is six comma-separated items, not three pairs. Nothing in the output says which is which. And {+keys} is the same map under the reserved allow-set, where the comma value is a literal comma โ€” the output has a run of three commas and is ambiguous by design.

:3 counts characters of the decoded value

Truncation happens before encoding, and it counts code points, not bytes and not output characters.

path = "/foo/bar"
{path:4}   ->  %2Ffoo    4 characters in, 6 out
{+path:4}  ->  /foo      4 characters in, 4 out
Enter fullscreen mode Exit fullscreen mode

Counting the modifier against the output would cut a %2F triplet in half. Counting bytes would cut a 4-byte emoji in half โ€” {e:2} over three ๐Ÿ˜€ gives exactly %F0%9F%98%80%F0%9F%98%80.

A prefix modifier on a list or a map is illegal, not "truncate each member". The engine throws on all four forms tested.

How it is checked

The verifier never reads the page's test data. It carries its own copy of every expansion printed in RFC 6570, and a second expander written from the ยง3.2.1 pseudocode in a deliberately different shape โ€” regex scan, no trace, encoding via Node's Buffer instead of the page's hand-written UTF-8.

  • 120 RFC example expansions, byte for byte: 120/120
  • 232 combinations in an operator ร— modifier ร— value-shape sweep, both implementations: 232/232 identical
  • 128 ASCII code points, both allow-sets
  • 695 trace segments across the 120 vectors, every one reassembling into its expansion and carrying a rule
  • 63 verifier asserts, 0 failures
  • 55 in-page checks, 0 failures

The page itself holds 115 section vectors plus 66 Appendix A level vectors and runs them on load, so the badge is measuring the same engine the tool uses.

The one thing worth taking away

If you are building a query string by concatenation, the bug is not the escaping. The bug is that you decided what ? means before you knew whether any parameter was defined.

Day 83 of SolveFromZero. Tomorrow: the reverse direction, and why matching a URI back to a template is not the same problem.

Top comments (0)