Literals, parsing, and JSON
You can turn every range type, every RangeSet<TRange, T> and every value set into text and back.
The text is the PostgreSQL literal, the same text the server itself reads and prints. They also
round-trip through System.Text.Json with no setup: a range or RangeSet as that literal, and a
value set as a JSON array.
Write and read literals
All range types and RangeSet<TRange, T> implement IParsable<T> and IFormattable. Their text
form is the PostgreSQL range literal, the same syntax PostgreSQL uses.
Print a range as a literal
Int32Range.CreateFinite(1, 10).ToString() // "[1,10]"
Int32Range.CreateFinite(1, 10, endInclusive: false)
.ToString() // "[1,9]" (a discrete type canonicalizes)
Int32Range.CreateUnboundedStart(5).ToString() // "(,4]"
Int32Range.CreateUnboundedEnd(5).ToString() // "[5,)"
Int32Range.Infinite.ToString() // "(,)"
Int32Range.Empty.ToString() // "empty"
DateRange.CreateFinite(new DateOnly(2025, 1, 1),
new DateOnly(2025, 3, 31)).ToString()
// "[2025-01-01,2025-03-31]"
DateTimeOffsetRange.CreateFinite(
new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.FromHours(1)),
new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.FromHours(1))).ToString()
// "[2024-06-01T00:00:00.0000000+01:00,2024-07-01T00:00:00.0000000+01:00)"
ToString() and IFormattable.ToString(format, provider) both give you the PostgreSQL range
literal.
The optional format is passed on to the element type, so you control how each bound is printed:
((IFormattable)DateRange.CreateFinite(new DateOnly(2025, 1, 1),
new DateOnly(2025, 3, 31)))
.ToString("MMM d yyyy", CultureInfo.InvariantCulture)
// "[Jan 1 2025,Mar 31 2025]"
That is display text, not a literal. It doesn't parse back, because temporal literals are ISO 8601 only (see Dates and times are strict ISO 8601).
The literal never depends on a culture. Without a format string, you get the invariant literal,
whatever provider you pass and whatever the current culture is. That holds for ToString(),
ToString(null, provider) and ToString("", provider), for string interpolation, string.Format
and logging templates. An empty format is the general format, as everywhere in .NET. A provider
applies only together with an explicit format, because then you are asking for display text
rather than a literal:
var thai = CultureInfo.GetCultureInfo("th-TH");
((IFormattable)range).ToString(null, thai); // "[2025-01-01,2025-03-31]" — the literal
((IFormattable)range).ToString("yyyy-MM-dd", thai); // "[2568-01-01,2568-03-31]" — Buddhist-era display text
RangeSet<TRange, T> prints as a PostgreSQL multirange literal:
IntSet.From([Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(7, 10)])
.ToString() // "{[1,5],[7,10]}"
IntSet.Empty.ToString() // "{}"
IntSet.Infinite.ToString() // "{(,)}"
Good to know:
- Before 9.0, a provider reached the element formatter even without a format.
th-THwrote Buddhist-era years.de-DEwrote a decimal comma that collides with the bound separator ([1,5,2)). A null provider meant the current culture forInt32Range,Int64RangeandDecimalRange.
Parse a literal
Every concrete range type has static Parse and TryParse methods. They accept any valid
PostgreSQL range literal:
var r1 = Int32Range.Parse("[1,10]", null); // Finite [1, 10]
var r2 = Int32Range.Parse("(,5]", null); // UnboundedStart (−∞, 5]
var r3 = Int32Range.Parse("[3,)", null); // UnboundedEnd [3, +∞)
var r4 = Int32Range.Parse("(,)", null); // Infinity (−∞, +∞)
var r5 = Int32Range.Parse("empty", null); // Empty
if (Int32Range.TryParse(userInput, null, out var range))
Console.WriteLine(range);
Parse and TryParse read the invariant literal and ignore the provider, as Guid.Parse
does. The text is PostgreSQL's, and it has no culture.
Integer bounds and elements follow PostgreSQL 16's integer grammar. You can write them in decimal,
or in hexadecimal, octal or binary after 0x, 0o or 0b, with single underscores between digits:
[0x1F,1_000) is [31,1000). PostgreSQL 15 and older read decimal digits only.
Decimal bounds follow PostgreSQL's numeric grammar: sign, digits, point and exponent. A group
separator or a currency symbol fails (FormatException).
A decimal bound or element is read exactly or not at all. Text that names a number decimal can't
hold, such as a 29th significant digit or 1e-30, fails (FormatException) rather than being
rounded to the nearest value. PostgreSQL keeps every digit, so a rounded value would be a different
number from the server's. Before 10.0, [1.00000000000000000000000000001,2) read as
[1.0000000000000000000000000000,2) and contained 1, where the server's range doesn't.
A literal whose lower bound is greater than its upper bound fails (FormatException), as it does
in PostgreSQL. TryParse returns false, and JSON and the TypeConverter throw. Equal bounds that
aren't both inclusive are fine and read as empty, so [5,5) is empty and [5,1) fails. Before
10.0, [5,1) read as empty, and an inverted range inside a multirange literal disappeared from the
set.
Discrete types are normalized when you parse them. "[1,10)" and "[1,9]" hold the same values,
and both parse to the same closed range [1, 9]:
Int32Range.Parse("[1,10)", null).ToString() // "[1,9]"
RangeSet<TRange, T> parses multirange literals the same way:
var set = RangeSet<Int32Range, int>.Parse("{[1,5],[7,10]}", null);
set.Count; // 2
set[0]; // [1, 5]
set[1]; // [7, 10]
Good to know:
- Before 9.0, the provider reached the element parser.
DateRange.Parse("[2026-01-01,2026-06-30]", th-TH)returned a range in 1483, andDecimalRange.Parse("[1.5,2.5)", de-DE)returned[15,25). Neither threw.
Dates and times are strict ISO 8601
Temporal bounds and elements accept strict ISO 8601 only. That means the forms this library writes
and the forms PostgreSQL prints in its default ISO DateStyle, and nothing else:
- A date is
yyyy-MM-dd. - A time is
HH:mm, orHH:mm:sswith any number of fraction digits. The fraction is truncated to the 100 ns the BCL types hold. Go and Java write nine digits. - A date and a time are joined by
Tor a space. - A
DateTimetakes an optionalZ, read asUtc, or an offset±HH,±HH:mmor±HH:mm:ss, read asLocal. The wall clock is kept as written, and the offset is never applied. - A
DateTimeOffsetneeds aZ,±HH,±HH:mmor±HH:mm:ss. The last form is how PostgreSQL writes its historical local-mean-time offsets. You get that instant at offset zero. - An hour of 24 fails (
FormatException), on every runtime. ISO 8601 allows24:00:00as the end of a day, butTimeOnlycan't hold it, and PostgreSQL never prints a timestamp that way. .NET 11 on its own would read it as midnight, or as the next day. - An offset goes up to ±15:59:59, as in PostgreSQL. A
DateTimeOffsetholds at most ±14:00, so for an offset beyond that you also get the instant at offset zero.+16:00fails (FormatException). infinityand-infinityread asMaxValueandMinValue. That is what Npgsql gives you when it reads them.- An exclusive
infinityexcludes the extreme.[2026-01-01,infinity)reads as[2026-01-01,9999-12-30]forDateRangeandLocalDateRange, as the same value read from a column does. The extreme stands for the infinity, so PostgreSQL's range also holds a 9999-12-31 that has no value of its own here (Domain width). - More than six fraction digits are three values.
10:00:00.1234567is .1234567 here. PostgreSQL reads the same text rounded to the microsecond, .123457, and the EF Core package stores it truncated, .123456, as Npgsql binds it. They are at most 1 µs apart. Your own values keep every digit through their literal and JSON, so round to the microsecond yourself if a value must equal what the server reads from its text (Domain width).
The DateTime rule is PostgreSQL's own rule for timestamp input, and it is what a tsrange
stores. So one literal is one value on every machine, and a local value round-trips with its kind.
The NodaTime types follow the same grammar, HH:mm included. An Instant still needs its offset
or Z. A LocalDateTime takes a Z or an offset and keeps the wall clock, as a DateTime does.
A closed temporal set reads its JSON array with this grammar too. A converter you register for the element type still wins. Writing is unchanged: the set writes the elements in System.Text.Json's form.
Good to know:
- Before 9.0, a lenient BCL parser read temporal text. It read
[01.02.2027,05.03.2027)month first, as January 2 to May 2. ADateTimeOffsetwithout an offset took the parsing machine's offset. - Before 9.0, System.Text.Json's own converter read a closed temporal set's JSON. That
converter is ISO 8601 too. But it reads a
DateTimeOffsetwithout an offset, or a bare date, in the parsing machine's offset. So one payload held different instants on different servers.
Parse from a span
Every type you can parse implements ISpanParsable<T>. So Parse and TryParse also take a
ReadOnlySpan<char>:
ReadOnlySpan<char> line = "period=[2024-01-01,2024-12-31];rate=4.5".AsSpan();
var period = DateRange.Parse(line[7..30], null); // no substring allocated
var tags = StringSet.Parse("{a,b}".AsSpan(), null);
var blocks = RangeSet<Int32Range, int>.Parse("{[1,5],[7,10]}".AsSpan(), null);
The literals are parsed over spans internally either way. The span overload saves the substring allocation when what you have is a slice of a larger buffer.
ISpanParsable<T> extends IParsable<T>. So the string overloads keep working unchanged, and so
does any generic code constrained on IParsable<T>.
Good to know:
- In generic code, a
stringargument can bind to the span overload. Where a type parameter is constrained toIRangeFactoryorIValueSetFactory, both overloads are visible. Astringargument then binds to the span one through the implicit conversion. Each type's two overloads are the same call, so your results don't change.
Quote a bound
PostgreSQL lets you quote a bound, so it can hold commas, brackets or other characters that would otherwise confuse the parser:
Int32Range.Parse("[\"1\",\"10\"]", null); // [1, 10]
A bound is read the way PostgreSQL's range_in reads it:
- A double quote opens or closes a quoted section anywhere in a bound, so
"1"2is12. - A doubled quote inside a quoted section is a literal
". - A backslash takes the next character literally, inside quotes or out, so
[\1,5)is[1,5). - Whitespace at either end of a bound is dropped, inside quotes or out. So
[" 2026-01-01",2026-02-01)is[2026-01-01,2026-02-01).
Good to know:
- Only an absent bound is infinite.
(,5)is unbounded below.["",5)and[ ,5)are not: their lower bound is present and empty. No element type reads an empty bound, so they fail (FormatException), as PostgreSQL does withinvalid input syntax for type integer: "". - Before 9.0, an empty quoted bound read as infinite.
["",5)read as(,4], and("","")as(,). - Only ASCII whitespace is dropped: space, tab, newline, carriage return, vertical tab and form feed. A no-break space stays in the bound, so the bound fails, as it does in PostgreSQL.
- Before 9.1, whitespace inside quotes was kept.
[" 2026-01-01",2026-02-01)failed (FormatException), where PostgreSQL reads it.
Bind a range from configuration
You can bind a range, a RangeSet or a value set from configuration:
{ "Billing": { "Period": "[2026-01-01,2026-06-30]", "Blocks": "{[1,5],[7,10]}", "Tags": "{alpha,beta}" } }
var billing = configuration.GetSection("Billing").Get<BillingOptions>(); // Period is a DateRange
This works because every type carries a TypeConverter to and from the same literal. Anything
built on TypeDescriptor reads it, and Microsoft.Extensions.Configuration binding is the main
one.
The converter always parses and formats invariantly. It ignores the culture it is handed, because the literal is the same text everywhere.
Good to know:
- An empty string converts to
null. It never becomes the empty range, which is the literalempty. - A malformed literal fails the bind. You get a
FormatExceptioninside the binder'sInvalidOperationException. - A property typed as one shape rejects the others. A
DateRange.Finiteproperty fails on a literal of another shape. - A minimal-API parameter typed as one shape binds only that shape. Each shape has its own
ParseandTryParse, so(DateRange.Finite period) => …binds?period=[2026-01-01,2026-06-30]and answers 400 for(,2026-06-30]or for text that doesn't parse. Before 10.0, such a parameter boundnullfor every literal that parsed, and the handler ran. - Before 9.0 there was no converter. Configuration binding of a range property produced
null, with no error. ASP.NET Core model binding was never affected: minimal APIs and MVC bind query, route and form values through the staticTryParse.
Serialize to JSON
Ranges serialize as JSON strings that hold the PostgreSQL literal. That keeps them compact, and
they round-trip. The System.Text.Json converters live in the CodoMetis.ValueRanges.Serialization
namespace. They cover every range type and its multirange counterpart.
What you need to register
Nothing:
JsonSerializer.Serialize(DateRange.CreateFinite(new DateOnly(2026, 1, 1),
new DateOnly(2026, 6, 30)));
// "[2026-01-01,2026-06-30]"
Every range type carries its converter as a [JsonConverter] attribute. So does each of its five
shapes (Finite, UnboundedStart, …), RangeSet<TRange, T> and every value set. So plain
JsonSerializer.Serialize(range) writes the literal, and an ASP.NET Core host binds request bodies
with no setup.
The attribute is the lowest-precedence way for System.Text.Json to pick a converter. A converter you register on the options or put on a property still wins.
AddRangeConverters() and RangeJsonConverterFactory are still there and give the same output.
Registering them does no harm, and you no longer need to:
using CodoMetis.ValueRanges.Serialization;
var options = new JsonSerializerOptions().AddRangeConverters(); // same wire form as without
Good to know:
- Before 9.0 the converters were opt-in. Without the registration, System.Text.Json fell back
to dumping public properties.
DateRangewrote{"Length":181}, and none of it could be read back.
Use source generation
A JsonSerializerContext honours the attributes too. If it serializes a value set, list the set's
element type as well, even string:
[JsonSerializable(typeof(Booking))] // holds a DateRange and a StringSet
[JsonSerializable(typeof(string))] // the StringSet's element type
internal partial class ApiJsonContext : JsonSerializerContext;
The element type must be listed because the set converter resolves the element's contract through the options. For Native AOT, see Native AOT.
Good to know:
- Most types name a closed converter. The range types, their shapes and the non-generic value sets name a closed converter type, which the generator creates directly.
- The generic types name a factory.
RangeSet<TRange, T>and the sets for your own element types nameRangeJsonConverterFactory, because an attribute can't name an open generic converter.
Round-trip a range through JSON
var range = Int32Range.CreateFinite(1, 10);
string json = JsonSerializer.Serialize(range); // "\"[1,10]\""
var back = JsonSerializer.Deserialize<Int32Range>(json);
// back == Int32Range.CreateFinite(1, 10)
// Multirange
var set = RangeSet<Int32Range, int>.From([
Int32Range.CreateFinite(1, 5),
Int32Range.CreateFinite(7, 10)
]);
string setJson = JsonSerializer.Serialize(set); // "\"{[1,5],[7,10]}\""
// Works with every range type and its multirange counterpart
var dates = JsonSerializer.Serialize(
DateRange.CreateFinite(new DateOnly(2025, 1, 1), new DateOnly(2025, 12, 31)));
// "\"[2025-01-01,2025-12-31]\""
null round-trips as null, in both directions, for every type here: ranges, shapes, RangeSet
and the value sets. It behaves exactly as for any other reference-typed property.
null stays distinct from the empty range, which is the literal "empty". A missing value and an
empty range are different facts with different JSON text, and neither is read as the other. A
malformed literal still fails (JsonException).
Good to know:
- Before 6.2, the range converters rejected a null token on read, while writing
nullon the way out. So a payload the package produced could not be read back. If you relied on that exception to reject a null where you expected a non-nullable range, the property now receivesnullinstead.
Declare a range as object, a shape or an interface
A range's shapes serialize to the same literal as the range type. So a range reached through
object is not a special case. That includes a boxed value, an object-typed property and a
mixed collection:
JsonSerializer.Serialize<object>(Int32Range.CreateFinite(1, 5)); // "\"[1,5]\""
JsonSerializer.Serialize(new List<object> { range, dateRange }); // ["[1,5]","[2024-01-01,2024-03-01]"]
Reading into a property typed as a shape works too. A literal of the wrong shape fails instead of
widening: "empty" is not an Int32Range.Finite, so reading it throws JsonException.
A value declared as one of the package's interfaces writes the same JSON as its concrete type:
record Booking(IRange<int> Seats, IValueSet<string> Tags);
JsonSerializer.Serialize(new Booking(Int32Range.CreateFinite(1, 4), StringSet.From("a")));
// {"Seats":"[1,4]","Tags":["a"]}
JsonSerializer.Deserialize<Booking>(json); // NotSupportedException: declare the concrete type
That covers IRange<T>, the shape interfaces such as IFiniteRange<T>, and IValueSet<T>. The
value is written through its runtime type's converter, so a converter you registered for that type
applies. Reading into an interface throws NotSupportedException, because the interface doesn't
say which type to construct. Declare the concrete type instead; it reads the same JSON. null
round-trips as for any reference type.
Your own converter for a range type also reaches its shapes. System.Text.Json picks a
converter by the exact type it is handed. A range reached through object (a boxed value, an
object collection, a dictionary of object) is handed over as its shape. So is a range declared
as a shape (Int32Range.Finite). The shape's own attribute would write the literal. So the shape's
converter first looks in Converters for the converter System.Text.Json would choose for the range
type. That is the first one that handles it: a JsonConverter<Int32Range>, or a factory that
produces one. Unless that converter is this package's own, the shape's converter hands the value to
it. That applies to values and dictionary keys, on write and on read. A read goes through the range
type and then narrows, so a property typed as a shape still rejects a value of another shape
(JsonException). One payload therefore has one JSON form per type, however you declare its
values.
Good to know:
- A converter on a property stays scoped to that property.
- A converter registered for a shape type itself wins for that shape, as for any type.
- A source-generated context must list the runtime type of a value declared as an interface. Otherwise the write fails and names the type.
- Before 9.0, a converter on the options for
Int32Rangereached only values declared asInt32Range.Serialize(range)used it, whileSerialize<object>(range), aList<object>and a shape-typed property wrote the literal. - Before 9.0, the interfaces carried no converter. System.Text.Json dumped the interface's
members:
IRange<int>wrote{}andIValueSet<string>wrote{"Values":["a"]}. Neither threw, and neither read back.
Use a range as a dictionary key
A range, a shape or a RangeSet can be a dictionary key:
var days = new Dictionary<DateRange, int>
{
[DateRange.CreateFinite(new DateOnly(2026, 1, 1), new DateOnly(2026, 6, 30))] = 181
};
JsonSerializer.Serialize(days); // {"[2026-01-01,2026-06-30]":181}
The property name is the same literal as the value. It is read back through the same parse. So it
is normalized ("[1,10)" is the key [1,9]), and a malformed key fails (JsonException).
JsonSerializerOptions.DictionaryKeyPolicy isn't applied to these keys. System.Text.Json applies
it to string keys only, and a naming policy would turn a literal into text that no longer parses.
Two spellings can be one key. Because keys are normalized, {"[1,5]":"a","[1,6)":"b"} names
the key [1,5] twice. For a RangeSet, so does {"{[1,5]}":…,"{[1,3],[4,5]}":…}. System.Text.Json
treats that like any duplicate key. By default the later value silently replaces the earlier one,
as it would for {"k":1,"k":2}.
Where a dropped entry matters, set AllowDuplicateProperties = false (.NET 10). Configuration,
permissions and anything keyed by a period a client chose are examples. It checks duplicates on the
parsed key, so two spellings of one key fail (JsonException), like a literal duplicate:
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.AllowDuplicateProperties = false);
Good to know:
- Value sets can't be keys. Their JSON is an array, not a string.
When a literal does not parse
A literal that doesn't parse fails with a JsonException. The message names the target type and
quotes the input: Cannot parse '[2026-13-01,2026-14-01]' as DateRange. It quotes at most 64
characters, plus the input's length when it is longer.
The excerpt is there on purpose:
- It makes a 400 diagnosable. System.Text.Json's own messages name only the JSON path.
- It is bounded. A hostile payload can't become an equally large exception message in every
log sink.
ParserResilienceTestsholds that bound. - It follows the BCL.
int.Parsequotes its input too.
Good to know:
- Your clients may see the excerpt. If your host returns exception messages to clients (MVC
does by default, through
JsonOptions.AllowInputFormatterExceptionMessages), the client sees an excerpt of what it sent itself.
Element converters
A value set serializes as a plain JSON array, and System.Text.Json serializes its elements. So your element converters decide how the elements look, whether you register them on the options, on the property or on the element type.
If System.Text.Json knows nothing about the element type, that would go wrong silently. It would
write an object of the element's properties, and read default. A set family prevents that by
supplying a fallback converter:
static JsonConverter<LocalDate>? IValueSetFactory<LocalDateSet, LocalDate>.ElementJsonConverter
=> /* ISO 8601, the same text form the array literals use */;
The fallback is used last: only when System.Text.Json has no scalar converter for the element type at all. So a converter you register by any of the three usual routes still wins.
- The sets over primitive types serialize natively and leave the fallback at the default
null. - Every set for your own element types defines one, because its element type is whatever you
supply. Each writes the same token type as its plain sibling. The integer versions write a JSON
number, and
DecimalSet<T>a JSON number that keeps the element's scale. The string, Guid, temporal and NodaTime versions write a JSON string holding the family's text form. - The five NodaTime sets define one too.
For the non-temporal families that makes the JSON byte for byte the same: Int32Set<OrderId> and
Int32Set are indistinguishable, and they accept the same payloads: a quoted number only under
JsonNumberHandling.AllowReadingFromString, a Guid only in its hyphenated form. The temporal
versions write the same value in slightly different text (seven fraction digits, and + escaped
by the default encoder). See value sets.
That fallback is why the NodaTime sets need no configuration:
JsonSerializer.Serialize(LocalDateSet.From(new LocalDate(2024, 1, 1))); // ["2024-01-01"]
The NodaTime package also ships AddNodaTimeRangeConverters(), which registers the same element
converters on the options. Use it to extend the ISO 8601 form to bare NodaTime properties next to
a set, which the fallback doesn't reach. See the
NodaTime package README.
Good to know:
- Those converters are a fallback too. Any other converter on the options for the same type wins, in either registration order. So NodaTime.Serialization.SystemTextJson stays in charge.
- A non-ISO calendar fails on write. A bare
LocalDate,LocalDateTimeorYearMonthin a calendar other than ISO throwsArgumentException, as NodaTime's own converter does.
Native AOT
Every type works under Native AOT and trimming. You register nothing beyond what source generation
always needs. Both packages build with IsAotCompatible and raise no trim or AOT warnings when you
publish your project. A NativeAOT consumer in this repository's smoke test exercises them.
Use a source-generated context, as for any AOT JSON. List the types you serialize. For a value set, also list its element type, because the set converter resolves the element's contract through the options:
[JsonSerializable(typeof(Booking))] // holds a DateRange and a RangeSet<DateRange, DateOnly>
[JsonSerializable(typeof(StringSet<PermissionKey>))]
[JsonSerializable(typeof(PermissionKey))] // the set's element type
internal partial class ApiJsonContext : JsonSerializerContext;
If you leave the element type out, the first use fails loudly, in both directions. System.Text.Json
throws a NotSupportedException that names it:
JsonTypeInfo metadata for type 'PermissionKey' was not provided by TypeInfoResolver …. That holds
even for the families that bring their own element converter: the sets for your own element types
and the NodaTime sets. The set converter asks the options for the element's contract first. It
uses its own converter only for an element the options know but can't write as a scalar.
Under Native AOT, System.Text.Json also turns off reflection-based serialization. So
JsonSerializer.Serialize(value) with default options throws InvalidOperationException for every
type, these included, whether typed or through object. The attributes take effect through the
context: pass its options or its type info.
The generic types, RangeSet<TRange, T> and the sets for your own element types, get their
converters from a registration each type makes for itself. They never use MakeGenericType, so a
type your program only ever deserializes works too. If trimming ever removes that registration, the
converter lookup throws a NotSupportedException that names the type. Register its closed
converter on the options to fix it:
options.Converters.Add(new ValueSetJsonConverter<StringSet<PermissionKey>, PermissionKey>());
Good to know:
- Before 9.0,
RangeSetand the sets for your own element types failed under Native AOT with "missing native code". Their converters were built by reflection over instantiations the AOT compiler never generated.
OpenAPI
ASP.NET Core's OpenAPI generator (Microsoft.AspNetCore.OpenApi) describes a range, multirange or
value set in a request or response body as {}, an unconstrained value. The generator builds
schemas from System.Text.Json's contract, and it can't see through a custom converter. .NET 10 has
no public API for a converter to declare its schema.
Query, route and header parameters are fine. They bind through TryParse, and ASP.NET Core
describes them as type: string on its own.
The package ships no OpenAPI dependency. Instead, copy this schema transformer into your API
project. This repository compiles and tests it, verbatim, against Microsoft.AspNetCore.OpenApi
10:
using CodoMetis.ValueRanges;
using CodoMetis.ValueRanges.Core;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
/// <summary>
/// Describes CodoMetis.ValueRanges types in ASP.NET Core's OpenAPI document as they appear on the
/// wire. Without it a range, multirange or value set is described as <c>{}</c>: the generator
/// cannot see through a custom JSON converter. Register it with
/// <c>builder.Services.AddOpenApi(o => o.AddSchemaTransformer<ValueRangesSchemaTransformer>());</c>
/// </summary>
public sealed class ValueRangesSchemaTransformer : IOpenApiSchemaTransformer
{
// Mirrors what the parsers accept: surrounding whitespace, "empty" in any case, quoted bounds.
private const string RangePattern = @"^\s*([Ee][Mm][Pp][Tt][Yy]|[\[(][\s\S]*,[\s\S]*[\])])\s*$";
private const string MultirangePattern = @"^\s*\{[\s\S]*\}\s*$";
public async Task TransformAsync(
OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
var type = context.JsonTypeInfo.Type;
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(RangeSet<,>))
{
var element = type.GetGenericArguments()[1];
schema.Type = JsonSchemaType.String;
schema.Pattern = MultirangePattern;
schema.Description = $"A PostgreSQL multirange literal over {element.Name}: ranges in braces, "
+ "e.g. {[1,5],[7,10]}, or {} for none.";
}
else if (FactoryElement(type, typeof(IRangeFactory<,>)) is { } rangeElement)
{
schema.Type = JsonSchemaType.String;
schema.Pattern = RangePattern;
schema.Description = $"A PostgreSQL range literal over {rangeElement.Name}: [ ] include a bound, "
+ "( ) exclude it, an omitted bound is unbounded, e.g. [1,10) or [5,); "
+ "or 'empty'.";
}
else if (FactoryElement(type, typeof(IValueSetFactory<,>)) is { } setElement)
{
schema.Type = JsonSchemaType.Array;
schema.UniqueItems = true;
schema.Items = await context.GetOrCreateSchemaAsync(setElement, cancellationToken: cancellationToken);
schema.Description = $"A set of distinct {setElement.Name} values.";
}
}
// A range variant (DateRange.Finite, …) inherits its union's IRangeFactory, so it is described too.
private static Type? FactoryElement(Type type, Type openFactory)
=> Array.Find(type.GetInterfaces(), i => i.IsGenericType && i.GetGenericTypeDefinition() == openFactory)
?.GetGenericArguments()[1];
}
Then register it:
builder.Services.AddOpenApi(o => o.AddSchemaTransformer<ValueRangesSchemaTransformer>());
What it produces:
| Type | Schema |
|---|---|
a range type or variant (DateRange, DateRange.Finite, …) |
type: string, a pattern for the range literal, a description naming the element type |
RangeSet<TRange, T> |
type: string, a pattern for the multirange literal |
a value set (StringSet, Int32Set<OrderId>, …) |
type: array, uniqueItems: true, items from the element type's own schema |
The patterns are loose on purpose. They accept everything the parsers accept: surrounding
whitespace, empty in any case, and quoted bounds that contain commas. A pattern that rejected a
valid literal would make a validating client reject good input. The patterns show a reader the
shape, and the server's parser has the final say.
Good to know:
- An element type with its own
[JsonConverter]keeps its own schema. A wrapper element with its own converter is described by whatever that element's schema is. That is again{}, unless you describe it too.