Skip to main content

iri-rs

iri-rs parses, resolves, normalises and compares IRIs (RFC 3987) and URIs (RFC 3986). It is a fork of iref, static-iref and iref-enum by Timothée Haudebourg, with the upstream history kept, and every crate of the stack that names an IRI names it with iri-rs.

The comparison ladder​

Are these two IRIs equal? has more than one correct answer. RFC 3987 section 5.3 sets out a ladder of comparisons, and each rung finds more pairs equal than the one below it.

  • Simple string comparison (5.3.1). Two IRIs are equal when their characters are.
  • Syntax-based normalisation (5.3.2). The scheme, an ASCII host and the hex digits of a percent-encoding are compared without regard to case, percent-encodings of unreserved characters are decoded, and . and .. segments are removed. Characters are not Unicode-normalised for the comparison, because section 5.3.2.2 expects IRIs to be created in normal form already.
  • Scheme-based normalisation (5.3.3). Rules that depend on the scheme, such as dropping :80 from an http IRI.
  • Protocol-based normalisation (5.3.4). Equivalence learned by using the resource, such as a redirect that only adds a trailing slash.

Section 5.1 leaves the choice to the application, except that "applications using IRIs as identity tokens with no relationship to a protocol MUST use the Simple String Comparison".

RDF and JSON-LD compare IRIs on that bottom rung. RDF 1.1 Concepts section 3.2 forbids any further normalisation when IRIs are compared, and the JSON-LD 1.1 algorithms resolve IRIs without normalising them and key each node by its expanded @id string. NGSI-LD asks for more. ETSI GS CIM 009 v1.9.1 clause 5.5.1 tells an implementation to follow RFC 3986 section 6 when it compares URIs. A broker needs both, often for the same value.

How iri-rs compares​

  • ==, Hash and Ord on Iri, IriRef, Uri and UriRef are simple string comparison. Two values are equal exactly when their as_str() are, and Borrow<str> holds, so a map keyed by IriBuf can be looked up with a &str.
  • equivalent() is the syntax-based rung as RFC 3986 section 6.2.2 defines it. It compares the components as it reads them, and allocates only for a component that holds an escape, uppercase host letters or dot segments.
  • normalized() returns that normal form as a new value, and normalized_for_scheme() also applies the scheme-based rules of RFC 3986 section 6.2.3 to http and https, dropping a default port and turning an empty path into /. Neither rewrites the IRI it is called on, so an IRI that is passed on keeps its original form, as RFC 3987 section 5.3.2.3 requires.

No feature changes what == means. A Rust type has one Eq, and Cargo builds a dependency once with every feature any crate asks for, so a flag on iri-rs would pick one rung for every crate that uses it. Normalised Hash and Eq would also normalise both values on every call. The code that compares picks the rung instead, and the usual pattern is to normalise once where a value enters and to compare strings from then on.

The table shows how iri-rs compares some pairs. The last column says whether the two normalized_for_scheme() results are equal.

FirstSecond==equivalent()By scheme
HTTP://Example.COM/ahttp://example.com/anoyesyes
http://example.com/%7euserhttp://example.com/~usernoyesyes
http://example.com/a/./b/../chttp://example.com/a/cnoyesyes
http://example.com:80/ahttp://example.com/anonoyes
http://example.com/Ahttp://example.com/anonono
urn:ngsi-ld:Building:1URN:NGSI-LD:Building:1nonono

The last pair is the same URN under RFC 8141, which makes urn and the namespace identifier case-insensitive. That rule belongs to a URN library, and iri-rs knows only the generic syntax.

use std::collections::HashSet;

use iri_rs::IriBuf;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let written = IriBuf::new("HTTP://Example.COM/%7euser/./a".to_owned())?;
let plain = IriBuf::new("http://example.com/~user/a".to_owned())?;

assert!(written != plain);
assert!(written.equivalent(&plain));
assert_eq!(written.normalized(), plain);

let mut known = HashSet::new();
known.insert(plain);
assert!(known.contains(written.normalized().as_str()));
assert!(!known.contains(written.as_str()));
Ok(())
}

Compile-time IRIs​

With the static feature, iri!, uri!, iri_ref! and uri_ref! check a literal while the macro expands and emit a const value with the positions of its components already worked out. Nothing is parsed at run time, and an invalid literal is a compile error.

use iri_rs::{Iri, iri};

const NGSI_LD: Iri<&'static str> = iri!("https://uri.etsi.org/ngsi-ld/");

With the enum feature, #[derive(IriEnum)] maps a fixed vocabulary onto a plain enum, and try_from matches an IRI by its exact spelling.

Where Andromeda uses it​

Andromeda normalises once, so it never calls equivalent(). iri-rs first checks an identifier against the RFC 3987 grammar, because the WHATWG URL parser used next is not a validator. The identifier is then parsed into its normal form, a URN by urn-rs and an ASCII URL by the WHATWG parser, while an IRI with non-ASCII characters is kept as written. The normal form is stored and compared as a string, in memory and in PostgreSQL. Relationship targets lists every rewrite the normal form makes, and a q operand is compared both as written and in its normal form (URIs in q).

Every IRI constant in the code is an iri! value, and a check refuses a raw string in its place (One reading of each rule). Standards lists the identifier specifications Andromeda follows.