///|
/// Conversion between core `Json` values and the JRD model.
///
/// This module is the parsing heart of the library:
///
/// * it enforces RFC 7033 member types on known members (a JRD is a JSON
/// object whose `subject` is a string, `aliases` an array of strings,
/// `properties` an object of string-or-null values, and `links` an
/// array of link relation objects; RFC 7033 defines no licence to
/// silently accept other shapes, so wrong types are structured errors);
/// * it enforces every `Limits` bound (counts, byte lengths, extension
/// members, nesting depth) — never by silent truncation;
/// * it preserves unknown members as extension `Json` values because
/// RFC 7033 Section 4.4 requires clients to ignore unknown members
/// rather than treat them as errors;
/// * it deliberately does NOT judge URI validity or rel semantics —
/// that belongs to the validator (`validator.mbt`).
///
/// Known members are:
///
/// * top level: `subject`, `aliases`, `properties`, `links`;
/// * per link: `rel`, `type`, `href`, `titles`, `properties`.
///|
/// Internal: the reserved top-level JRD member names.
fn reserved_top_level() -> Array[String] {
["subject", "aliases", "properties", "links"]
}
///|
/// Internal: the reserved link member names.
fn reserved_link_members() -> Array[String] {
["rel", "type", "href", "titles", "properties"]
}
///|
/// Internal: whether `name` is one of `names`.
fn is_reserved(name : String, names : Array[String]) -> Bool {
let mut i = 0
while i < names.length() {
if name == names[i] {
return true
}
i = i + 1
}
false
}
///|
/// Internal: byte-length check with a Limit error.
fn check_string_len(s : String, limit : Int, what : String) -> Unit raise {
if utf8_byte_length(s) > limit {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"\{what} exceeds \{limit} bytes",
)
}
}
///|
/// Internal: convert a core JSON object member into the model's property
/// map. Keys are property URIs; values must be strings or null per RFC
/// 7033 Section 4.4.3. Enforces count, name-length and value-length
/// limits.
fn json_properties_to_map(
members : Map[String, Json],
max_properties : Int,
max_name_bytes : Int,
max_value_bytes : Int,
where_ : String,
) -> Map[String, PropertyValue] raise {
if members.length() > max_properties {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"\{where_}: too many properties (\{members.length()} > \{max_properties})",
)
}
let result : Map[String, PropertyValue] = Map([])
for name, value in members {
check_string_len(name, max_name_bytes, "\{where_} property name")
match value {
String(s) => {
check_string_len(s, max_value_bytes, "\{where_} property value")
result.set(name, StringValue(s))
}
Null => result.set(name, NullValue)
_ =>
raise WebFingerError(
Property,
WrongMemberType,
None,
"\{where_}: property value must be a string or null",
)
}
}
result
}
///|
/// Internal: convert the `titles` object of a link. Keys are language
/// tags or `"und"`; values must be strings (RFC 7033 Section 4.4.4.4).
fn json_titles_to_map(
members : Map[String, Json],
max_titles : Int,
max_string_bytes : Int,
) -> Map[String, String] raise {
if members.length() > max_titles {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"link: too many titles (\{members.length()} > \{max_titles})",
)
}
let result : Map[String, String] = Map([])
for name, value in members {
check_string_len(name, max_string_bytes, "title language tag")
match value {
String(s) => {
check_string_len(s, max_string_bytes, "title text")
result.set(name, s)
}
_ =>
raise WebFingerError(
Link,
WrongMemberType,
None,
"title value must be a string",
)
}
}
result
}
///|
/// Internal: extract unknown members of a JSON object as preserved
/// extensions, enforcing the extension-member count limit.
fn json_extensions(
members : Map[String, Json],
reserved : Array[String],
max_extensions : Int,
where_ : String,
) -> Map[String, Json] raise {
let result : Map[String, Json] = Map([])
let mut count = 0
for name, value in members {
if !is_reserved(name, reserved) {
count = count + 1
if count > max_extensions {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"\{where_}: too many unknown members (\{count} > \{max_extensions})",
)
}
result.set(name, value)
}
}
result
}
///|
/// Internal: parse one link relation object.
fn json_to_link(j : Json, limits : Limits, depth : Int) -> JrdLink raise {
let members = match j {
Object(m) => m
_ =>
raise WebFingerError(
Link,
WrongMemberType,
None,
"links entries must be objects",
)
}
if depth > limits.max_nesting_depth {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"JSON nesting exceeds \{limits.max_nesting_depth} levels",
)
}
// rel: REQUIRED by RFC 7033 Section 4.4.4.1.
let rel = match members.get("rel") {
None =>
raise WebFingerError(
Link,
MissingRequiredMember,
None,
"link is missing the required 'rel' member",
)
Some(String(s)) => {
check_string_len(s, limits.max_string_bytes, "link rel value")
s
}
Some(_) =>
raise WebFingerError(
Link,
InvalidLink,
None,
"link 'rel' must be a string",
)
}
let media_type = match members.get("type") {
None => None
Some(String(s)) => {
check_string_len(s, limits.max_string_bytes, "link type value")
Some(s)
}
Some(_) =>
raise WebFingerError(
Link,
WrongMemberType,
None,
"link 'type' must be a string",
)
}
let href = match members.get("href") {
None => None
Some(String(s)) => {
check_string_len(s, limits.max_string_bytes, "link href value")
Some(s)
}
Some(_) =>
raise WebFingerError(
Link,
WrongMemberType,
None,
"link 'href' must be a string",
)
}
let titles = match members.get("titles") {
None => Map([])
Some(Object(t)) =>
json_titles_to_map(t, limits.max_titles_per_link, limits.max_string_bytes)
Some(_) =>
raise WebFingerError(
Link,
WrongMemberType,
None,
"link 'titles' must be an object",
)
}
let properties = match members.get("properties") {
None => Map([])
Some(Object(p)) =>
json_properties_to_map(
p,
limits.max_properties_per_link,
limits.max_property_name_bytes,
limits.max_property_value_bytes,
"link",
)
Some(_) =>
raise WebFingerError(
Link,
WrongMemberType,
None,
"link 'properties' must be an object",
)
}
let extensions = json_extensions(
members,
reserved_link_members(),
limits.max_extension_members,
"link",
)
{ rel, media_type, href, titles, properties, extensions }
}
///|
/// Internal: JSON to JRD conversion (raise-based; public boundary is in
/// `parser.mbt`). `depth` starts at 1 for the root object.
fn json_to_jrd_inner(
j : Json,
limits : Limits,
depth : Int,
) -> JsonResourceDescriptor raise {
if depth > limits.max_nesting_depth {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"JSON nesting exceeds \{limits.max_nesting_depth} levels",
)
}
let members = match j {
Object(m) => m
_ =>
raise WebFingerError(
Jrd,
RootNotObject,
None,
"JRD root must be a JSON object",
)
}
let subject = match members.get("subject") {
None => None
Some(String(s)) => {
check_string_len(s, limits.max_subject_bytes, "subject")
Some(s)
}
Some(_) =>
raise WebFingerError(
Subject,
WrongMemberType,
None,
"subject must be a string",
)
}
let aliases : Array[String] = []
match members.get("aliases") {
None => ()
Some(Array(arr)) => {
if arr.length() > limits.max_aliases {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"too many aliases (\{arr.length()} > \{limits.max_aliases})",
)
}
for item in arr {
match item {
String(s) => {
check_string_len(s, limits.max_alias_bytes, "alias")
aliases.push(s)
}
_ =>
raise WebFingerError(
Alias,
WrongMemberType,
None,
"aliases entries must be strings",
)
}
}
}
Some(_) =>
raise WebFingerError(
Alias,
WrongMemberType,
None,
"aliases must be an array",
)
}
let properties = match members.get("properties") {
None => Map([])
Some(Object(p)) =>
json_properties_to_map(
p,
limits.max_properties,
limits.max_property_name_bytes,
limits.max_property_value_bytes,
"jrd",
)
Some(_) =>
raise WebFingerError(
Property,
WrongMemberType,
None,
"properties must be an object",
)
}
let links : Array[JrdLink] = []
match members.get("links") {
None => ()
Some(Array(arr)) => {
if arr.length() > limits.max_links {
raise WebFingerError(
Limit,
LimitExceeded,
None,
"too many links (\{arr.length()} > \{limits.max_links})",
)
}
for item in arr {
links.push(json_to_link(item, limits, depth + 1))
}
}
Some(_) =>
raise WebFingerError(
Link,
WrongMemberType,
None,
"links must be an array",
)
}
let extensions = json_extensions(
members,
reserved_top_level(),
limits.max_extension_members,
"jrd",
)
{ subject, aliases, properties, links, extensions }
}
///|
/// Convert a parsed core `Json` value into the JRD model under the given
/// limits. Public, but `parse_jrd` is the usual entry point.
pub fn json_to_jrd(
j : Json,
limits : Limits,
) -> Result[JsonResourceDescriptor, WebFingerError] {
Ok(json_to_jrd_inner(j, limits, 1)) catch {
e => Err(unwrap_webfinger_error(e))
}
}
///|
/// The nesting depth of a JSON value: `1` for a scalar, one more for
/// each enclosing container. Implemented iteratively so that deeply
/// nested inputs cannot overflow the stack.
pub fn json_depth(j : Json) -> Int {
let mut max_depth = 1
let stack : Array[(Json, Int)] = [(j, 1)]
while stack.length() > 0 {
let top = stack[stack.length() - 1]
let (value, depth) = top
ignore(stack.pop())
if depth > max_depth {
max_depth = depth
}
match value {
Array(arr) =>
for item in arr {
stack.push((item, depth + 1))
}
Object(members) =>
for _k, v in members {
stack.push((v, depth + 1))
}
_ => ()
}
}
max_depth
}
///|
/// Internal: model property map to core JSON object.
fn property_map_to_json(m : Map[String, PropertyValue]) -> Json {
let members : Map[String, Json] = Map([])
for name, value in m {
match value {
StringValue(s) => members.set(name, Json::string(s))
NullValue => members.set(name, Json::null())
}
}
Json::object(members)
}
///|
/// Internal: model title map to core JSON object.
fn title_map_to_json(m : Map[String, String]) -> Json {
let members : Map[String, Json] = Map([])
for name, value in m {
members.set(name, Json::string(value))
}
Json::object(members)
}
///|
/// Internal: a link model to core JSON with the canonical member order
/// rel, type, href, titles, properties, then preserved extensions.
fn link_to_json(link : JrdLink) -> Json {
let members : Map[String, Json] = Map([])
members.set("rel", Json::string(link.rel))
match link.media_type {
Some(t) => members.set("type", Json::string(t))
None => ()
}
match link.href {
Some(h) => members.set("href", Json::string(h))
None => ()
}
if !link.titles.is_empty() {
members.set("titles", title_map_to_json(link.titles))
}
if !link.properties.is_empty() {
members.set("properties", property_map_to_json(link.properties))
}
for name, value in link.extensions {
members.set(name, value)
}
Json::object(members)
}
///|
/// A single link model as a core `Json` value with the canonical member
/// order (rel, type, href, titles, properties, extensions). Useful for
/// CLI output and tooling.
pub fn link_model_to_json(link : JrdLink) -> Json {
link_to_json(link)
}
///|
/// The model as a core `Json` value with the canonical member order:
/// subject, aliases, properties, links, then preserved extensions. Note
/// that JSON object member order carries no semantics; this order exists
/// only to make serialization deterministic.
pub fn jrd_to_json(jrd : JsonResourceDescriptor) -> Json {
let members : Map[String, Json] = Map([])
match jrd.subject {
Some(s) => members.set("subject", Json::string(s))
None => ()
}
if jrd.aliases.length() > 0 {
let alias_json : Array[Json] = []
for a in jrd.aliases {
alias_json.push(Json::string(a))
}
members.set("aliases", Json::array(alias_json))
}
if !jrd.properties.is_empty() {
members.set("properties", property_map_to_json(jrd.properties))
}
if jrd.links.length() > 0 {
let link_json : Array[Json] = []
for link in jrd.links {
link_json.push(link_to_json(link))
}
members.set("links", Json::array(link_json))
}
for name, value in jrd.extensions {
members.set(name, value)
}
Json::object(members)
}