raoh
Rust port of Raoh, a decoder library for turning untyped boundary input into typed domain values.
It is built around a parse-don't-validate approach:
- decode at the boundary
- keep invalid states out of the domain model
- return failures as values instead of panicking
- attach structured errors to precise paths
Serde already turns JSON text into a serde_json::Value. raoh turns that Value into domain
values, and when the input is wrong it reports every problem it found, each with the JSON Pointer
of where it was, instead of stopping at the first one.
JSON text --serde_json--> serde_json::Value --raoh--> domain values
\--> Issues (path, code, message, meta)
A domain type does not derive Deserialize and its fields stay private. The only way to get a
value of it from outside is through its decoder, so a value that exists has been checked.
Installation
[]
= "0.1"
Optional features:
| Feature | Adds |
|---|---|
regex |
string().pattern(...) |
decimal |
decimal(), decoding into rust_decimal::Decimal |
uuid |
string().uuid(), decoding into uuid::Uuid |
url |
string().url(), decoding into url::Url |
The minimum supported Rust version is 1.87, with every feature.
Quick start
use *;
;
;
let issues = from_str.unwrap_err;
assert_eq!;
Issues implements std::error::Error and serde::Serialize, so it can be returned from a
handler or written out as the response body as it is.
The model
Decoder
A decoder is a value that describes how to read an input. It holds no state and can be reused.
Decoders compose like iterator adapters, and the composed type is hidden behind
impl Decoder<Value, Output = T>. Where a type has to be named, such as a recursive decoder or a
decoder kept in a struct field or a static, .boxed() turns it into a
BoxDecoder<Value, T>, which is Send + Sync.
The walk down the input uses a Path borrowed from the stack, so a successful decode allocates
nothing for paths. A path is copied out into a Pointer only when an issue is recorded.
Issue and Issues
Each issue has:
path: a JSON Pointer (RFC 6901), such as/items/0/namecode: what kind of problem it is, such asrequiredorout_of_rangemessage_key: the code, or a refinement of it such asout_of_range.minimummeta: what else the code says, such asmin,maxandactual
An issue carries no sentence of its own. issue.message() writes one from the English catalogue,
and issue.message_with(Messages::japanese()) from another. The only sentence an issue carries is
one its creator gave with with_message(...), which every language then shows as written:
use ;
let built_in = new.with_meta;
assert_eq!;
let custom = new.with_message;
assert_eq!;
The codes, message keys and meta keys are the same as in Raoh for Java from 0.8 on, and the
codes and meta keys the same as in raoh-php, so the same client-side
handling works for all of them, and a catalogue written for Raoh for Java resolves these issues
too. tests/compat runs the same inputs through Raoh for Java and checks this crate gives the same
issues; the cases where it does not on purpose are listed there and under
Differences from Raoh for Java.
Issues keeps them in the order they were found. flatten() groups the English messages by
path, and to_json() gives the [{"path", "code", "message", "meta"}] form, which is also what
serde::Serialize writes. flatten_with and to_json_with take another catalogue.
Combining decoders
Independent parts: tuples
A tuple of decoders over the same input is a decoder. It runs every part and reports the issues
of all of them. object wraps a tuple of fields, which also allows .strict().
use *;
let point = object.strict;
let issues = point.decode.unwrap_err;
let paths: = issues.iter.map.collect;
assert_eq!;
Dependent rules: and_then
A rule that relates several parts runs once the parts have decoded. The function returns
Result<T, E> where E: Into<Issues>, so a domain constructor returning Result<Self, Issue>
can be passed directly. An issue it returns is read as relative to where the decoder is.
use *;
use ;
let period = object
.and_then;
let trip = object;
let issues = trip.decode.unwrap_err;
assert_eq!;
Tuples accumulate issues; and_then stops at the first failure, because the rule cannot be
checked before its parts exist.
Decoder into decoder: pipe
pipe hands one decoder's output to another decoder as its input, at the same path. It is how a
decoder written for a domain type is composed with the boundary checks in front of it.
Constraints without a new type: refine
refine(predicate, code, message) keeps the output type and adds a check, reported with the
message as a custom one.
Built-in decoders
All of these live in raoh::json and come with use raoh::json::prelude::*. Missing or null
input is required for every one of them, and a value of another JSON type is type_mismatch.
The constraints of one decoder run in the order written, and the first to fail is reported.
string(): trim, lowercase, uppercase, non_blank, min_length, max_length, length,
starts_with, ends_with, contains, one_of, email, ip, ipv4, ipv6, ulid, cuid,
pattern (feature regex), and the conversions parse::<T: FromStr>(), uuid() (feature
uuid) and url() (feature url). Lengths count characters, not bytes.
i32(), i64(), u32(), u64(): min, max, range(a..=b), positive, multiple_of,
one_of, and for the signed ones negative, non_negative and non_positive. A number with a
fraction, or one the type cannot hold, is type_mismatch.
f64(): min, max, range, positive, negative, non_negative, non_positive, one_of.
decimal() (feature decimal): the numeric constraints plus multiple_of and scale.
serde_json keeps a number as its nearest f64 unless its arbitrary_precision feature is on,
so enable that feature in the application when decimals must be exact.
bool(): is_true, is_false.
Every one of them takes .message("..."), which gives the most recent constraint written before
it a custom message. Transformations such as trim cannot fail and are passed over, so
string().trim().message("...") gives the message to the type check, and
string().min_length(3).trim().message("...") gives it to min_length.
Whitespace, character counts, string order, case folding and number formatting follow Raoh for
Java 0.8: trim and non_blank use Unicode's White_Space (so U+3000 and U+00A0 are whitespace
and control characters are not), lengths count code points, one_of, discriminate and
enum_of sort by code point, enum_of folds ASCII case only, and a fractional bound appears in
a message as Double.toString writes it, such as 1.0E7. ipv6 accepts the RFC 4291 text form
without brackets, and a zone ID only on a link-local or non-global multicast address, decided by
the text alone. An issue's meta iterates in key order.
Objects, lists and maps
field(name, d): a member that must be thereoptional_field(name, d):Option<T>,Nonewhen the member is missingpresence_field(name, d):Presence<T>, one ofAbsent,NullorPresent(T)d.nullable():Option<T>,Nonewhen the value isnulld.list():Vec<T>, withnon_empty,min_size,max_size,sizeanduniquedict(d): anIndexMap<String, T>from an object used as a map, in the order theValuekeeps its keys
object requires its input to be an object. Anything else is one issue at the object's own path:
required for missing or null, type_mismatch otherwise. A field is not a decoder on its own,
so optional_field never reads a scalar as an object without that member.
A missing member and a null one are different inputs. field("note", string().nullable())
accepts null but reports a missing member as required, while optional_field accepts a
missing member but not null. presence_field tells all three apart, which is what a PATCH
request needs:
use *;
let nickname = object;
assert_eq!;
assert_eq!;
assert_eq!;
Choices
enum_of([("red", Color::Red), ...]): a string naming one of the values, ignoring ASCII caseliteral("v1"): exactly that stringone_of((a, b, ...)): the first alternative that decodes, orone_of_failedwith each alternative's issues inmeta.candidatesdiscriminate("type", (variant("a", da), variant("b", db), ...)): the variant the membertypenames
use *;
let issues = contact.decode.unwrap_err;
let issue = issues.iter.next.unwrap;
assert_eq!;
assert_eq!;
Defaults and recovery
with_default(v) gives v when the input is missing or null, and still reports any other
problem. recover(v) gives v whatever the problem was.
Recursive structures
A decoder that refers to itself names its own type, so it returns a BoxDecoder and refers to
itself through lazy:
use *;
let tree = category.decode;
assert!;
Messages in other languages
Messages::english() and Messages::japanese() hold the catalogues Raoh for Java ships, word for
word, plus a template for invalid_format.json. A catalogue is a stack of layers, as a locale's
.properties file sits over its parent's: japanese() is a layer over english(),
with_overrides puts a layer of your own on top, and falling_back_to puts another catalogue
beneath. An issue is looked up one layer at a time, by message key and then by code, so a layer
that translates only invalid_format wins over the refined invalid_format.email beneath it, as
in Raoh for Java. Messages::from_properties reads a .properties file as Java's
Properties.load does, \uXXXX escapes included, so an existing Raoh for Java catalogue can be
used as it is. A template's {name} placeholders are filled from meta.
use *;
use Messages;
let issues = string.min_length.decode.unwrap_err;
assert_eq!;
let ours = english.with_overrides;
assert_eq!;
Any Fn(&Issue) -> String is a resolver too.
Differences from Raoh for Java
In what it reports:
objectchecks once that its input is an object and reports one issue at its own path when it is not. Raoh for Java checks in each field, reportingtype_mismatchat every field's path and reading a non-object as an object without anyoptional_field.uuid()parses with theuuidcrate, which also accepts 32 digits without hyphens and the form in braces.url()parses with theurlcrate, which follows the WHATWG URL Standard: it accepts_and non-ASCII characters in a host, refuses a port above 65535, and normalises the URL, sohttps://example.combecomeshttps://example.com/.pattern()takes the syntax of theregexcrate, where\d,\wand\smatch Unicode characters and Java's match ASCII only.serde_jsonkeeps an integer beyond theu64range as a float, as it keeps1e20, so the integer decoders report it as a number that is not an integer (type_mismatchwithactual), where Raoh for Java reports it as outside the range (type_mismatch.numeric_range).serde_jsonreads-0.0as the same float as-0, so the integer decoders read both as 0. Raoh for Java refuses-0.0.- A fractional decimal bound outside 0.001 to 10⁷ appears in a message in exponent form, such as
5.0E-4where Raoh for Java writes0.0005, becausemetaholds it as a JSON number. strict()reports unknown members in the order theValuekeeps its keys. That is the input order whenserde_json'spreserve_orderfeature is enabled, as Raoh for Java reports them, and sorted order otherwise.
In the API:
- Combining is done with tuples and
object, notcombine. There is nonested, because a member is handed to its decoder as aValuealready. flatMapisand_then, and there are noResult,OkorErrtypes of its own: decoding givesstd::result::Result<T, Issues>.- There is no encoder. Serde's
Serializecovers that direction. - There is no domain construction guard (
raoh-gsh). Private fields and module privacy stop a domain value from being built anywhere but its own module. - There are no date and time decoders yet.
string().parse::<T>()reads any type that implementsFromStr, which includes the date types ofjiffandchrono.
Development
scripts/compat/generate.sh regenerates tests/compat/expected.json and copies the message
catalogues from the Raoh for Java version scripts/compat/pom.xml names. It needs Java 25 and
Maven.
License
Apache License 2.0