Practical usage guide for Chatter.Rest.UriTemplates.
Chatter.Rest.UriTemplates is a standalone RFC 6570 URI Template expansion library for .NET. It supports Levels 1 through 4 of the RFC 6570 specification, covering simple string expansion, reserved/fragment expansion, all Level 3 operators (label, path segment, path-style parameter, form-style query, and form-style query continuation), and Level 4 value modifiers (prefix :N and explode *) with composite value types (lists and associative arrays).
Spec: RFC 6570 — URI Template
dotnet add package Chatter.Rest.UriTemplatesNamespace:
using Chatter.Rest.UriTemplates;The package targets netstandard2.0 and net8.0 with no external dependencies.
using Chatter.Rest.UriTemplates;
var template = new UriTemplate("/orders{?status,page}");
var uri = template.Expand(new Dictionary<string, string>
{
["status"] = "shipped",
["page"] = "2"
});
// Result: "/orders?status=shipped&page=2"Constructor. Parses the template string eagerly on construction, so every parse failure listed below surfaces at construction time — never later, at Expand.
var template = new UriTemplate("/search{?q,lang}");This is the authoritative statement of the constructor's exception contract.
ArgumentNullException—templateis null.FormatException— the template is malformed:- an unclosed
{, a nested{, or an empty expression{} - a double operator, e.g.
{??x} - an invalid variable name: empty, containing whitespace, a leading, trailing, or consecutive dot, an incomplete percent-encoded triplet, or any other character outside the RFC 6570 §2.3 grammar (
ALPHA / DIGIT / "_" / pct-encoded, optionally separated by single dots) - an invalid prefix modifier:
:followed by nothing, a non-numeric length, a leading zero, or a length outside 1–9999 (RFC 6570 §2.4.1) - an explode modifier
*anywhere but the end of the variable name - a prefix modifier and an explode modifier on the same variable — they are mutually exclusive per RFC 6570
- a literal-validation failure: an ASCII character not permitted in literal text (space, C0 control characters, DEL,
",<,>,\,^,`,|, or a bare{or}), a%that does not start a valid percent-encoded triplet, an unpaired UTF-16 surrogate, or a non-ASCII character whose scalar value falls outside the RFC 6570 §1.5ucschar/iprivateranges
- an unclosed
NotSupportedException— the expression starts with one of the operators RFC 6570 §2.2 reserves for future use:=,,,!,@, or|(e.g.{=var}).
DI alternative to calling new UriTemplate() directly. Inject IUriTemplateFactory and call Create to obtain a UriTemplate instance. Available via the Chatter.Rest.UriTemplates.DependencyInjection package. See Section 11 — Dependency Injection for setup and usage. With the default parser, Create throws exactly what the constructor throws — see Constructor exceptions; a custom IUriTemplateParser registration substitutes its own parse-failure behavior.
UriTemplate template = factory.Create("/orders{?status,page}");Expands the URI template using the provided variable dictionary.
- Throws
ArgumentNullExceptionifvariablesis null. - Throws
FormatExceptionif a variable value contains an unpaired UTF-16 surrogate and cannot be percent-encoded — see Unpaired surrogates in values; what the message carries is governed by Exception message content. - Which inputs this overload expands as undefined, and what expansion then emits for them, follow What counts as undefined.
- Keys must be non-null strings; copy timing, ordinal name matching, and null-key behavior follow the shared contract in Variable materialization.
var uri = template.Expand(new Dictionary<string, string>
{
["q"] = "dotnet",
["lang"] = "en"
});
// Result: "/search?q=dotnet&lang=en"Expands the URI template using a dictionary that supports composite value types for RFC 6570 Level 4 expansion.
- Parameter:
variables— a dictionary mapping variable names to values. - Returns: the expanded URI string.
- Throws
ArgumentNullExceptionifvariablesis null. - Throws
FormatExceptionif a variable value is not a supported type, if a prefix modifier is applied to a composite value (list or associative array — see Prefix truncation in code points), if a composite value contains a null element, key, or value, or if a string value, list element, or associative-array key or value contains an unpaired UTF-16 surrogate and cannot be percent-encoded — see Unpaired surrogates in values. - Keys must be non-null strings -- copy timing, ordinal name matching, per-entry memoization, and null-key behavior follow the shared contract in Variable materialization.
Supported value types:
null— a permitted value for any entry.string— simple string value. Works with all operators and Level 4 prefix modifiers.IEnumerable<string>(e.g.,string[],List<string>) — list value.IDictionary<string, string>(e.g.,Dictionary<string, string>) — associative array value; pair order is canonicalized per Associative-array pair order.IEnumerable<KeyValuePair<string, string>>— associative array value; pair order is determined by the container type per Associative-array pair order.
Which of these inputs expand as undefined follows What counts as undefined.
var uri = new UriTemplate("{?list*}").Expand(new Dictionary<string, object?>
{
["list"] = new[] { "red", "green", "blue" }
});
// Result: "?list=red&list=green&list=blue"The existing Expand(IDictionary<string, string>) overload still works for callers who only need string values (Level 1–3 inputs and string-only Level 4 like {var:3}).
This is the authoritative statement of the input-materialization contract: how a variable-accepting Expand call takes in the caller's entries, and when, if ever, it reads a caller-supplied sequence. The contract is shared by all five variable-accepting overloads -- Expand(IDictionary<string, string>), Expand(IDictionary<string, object?>), Expand(IDictionary<string, UriTemplateValue>), and both tuple overloads. Overload sections carry at most a summary sentence and a link here; the detail lives only in this section.
- Eager one-time copy. Each call reads the caller's container exactly once, at call time, copying its entries into private storage. The copy records entry names, and it never reads a caller-supplied sequence -- both are commitments the library maintains, not descriptions of any one overload's copy loop. Whatever else a copy performs per entry (type tests, wrapper or snapshot preparation) is caller-invisible internal work that varies by overload, is not committed, and may change. A caller-supplied sequence is first read at the first point where expansion reaches that entry's enumerator. Being named by an expression is necessary but not sufficient for that to happen, and which named entries expansion reaches is not promised -- see Values must be finite sequences. (On the
UriTemplateValueoverload no deferred caller sequence exists to read at all: the caller's sequence was already drained insideFromat construction -- see Values must be finite sequences -- and any per-entry work that overload's copy performs operates on the library's own construction-time snapshot, never on anything the caller supplied or can observe.) - Ordinal re-keying. The copy re-keys every entry under an ordinal, case-sensitive comparison, so variable-name matching is ordinal and case-sensitive (RFC 6570 §2.3) regardless of the comparer the caller's container was created with. Consequence: a caller that supplies a case-insensitive dictionary keyed
"ID"finds that{id}expands as undefined -- the container's own lookup semantics do not survive the copy. - Variable names must be non-null strings. This is a caller obligation on every overload. What a violation produces differs by input shape:
- The tuple overloads commit to rejecting an entry with a null key by throwing
ArgumentExceptionwhose message names the offending entry index. - For the three dictionary overloads the library withholds commitment on the exception identity. Today those paths have no dedicated guard: a custom
IDictionaryimplementation that yields a null key during enumeration (a BCLDictionarycannot hold one) surfaces anArgumentNullExceptionwhoseparamName,key, exists on noExpandoverload -- an observation, not a contract. The asymmetry is tracked in issue #60, and what a violation throws on this path may change when that issue is resolved.
- The tuple overloads commit to rejecting an entry with a null key by throwing
- Per-entry memoization. Within a single
Expandcall, a caller-supplied sequence is read at most once per entry: the first expression whose expansion reaches the entry's enumerator drains the sequence, records its members, and replays the recording for every later expression naming the same variable. The unit of memoization is the entry -- the binding of one variable name to one value -- not the value object. A single sequence instance bound to several referenced names is read at most once per name, and where expansion does reach a second such name, that read observes whatever a fresh enumeration of the already-drained sequence happens to yield: nothing, a throw, or different members. That outcome is determined by the sequence, not by the library -- a sequence-dependent consequence, not a promise. Memoization is scoped to the call: nothing is replayed acrossExpandcalls. - Never-named entries: no caller-supplied sequence is read. A caller-supplied sequence is never read for an entry whose name no expression in the template references.
List and associative-array values carry a caller obligation: Every composite value supplied for a variable the template names must be a finite sequence; the library does not defend against endless enumeration.
The obligation is keyed to the caller, not to which checks the library happens to perform:
- Consequence of violating it. If expansion begins enumerating a value and that enumeration never terminates,
Expandnever returns. - No early-failure guarantee. Other contract violations may throw before or during enumeration; which failure surfaces first is unspecified and may change between releases. Callers must not rely on an early failure to bound an infinite sequence.
Two properties of Variable materialization bound this obligation: a caller-supplied sequence is read at most once per entry -- per name-to-value binding, not per value object, see the canonical section for what a sequence bound to several names observes -- so a single-pass or lazily evaluated finite sequence bound to one referenced name is safe within the call; and no caller-supplied sequence is read for a never-named entry, so an unused lazy, blocking, or endless sequence alongside the referenced values is harmless.
For illustration only, a named value can also go unread when expansion never reaches its enumerator. These cases are not exhaustive, and none of them is a promise:
- Expansion may stop at an earlier failure first:
{bad,later}can reportbadand leavelateruntouched. - A varspec may be rejected before its enumerator is entered: a prefix modifier over a composite is invalid and throws
FormatException(see Prefix truncation in code points); today that check runs before a single member is read, so{items:1}with an endlessIEnumerable<string>happens to throw rather than hang — but whether that check fires before enumeration is exactly the ordering this section declines to promise.
The obligation above applies to every composite value supplied for a named variable, whether or not the current implementation would reach it.
The same obligation applies to both eager UriTemplateValue factories -- UriTemplateValue.From(IEnumerable<string>) and UriTemplateValue.From(IDictionary<string, string>) -- with no template involved: each drains its input completely at construction, so the caller must supply a finite input to either one. The consequence is conditioned exactly as above. If that construction-time enumeration continues indefinitely, From never returns; where it does not continue -- because an earlier validation failure ends the drain (From(IEnumerable<string>) rejects a null element, and From(IDictionary<string, string>) a null key or value, as enumeration encounters it), or because the caller's own enumerator throws first (see Exception message content) -- the premise does not hold and no nontermination is claimed. Which failure surfaces first is unspecified here too, so callers must not rely on an early failure to bound an endless input.
Messages the library itself constructs for variable-value failures are written so callers can log them safely. The library maintains one commitment about their content:
- Value content never appears. Exception messages the library constructs for variable-value failures never contain value content: string values, list elements, and associative-array values are never quoted. This is a promise the library maintains, not a description of throw sites someone has checked — a future throw site that quoted value content would be a bug against this contract. Those messages therefore stay safe to log even when values carry secrets or personal data.
- Which identifying datum appears varies by failure — illustrations, not promises. Most expansion messages name the failing variable, for example
Variable 'keys' contains a null key. Associative array keys must be non-null strings.The associative-array null-value message additionally quotes the offending key:Variable 'keys' contains a null value for key 'dot'. Associative array values must be non-null strings.A tuple entry with a null key reports the entry index (for that failure alone the index is committed, not illustrative -- see Variable materialization); a value containing an unpaired UTF-16 surrogate reports the character index within the value; an unsupported value type quotes the value's runtime type name.UriTemplateValue.Fromkeeps the same posture at construction time: the messages it constructs never contain supplied values — the null-value message quotes the offending key (Dictionary must not contain null values (key: 'dot').), the null-key message names neither the key nor the value (the key is null), and the null-element message carries no supplied data at all. No message is promised to carry any particular datum. - Wording is not API. Message wording, and which identifying detail appears, are not stable API and may change between releases; callers must not parse messages. The only stable property is the absence of value content. Associative-array keys and runtime type names are supplied-adjacent data that MAY appear in messages, so a caller whose keys are themselves sensitive cannot treat these messages as key-free.
This commitment is scoped to messages the library constructs about variable values, and it binds no other exception a caller can observe.
- Exceptions raised by caller-supplied enumeration are outside it. If a caller's own
IEnumerable<string>,IDictionary<string, string>, orIEnumerable<KeyValuePair<string, string>>throws while the library is reading it — fromGetEnumerator(), fromMoveNext(), from ayield returnbody, or from anything else the caller's code runs during enumeration — the library propagates that exception unchanged. This covers both places the library reads a caller-supplied sequence: expansion reading a deferred value, and either eagerUriTemplateValue.Fromfactory draining its input at construction (see Values must be finite sequences). Its message is whatever the caller's code produced, so it can carry supplied values, including secrets. The library deliberately does not sanitize or wrap such an exception: discarding the caller's own diagnostic detail would cost more than it protects. A caller that logs these failures and treats its values as sensitive must account for the exceptions its own enumeration raises; only the messages above are covered. - Parse-time messages are a separate, deliberate case.
FormatExceptionmessages from the constructor quote template text — variable names and offending literal characters — because there the template itself is the malformed input.
Tuple convenience overload. First-wins for duplicate keys.
- Throws
ArgumentNullExceptionifvariablesis null. - Throws
ArgumentExceptionif an entry has a null key; the committed message content and the rest of the shared input contract (copy timing, ordinal name matching) live in Variable materialization. - Throws
FormatExceptionif a variable value contains an unpaired UTF-16 surrogate and cannot be percent-encoded — see Unpaired surrogates in values; what the message carries is governed by Exception message content.
var uri = template.Expand(
("q", "dotnet"),
("lang", "en")
);
// Result: "/search?q=dotnet&lang=en"Duplicate handling:
var uri = template.Expand(
("q", "first"),
("q", "second") // ignored — "first" wins
);
// Result: "/search?q=first"Tuple convenience overload for composite values. Accepts the same value types as the canonical path it delegates to — string, IEnumerable<string> lists, IDictionary<string, string> dictionaries, IEnumerable<KeyValuePair<string, string>> pair sequences (see Associative-array pair order), and null — via object?. First-wins for duplicate keys. Delegates to the canonical IDictionary<string, object?> expansion path.
- Throws
ArgumentNullExceptionifvariablesis null. - Throws
ArgumentExceptionif an entry has a null key; the committed message content and the rest of the shared input contract (copy timing, ordinal name matching, per-entry memoization) live in Variable materialization. - Throws
FormatExceptionif a value is not a supported type (includingUriTemplateValue-- use the dedicated overload instead), if a prefix modifier is applied to a composite value (see Prefix truncation in code points), if a composite value contains a null element, key, or value, or if a string value, list element, or associative-array key or value contains an unpaired UTF-16 surrogate and cannot be percent-encoded — see Unpaired surrogates in values.
var uri = new UriTemplate("/users/{id}{?tag*}").Expand(
("id", (object?)"42"),
("tag", (object?)new[] { "active", "premium" })
);
// Result: "/users/42?tag=active&tag=premium"Mixed-type usage with string, list, and dictionary values in one call:
var uri = new UriTemplate("{/path}{?color*}{;keys*}").Expand(
("path", (object?)"files"),
("color", (object?)new[] { "red", "green" }),
("keys", (object?)new List<KeyValuePair<string, string>>
{
new("semi", ";"),
new("dot", "."),
})
);
// Result: "/files?color=red&color=green;semi=%3B;dot=."Duplicate handling:
var uri = new UriTemplate("/users/{id}").Expand(
("id", (object?)"first"),
("id", (object?)"second") // ignored -- "first" wins
);
// Result: "/users/first"Note: Passing a
UriTemplateValueinstance through this overload throwsFormatException. UseExpand(IDictionary<string, UriTemplateValue>)for strongly-typed values.
Expands the URI template with no variables supplied — equivalent to passing an empty dictionary. Every variable is therefore an absent entry under What counts as undefined.
var uri = new UriTemplate("/orders{?status,page}").Expand();
// Result: "/orders"Returns all variable names referenced in the template, deduplicated, in order of first appearance.
var template = new UriTemplate("/orders{?status,page}{&lang}");
var vars = template.GetVariables();
// Result: ["status", "page", "lang"]RFC 6570 permits duplicate variable names, and each occurrence expands: {?x,x} with x = "1" produces ?x=1&x=1, while GetVariables() reports x once. Callers that count expansion output via GetVariables() will under-count in that case.
var t = new UriTemplate("/users/{id}");
t.Expand(("id", "42"));
// Result: "/users/42"Values are percent-encoded using unreserved encoding:
var t = new UriTemplate("/search/{query}");
t.Expand(("query", "hello world"));
// Result: "/search/hello%20world"Reserved characters in the value are passed through unencoded:
var t = new UriTemplate("/proxy/{+path}");
t.Expand(("path", "foo/bar/baz"));
// Result: "/proxy/foo/bar/baz"Only expand trusted values with {+var} and {#var}. Both operators bypass reserved-character encoding — exactly what RFC 6570 Section 3.2.3 requires — so the value can change the meaning of the surrounding URI. Which component it can reach depends on where the expression sits in the template.
With http://ex.com/a{+p} the expression sits in the path, after the authority has already ended, so the value can add or rewrite everything from the path onward:
| Value | Expansion | Effect |
|---|---|---|
?admin=1 |
http://ex.com/a?admin=1 |
starts the query string |
#frag |
http://ex.com/a#frag |
starts the fragment |
../../etc/passwd |
http://ex.com/a../../etc/passwd |
traversal sequence passes through raw |
x@evil.com |
http://ex.com/ax@evil.com |
stays in the path — no userinfo is introduced from this position |
//evil.com/a |
http://ex.com/a//evil.com/a |
stays in the path — no authority is rewritten from this position |
When the expression sits inside or before the authority, the value reaches the host itself:
| Template | Value | Expansion |
|---|---|---|
http://{+host}/path |
x@evil.com |
http://x@evil.com/path — a userinfo component is introduced and the request moves to another host |
http://{+host}/path |
evil.com |
http://evil.com/path |
{+p}/path |
//evil.com |
//evil.com/path — a scheme-relative URL pointing at another origin |
Under the default {var} operator these characters are percent-encoded, so none of them can change the URI's structure: http://{host}/path with host = "x@evil.com" produces http://x%40evil.com/path, and /proxy/{path} with path = "../../etc/passwd" produces /proxy/..%2F..%2Fetc%2Fpasswd. Use the default operator for untrusted values; if reserved expansion is genuinely required, validate the value against a caller-side allowlist first. See Encoding Rules for how pre-encoded sequences behave under {+} and {#}.
What the default operator does and does not guarantee. Percent-encoding guarantees that the value cannot alter the structure of the URI as parsed — the encoded value stays inside the single component it was expanded into. It does not sanitize the value's meaning. /proxy/..%2F..%2Fetc%2Fpasswd decodes straight back to ../../etc/passwd, so any downstream component that percent-decodes before routing or filesystem normalization sees the traversal sequence again. Encoding defers that problem to the consumer; it does not eliminate it. Validate or normalize untrusted path values on the receiving side regardless of which operator produced them.
Prepends # to the expanded value:
var t = new UriTemplate("/page{#section}");
t.Expand(("section", "overview"));
// Result: "/page#overview"{#var} uses the same reserved encoding as {+var}, so the trust warning above applies equally here.
Prepends . and uses . as separator:
var t = new UriTemplate("/api{.version}");
t.Expand(("version", "v2"));
// Result: "/api.v2"
var t2 = new UriTemplate("/host{.sub,domain}");
t2.Expand(("sub", "www"), ("domain", "example"));
// Result: "/host.www.example"Prepends / and uses / as separator:
var t = new UriTemplate("/files{/dir,file}");
t.Expand(("dir", "photos"), ("file", "cat.jpg"));
// Result: "/files/photos/cat.jpg"Note: a template that begins with {/...} can produce a scheme-relative URL when the first variable expands to an empty string — {/a,b} with a = "" and b = "evil.com" produces //evil.com. This is RFC-conformant, but if such a template's values are not trusted, prefix the template with a literal path segment.
Prepends ; and uses ; as separator. Empty values include the name without =:
var t = new UriTemplate("/matrix{;x,y}");
t.Expand(("x", "1"), ("y", "2"));
// Result: "/matrix;x=1;y=2"
// Empty value:
t.Expand(("x", ""), ("y", "2"));
// Result: "/matrix;x;y=2"Prepends ? and uses & as separator. Empty values include =:
var t = new UriTemplate("/orders{?status,page}");
t.Expand(("status", "shipped"), ("page", "2"));
// Result: "/orders?status=shipped&page=2"
// Empty value:
t.Expand(("status", ""));
// Result: "/orders?status="Prepends & and uses & as separator. Use this for appending to an existing query string:
var t = new UriTemplate("/orders?mode=list{&status,page}");
t.Expand(("status", "shipped"), ("page", "2"));
// Result: "/orders?mode=list&status=shipped&page=2"The library applies two encoding strategies per RFC 6570:
Unreserved encoding (Level 1, ., /, ;, ?, & operators):
Only unreserved characters pass through unencoded. Everything else is percent-encoded as UTF-8 bytes.
Unreserved characters: A-Z a-z 0-9 - . _ ~
var t = new UriTemplate("/search/{query}");
t.Expand(("query", "hello world!"));
// Result: "/search/hello%20world%21"Reserved encoding (Level 2: + and # operators):
Both unreserved and reserved characters pass through unencoded. Only characters outside both sets are percent-encoded.
Reserved characters: : / ? # [ ] @ ! $ & ' ( ) * + , ; =
var t = new UriTemplate("{+path}");
t.Expand(("path", "/foo/bar?q=1"));
// Result: "/foo/bar?q=1" (slashes, ?, = all preserved)% itself is not in the pass-through set: under {+} and {#} a valid percent triplet (% followed by two hex digits) is preserved verbatim, while a bare % is encoded as %25.
Trust boundary. Preserving pre-encoded triplets is part of the {+}/{#} trust boundary: {+p} with p = "%0d%0aX: y" yields %0d%0aX:%20y, and p = "%2e%2e%2fetc" stays %2e%2e%2fetc — a downstream server that decodes these sees control characters or a traversal sequence. Unreserved encoding neutralizes the same input by encoding % as %25, so this is the key behavioral difference between the two operator families: values expanded with {+} or {#} must be trusted.
Even under {+} and {#}, characters outside the reserved and unreserved sets are always percent-encoded: raw CR, LF, NUL, backslash, and direction-override characters such as U+202E never pass through ("a\r\nb" becomes a%0D%0Ab), and non-ASCII text is always UTF-8 percent-encoded, so raw homograph bytes never appear in the output.
That guarantee is scoped to raw control characters in the value, and it is not an end-to-end guarantee against HTTP header splitting. As the trust-boundary note above states, {+} and {#} preserve valid percent triplets, so a caller-supplied %0d%0a survives expansion unchanged ({+p} with p = "%0d%0aX: y" yields %0d%0aX:%20y) and becomes CR LF again in any consumer that percent-decodes the value before placing it in a header. What this library guarantees is that it never introduces a raw control character into the output; whether a decoded value is safe in a header, a host position, or a filesystem path must be validated where that decoding happens.
This is the authoritative statement of the value-encodability contract. It is shared by every Expand overload because every expansion routes through the library's one validating encoder step.
Percent-encoding converts text to UTF-8 bytes, and UTF-8 cannot represent an unpaired UTF-16 surrogate (a high surrogate without a matching low, or a lone low surrogate). Supplying well-formed UTF-16 is therefore a caller obligation, and it covers every string the expansion encodes: string values, list elements, and associative-array keys and values.
- Consequence of violating it. If expansion reaches a string containing an unpaired surrogate, that string cannot be percent-encoded and
ExpandthrowsFormatException. What the message carries is governed by Exception message content. - A prefix modifier does not narrow the obligation. The library commits to validating the whole supplied value: an unpaired surrogate is rejected even when a
{var:N}prefix would have truncated it away — see Prefix truncation in code points. - Whether a violating value is reached is not promised. Which entries are read at all is governed by Variable materialization, and which failure surfaces first by the ordering non-promises in Values must be finite sequences.
This contract is about supplied values at expansion time. An unpaired surrogate in the template's own literal text is a parse-time failure, rejected by the constructor — see Constructor exceptions.
For integration with the HAL LinkObject, see the Chatter.Rest.Hal package.
When a variable referenced in the template is undefined, the library commits to omitting it entirely per RFC 6570 rules: no placeholder or literal {var} text is left in the output.
This is the authoritative statement of which inputs expand as undefined. The mapping is a commitment the library maintains, shared by every Expand overload whose input shape can express the case:
- Absent entry — no supplied entry has the variable's name, under the ordinal, case-sensitive matching of Variable materialization. The no-argument
Expand()is this case for every variable. - Null value — the entry's value is
null. This holds on every overload: theobject?-typed andUriTemplateValue-typed paths, and also thestring-typed paths, where the value is declared non-nullable but a null that arrives anyway (for example from a caller without nullable reference type analysis) is treated as undefined rather than rejected. - Empty composite — the entry's value is a list or associative array that yields no members, on both the
object?andUriTemplateValuepaths, or a pair sequence that yields no members on theobject?path (UriTemplateValuehas no pair-sequence factory, so pair sequences exist only on theobject?path).
An empty string is not undefined: the variable expands under the operator's empty-value rule ({?status} with "" yields ?status=; {;x} with "" yields ;x).
var t = new UriTemplate("/orders{?status,page}");
// Only "status" provided; "page" is undefined:
t.Expand(("status", "shipped"));
// Result: "/orders?status=shipped"
// All variables undefined:
t.Expand(new Dictionary<string, string>());
// Result: "/orders"This applies consistently across all operator types. Operator prefixes (?, #, ., /, ;, &) are only emitted when at least one variable in the expression produces a value.
RFC 6570 Level 4 adds two value modifiers and composite value types:
- Prefix (
{var:3}) — truncate a string value before expansion — see Prefix truncation in code points. - Explode (
{var*}) — expand list or associative array values into separate segments per the operator's rules.
Level 4 expansion requires the Expand(IDictionary<string, object?>) overload so that list and associative-array values can be supplied alongside string values.
This is the authoritative statement of the prefix-truncation contract. It is shared by every operator and overload because every prefixed expansion routes through the library's one internal truncation step.
{var:N} truncates a string value to at most its first N Unicode code points before encoding (RFC 6570 §2.4.1). The library commits to both boundary properties that follow from that unit:
- Not UTF-16 code units — a surrogate pair counts as one code point and is never split by truncation.
- Not grapheme clusters — a combining mark counts as its own code point, so
{var:1}on"é"composed ase+ U+0301 keeps onlye.
Two adjacent contracts live at their own anchors:
- A prefix modifier applies only to string values: applying it to a composite value (a list or associative array) is invalid per RFC 6570 and throws
FormatException. Whether that rejection fires before the composite is enumerated is deliberately unpromised — see Values must be finite sequences. - Truncation does not narrow the value's encodability obligation: an unpaired surrogate beyond the prefix boundary is still rejected — see Unpaired surrogates in values.
var uri = new UriTemplate("{var:3}").Expand(new Dictionary<string, object?>
{
["var"] = "value"
});
// Result: "val"Pass a list as any IEnumerable<string> (e.g., string[] or List<string>):
var uri = new UriTemplate("{?list}").Expand(new Dictionary<string, object?>
{
["list"] = new[] { "red", "green", "blue" }
});
// Result: "?list=red,green,blue"RFC 6570 mandates no particular pair order for associative-array values, so this library defines one. The order an expansion produces is derived from the ordering contract of the value the caller supplies:
| Supplied value | Pair order |
|---|---|
A keyed or set container: IDictionary<string, string> (Dictionary, FrozenDictionary, ImmutableDictionary, ConcurrentDictionary, ReadOnlyDictionary, SortedDictionary, SortedList), including UriTemplateValue.From(IDictionary<string, string>), or ISet<KeyValuePair<string, string>> (HashSet, FrozenSet, ImmutableHashSet) |
Canonicalized: sorted ordinally by key (string.CompareOrdinal), with an ordinal comparison of the value as tie-break |
Every other IEnumerable<KeyValuePair<string, string>> — List<KeyValuePair<string, string>>, arrays, ImmutableArray<...>, ImmutableList<...>, ReadOnlyCollection<...>, Queue<...>, LinkedList<...>, Stack<...>, iterator methods, LINQ pipelines such as Select and OrderBy, and custom enumerables |
Exactly the order the sequence enumerates, preserved verbatim, duplicate keys included |
Canonicalization is a uniform policy applied to these two interfaces, not an inference about each container. Most keyed and set containers genuinely expose no way to place one pair before another — a Dictionary<string, string> enumerates in hash-slot order, which diverges from insertion order once an entry is removed and another inserted into the freed slot. A few do carry a caller-supplied order: SortedDictionary, SortedList, and SortedSet enumerate by their comparer, and the library replaces that order with its own. Sorting every implementation of these interfaces the same way means one interface always implies one ordering, and makes the expansion reproducible. The value tie-break exists because a set can hold two pairs with the same key; dictionary keys are unique, so for a dictionary the tie-break never fires and the order is purely ordinal by key.
No .NET interface distinguishes an ordered sequence from an unordered one — a Queue<T> and a HashSet<T> are both just IEnumerable<T> — so the library does not guess: it canonicalizes only where the container type proves the order is not yours, and defers to you everywhere else. If you supply a custom container that enumerates nondeterministically but implements neither IDictionary<string, string> nor ISet<KeyValuePair<string, string>>, determinism is yours to impose: supply an ordered sequence — a List<KeyValuePair<string, string>> or an OrderBy projection — or convert it to an IDictionary<string, string> and let the canonical order apply. Conversely, a sequence you deliberately ordered — including one that repeats a key — is never reordered.
Two edge cases follow directly from the type tests. SortedSet<KeyValuePair<string, string>> and ImmutableSortedSet<KeyValuePair<string, string>> implement ISet<...>, so they are canonicalized even though a comparer orders them (a rare shape — populating one with more than a single distinct pair requires an explicit IComparer<KeyValuePair<string, string>>, since KeyValuePair<,> is not comparable and the default comparer throws on the first comparison; constructing an empty one, or adding a single pair, succeeds without a comparer). And a custom type that implements only IReadOnlyDictionary<string, string> without IDictionary<string, string> falls into the preserve branch and is expanded in its enumeration order. FrozenDictionary<string, string> implements IDictionary<string, string> and FrozenSet<KeyValuePair<string, string>> implements ISet<...>, so both are canonicalized.
The sort is ordinal, not culture-aware, so uppercase ASCII sorts before lowercase:
var uri = new UriTemplate("{?o*}").Expand(new Dictionary<string, object?>
{
["o"] = new Dictionary<string, string>
{
["a"] = "1",
["_"] = "2",
["Z"] = "3",
["B"] = "4",
}
});
// Result: "?B=4&Z=3&_=2&a=1"Keys are sorted ordinally regardless of the order they were added in:
var uri = new UriTemplate("{?keys*}").Expand(new Dictionary<string, object?>
{
["keys"] = new Dictionary<string, string>
{
["semi"] = ";",
["dot"] = ".",
["comma"] = ",",
}
});
// Result: "?comma=%2C&dot=.&semi=%3B"Use List<KeyValuePair<string, string>> to choose the pair order yourself:
var keys = new List<KeyValuePair<string, string>>
{
new("semi", ";"),
new("dot", "."),
new("comma", ","),
};
var uri = new UriTemplate("{keys}").Expand(new Dictionary<string, object?>
{
["keys"] = keys
});
// Result: "semi,%3B,dot,.,comma,%2C"
var uri2 = new UriTemplate("{?keys*}").Expand(new Dictionary<string, object?>
{
["keys"] = keys
});
// Result: "?semi=%3B&dot=.&comma=%2C"An ordered sequence is also the only way to expand duplicate keys, which a dictionary cannot represent:
var tags = new List<KeyValuePair<string, string>>
{
new("tag", "a"),
new("tag", "b"),
};
var uri = new UriTemplate("{?tags*}").Expand(new Dictionary<string, object?>
{
["tags"] = tags
});
// Result: "?tag=a&tag=b"var vars = new Dictionary<string, object?>
{
["list"] = new[] { "red", "green", "blue" }
};
// Semicolon explode: each member becomes ;varname=value
new UriTemplate("{;list*}").Expand(vars);
// Result: ";list=red;list=green;list=blue"
// Query explode: each member becomes varname=value, joined by &
new UriTemplate("{?list*}").Expand(vars);
// Result: "?list=red&list=green&list=blue"
// Ampersand explode: same as query but with & prefix
new UriTemplate("{&list*}").Expand(vars);
// Result: "&list=red&list=green&list=blue"Level 1–3 expressions and Level 4 expressions can coexist in a single template:
var uri = new UriTemplate("/users/{id}{?filter*}").Expand(new Dictionary<string, object?>
{
["id"] = "42",
["filter"] = new[] { "active", "premium" }
});
// Result: "/users/42?filter=active&filter=premium"UriTemplateValue is a strongly-typed alternative to the IDictionary<string, object?> overload. Instead of relying on runtime type dispatch, callers create values through overloaded From factory methods and get compile-time safety.
public abstract class UriTemplateValue
{
private protected UriTemplateValue() { }
public static StringValue From(string value);
public static ListValue From(IEnumerable<string> values);
public static DictionaryValue From(IDictionary<string, string> pairs);
}
public sealed class StringValue : UriTemplateValue { internal string Value { get; } }
public sealed class ListValue : UriTemplateValue { internal IReadOnlyList<string> Values { get; } }
public sealed class DictionaryValue : UriTemplateValue { internal IReadOnlyDictionary<string, string> Pairs { get; } }The corresponding Expand overload:
public string Expand(IDictionary<string, UriTemplateValue> variables);Keys must be non-null strings; this overload shares the input-materialization contract in Variable materialization (copy timing, ordinal name matching, per-entry memoization, null-key behavior).
Both eager factories — From(IEnumerable<string>) and From(IDictionary<string, string>) — drain their input at construction, so each requires a finite input; see Values must be finite sequences. What the messages those factories construct may carry, and why an exception raised by the caller's own enumeration is outside that guarantee, is governed by Exception message content.
var uri = new UriTemplate("/users/{id}").Expand(new Dictionary<string, UriTemplateValue>
{
["id"] = UriTemplateValue.From("42")
});
// Result: "/users/42"var uri = new UriTemplate("{?color*}").Expand(new Dictionary<string, UriTemplateValue>
{
["color"] = UriTemplateValue.From(new[] { "red", "green", "blue" })
});
// Result: "?color=red&color=green&color=blue"var uri = new UriTemplate("{?keys*}").Expand(new Dictionary<string, UriTemplateValue>
{
["keys"] = UriTemplateValue.From(new Dictionary<string, string>
{
["semi"] = ";",
["dot"] = ".",
["comma"] = ",",
})
});
// Result: "?comma=%2C&dot=.&semi=%3B"UriTemplateValue.From(IDictionary<string, string>) follows the same canonicalized pair order as a plain IDictionary<string, string> — see Associative-array pair order. To choose the pair order yourself, supply a List<KeyValuePair<string, string>> through the IDictionary<string, object?> overload instead.
var uri = new UriTemplate("/users/{id}{?tag*}").Expand(new Dictionary<string, UriTemplateValue>
{
["id"] = UriTemplateValue.From("42"),
["tag"] = UriTemplateValue.From(new[] { "active", "premium" })
});
// Result: "/users/42?tag=active&tag=premium"| Overload | Best for |
|---|---|
Expand() |
All variables undefined — expands the template with no values. |
Expand(IDictionary<string, string>) |
Simple string-only values (Levels 1-3 and string-only Level 4). |
Expand(IDictionary<string, object?>) |
Mixed value types when working with loosely-typed data via dictionary. |
Expand(IDictionary<string, UriTemplateValue>) |
Mixed value types with compile-time safety via dictionary. |
Expand(params (string, string)[]) |
Quick inline calls with string-only values. |
Expand(params (string, object?)[]) |
Quick inline calls with mixed value types — accepts every value type the IDictionary<string, object?> overload accepts. |
The object? and UriTemplateValue overloads (both dictionary and tuple forms) produce identical expansion results. Choose UriTemplateValue when you want the compiler to catch invalid value types instead of getting a FormatException at runtime. The tuple overloads delegate to their dictionary counterparts, adding only first-wins duplicate handling.
The Chatter.Rest.UriTemplates.DependencyInjection package provides integration with Microsoft.Extensions.DependencyInjection.
dotnet add package Chatter.Rest.UriTemplates.DependencyInjectionCall AddUriTemplates() on your IServiceCollection during startup:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddUriTemplates();This registers:
IUriTemplateParseras a singleton — the stateless parser is shared across the application.IUriTemplateFactoryas transient — a new factory instance is resolved each time, using the parser from the current scope.
Both registrations use TryAdd, so they will not override any custom registrations you have already added to the container.
Inject IUriTemplateFactory and call Create to build UriTemplate instances:
using Chatter.Rest.UriTemplates;
public class OrderClient
{
private readonly IUriTemplateFactory _templateFactory;
public OrderClient(IUriTemplateFactory templateFactory)
{
_templateFactory = templateFactory;
}
public string BuildOrderUri(string status, string page)
{
var template = _templateFactory.Create("/orders{?status,page}");
return template.Expand(("status", status), ("page", page));
}
}IUriTemplateFactory.Create(string template) returns a UriTemplate instance with the same behavior as new UriTemplate(string) — the same Expand overloads, GetVariables(), and encoding rules apply. Use the factory when you want to avoid a direct dependency on the UriTemplate constructor for testability or when the parser implementation is provided through the container. Note: IUriTemplateExpander is internal and not registered in the DI container — the expander cannot be replaced via DI.