RFC 6570 URI Template expansion for .NET.
Chatter.Rest.UriTemplates helps API clients expand templated links like
/orders/{id}{?status,page} into safe, correctly encoded URIs. It is useful
when working with REST APIs, HAL links, OpenAPI-style client code, or any API
that returns URI templates for clients to fill in.
using Chatter.Rest.UriTemplates;
var template = new UriTemplate("/orders/{id}{?status,page}");
var uri = template.Expand(
("id", "42"),
("status", "shipped"),
("page", "2"));
// "/orders/42?status=shipped&page=2"- Expand RFC 6570 URI Templates without hand-building paths and query strings.
- Get correct UTF-8 percent-encoding for path, query, fragment, and reserved expansions.
- Omit undefined variables according to RFC 6570 instead of leaving broken placeholders behind.
- Support all RFC 6570 Levels 1-4 operators, including prefix modifiers, explode modifiers, list values, and associative-array values.
- Small, dependency-free API.
- Target both modern .NET and broad .NET Standard consumers.
Install from NuGet:
dotnet add package Chatter.Rest.UriTemplatesThen import the namespace:
using Chatter.Rest.UriTemplates;The package targets net8.0 and netstandard2.0 and has no external runtime
NuGet dependencies.
A companion package provides integration with Microsoft.Extensions.DependencyInjection:
dotnet add package Chatter.Rest.UriTemplates.DependencyInjectionNuGet resolves the core Chatter.Rest.UriTemplates package as a transitive
dependency, so a single install command is sufficient.
Register the services during startup:
using Microsoft.Extensions.DependencyInjection;
services.AddUriTemplates();Then inject IUriTemplateFactory to create and expand templates:
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));
}
}AddUriTemplates() registers IUriTemplateParser as a singleton and
IUriTemplateFactory as transient. Both use TryAdd, so they will not
override custom registrations. See the
usage guide (section 11) for full
details.
Create a template once, then expand it with either a dictionary or tuple pairs.
var template = new UriTemplate("/search{?q,limit}");
var uri = template.Expand(new Dictionary<string, string>
{
["q"] = "uri templates",
["limit"] = "10"
});
// "/search?q=uri%20templates&limit=10"Tuple pairs are convenient for short calls:
var uri = new UriTemplate("/users/{id}")
.Expand(("id", "brennan@example.com"));
// "/users/brennan%40example.com"var uri = new UriTemplate("/customers/{customerId}/orders/{orderId}")
.Expand(
("customerId", "cust_123"),
("orderId", "ord_456"));
// "/customers/cust_123/orders/ord_456"Use {?var} to start a query string and {&var} to append to an existing one.
var listOrders = new UriTemplate("/orders{?status,page,pageSize}");
var uri = listOrders.Expand(
("status", "ready to ship"),
("page", "2"),
("pageSize", "50"));
// "/orders?status=ready%20to%20ship&page=2&pageSize=50"var uri = new UriTemplate("/orders?sort=created{&status,page}")
.Expand(("status", "open"), ("page", "3"));
// "/orders?sort=created&status=open&page=3"Use {+var} when a value intentionally contains URI reserved characters, such
as a path that should keep its slashes.
var uri = new UriTemplate("/proxy/{+path}")
.Expand(("path", "files/2026/report.pdf"));
// "/proxy/files/2026/report.pdf"Using {var} instead would encode the slashes:
var uri = new UriTemplate("/proxy/{path}")
.Expand(("path", "files/2026/report.pdf"));
// "/proxy/files%2F2026%2Freport.pdf"Only expand trusted values with {+var} and {#var}. Reserved and
fragment expansion let reserved URI characters pass through unencoded, so the
value can change the meaning of the surrounding URI — see
Encoding Rules (section 6) in the usage
guide for the full trust-boundary contract, including what the default
{var} operator does and does not guarantee.
Variables that are not supplied are omitted per RFC 6570, including the expression's operator prefix when every variable in it is undefined — see What counts as undefined in the usage guide for exactly which inputs expand as undefined.
var template = new UriTemplate("/orders/{id}{?status,page}");
var uri = template.Expand(("id", "42"), ("status", "open"));
// "/orders/42?status=open"If every query variable is missing, no query string is added:
var uri = new UriTemplate("/orders{?status,page}")
.Expand(new Dictionary<string, string>());
// "/orders"Empty strings are defined values and are expanded according to the operator — see What counts as undefined.
new UriTemplate("/orders{?status}")
.Expand(("status", ""));
// "/orders?status="
new UriTemplate("/matrix{;flag}")
.Expand(("flag", ""));
// "/matrix;flag"Use GetVariables() when you need to validate input, build UI prompts, or log
which values a template expects.
var template = new UriTemplate("/orders/{id}{?status,page}{&locale}");
var variables = template.GetVariables();
// ["id", "status", "page", "locale"]Parses the template eagerly on construction, so every parse failure surfaces
at construction time, never later at Expand — see
Constructor exceptions in the usage
guide for the authoritative exception contract.
For dependency injection scenarios, use IUriTemplateFactory.Create(string)
instead of calling the constructor directly. See the
Dependency Injection section.
Expands the template using string values from a dictionary.
var uri = new UriTemplate("/search{?q,lang}")
.Expand(new Dictionary<string, string>
{
["q"] = "dotnet",
["lang"] = "en"
});
// "/search?q=dotnet&lang=en"Expands the template using a dictionary that supports composite value types for
Level 4 expansion. Supported value types: string, IEnumerable<string>,
IDictionary<string, string>, IEnumerable<KeyValuePair<string, string>>, and
null (treated as undefined — see
What counts as undefined).
Associative-array pair order is derived from the container type supplied — see
Associative-array pair order.
Composite values must be
finite sequences.
var uri = new UriTemplate("{?list*}").Expand(new Dictionary<string, object?>
{
["list"] = new[] { "a", "b", "c" }
});
// "?list=a&list=b&list=c"Strongly-typed alternative to the object? overload. Values are created through
static factory methods on UriTemplateValue:
var uri = new UriTemplate("{?color*}").Expand(new Dictionary<string, UriTemplateValue>
{
["color"] = UriTemplateValue.From(new[] { "red", "green", "blue" })
});
// "?color=red&color=green&color=blue"Factory methods: From(string) returning StringValue,
From(IEnumerable<string>) returning ListValue,
From(IDictionary<string, string>) returning DictionaryValue.
Both eager factories — From(IEnumerable<string>) and
From(IDictionary<string, string>) — drain their input at construction, so each
requires a finite sequence.
Expands the template using tuple pairs. If the same key is supplied more than once, the first value wins.
var uri = new UriTemplate("/search{?q}")
.Expand(("q", "first"), ("q", "second"));
// "/search?q=first"Tuple overload for composite values. Supported value types via object?:
string, IEnumerable<string>, IDictionary<string, string>,
IEnumerable<KeyValuePair<string, string>>, and null (treated as
undefined — see
What counts as undefined).
First-wins for duplicate keys.
var uri = new UriTemplate("/users/{id}{?tag*}").Expand(
("id", (object?)"42"),
("tag", (object?)new[] { "active", "premium" })
);
// "/users/42?tag=active&tag=premium"Passing a
UriTemplateValueinstance through this overload throwsFormatException. UseExpand(IDictionary<string, UriTemplateValue>)for strongly-typed values.
Expands the template with all variables undefined. Every expression is omitted per RFC 6570 rules — see What counts as undefined.
var uri = new UriTemplate("/orders{?status,page}").Expand();
// "/orders"Returns variable names in first-seen order with duplicates removed. Variable names are matched case-sensitively — see Variable materialization.
var variables = new UriTemplate("/{resource}/{id}{?id,format}")
.GetVariables();
// ["resource", "id", "format"]RFC 6570 permits duplicate variable names, and each occurrence expands while
GetVariables() reports the name once — see
GetVariables() in the
usage guide for the under-counting consequence.
The library supports RFC 6570 Levels 1-4.
| Operator | Level | Purpose | Example |
|---|---|---|---|
{var} |
1 | Simple string expansion | /users/{id} |
{+var} |
2 | Reserved expansion | /proxy/{+path} |
{#var} |
2 | Fragment expansion | /docs{#section} |
{.var} |
3 | Label expansion | /api{.version} |
{/var} |
3 | Path segment expansion | /files{/folder,name} |
{;var} |
3 | Path-style parameters | /matrix{;x,y} |
{?var} |
3 | Form-style query | /orders{?status,page} |
{&var} |
3 | Form-style query continuation | /orders?sort=date{&page} |
{var:3} |
4 | Prefix modifier | /search{?q:10} |
{var*} |
4 | Explode modifier | /items{?tag*} |
Variables are supplied as string for simple values. Level 4 composite values
(lists and associative arrays) are supported via the
Expand(IDictionary<string, object?>) overload or the strongly-typed
Expand(IDictionary<string, UriTemplateValue>) overload.
Note: a template that begins with {/...} can produce a scheme-relative URL
when its first variable expands to an empty string — see
Path Segment Expansion
in the usage guide before expanding untrusted values in such a template.
Chatter.Rest.UriTemplates percent-encodes values as UTF-8 bytes. Supplied
strings must be well-formed UTF-16: expanding a value that contains an
unpaired surrogate throws FormatException — see
Unpaired surrogates in values.
- Simple, label, path segment, path-style parameter, and query operators encode
everything except RFC 3986 unreserved characters:
A-Z a-z 0-9 - . _ ~. - Reserved and fragment operators preserve reserved URI characters such as
/,?,#,&, and=. - Already percent-encoded sequences are preserved for reserved and fragment expansion.
new UriTemplate("/search/{q}")
.Expand(("q", "hello world!"));
// "/search/hello%20world%21"
new UriTemplate("{+url}")
.Expand(("url", "https://example.com/docs?q=uri%20templates"));
// "https://example.com/docs?q=uri%20templates"Preserving pre-encoded sequences is part of the {+}/{#} trust boundary:
expand {+url}-style templates only with URLs you already trust, and if an
expanded value will be decoded and then used in a header, a host position, or
a filesystem path, validate it there as well. See
Encoding Rules (section 6) in the usage
guide for what {+} and {#} preserve and what is always percent-encoded.
Level 4 templates use the Expand(IDictionary<string, object?>) overload to
supply list and associative-array values alongside strings. A prefix modifier
truncates a string value in Unicode code points — see
Prefix truncation in code points.
// Prefix modifier: truncate the value before expansion
var uri = new UriTemplate("{var:3}").Expand(new Dictionary<string, object?>
{
["var"] = "value"
});
// "val"
// List value with explode: expand each member as a query parameter
var uri = new UriTemplate("{?color*}").Expand(new Dictionary<string, object?>
{
["color"] = new[] { "red", "green", "blue" }
});
// "?color=red&color=green&color=blue"
// Associative array with explode
var keys = new List<KeyValuePair<string, string>>
{
new("semi", ";"),
new("dot", "."),
};
var uri = new UriTemplate("{?keys*}").Expand(new Dictionary<string, object?>
{
["keys"] = keys
});
// "?semi=%3B&dot=."The Expand(IDictionary<string, string>) overload continues to work for
string-only values, including templates with prefix modifiers.
RFC 6570 mandates no particular pair order for associative-array values, so this library defines one, derived from the ordering contract of the container type the caller supplies — see Associative-array pair order in the usage guide for the authoritative ordering contract.
- Usage guide
- Architecture and design
- Development guide
- RFC 6570 test plan
- Backlog
- RFC 6570 specification
Build and test locally with the .NET SDK. The uritemplate-test submodule is
required — the RFC 6570 compliance tests read their test data from it and
throw on a clone where it was never initialized:
git submodule update --init
dotnet restore
dotnet testSee CONTRIBUTING.md.
MIT. See LICENSE.