// linkset_json.mbt — RFC 9264 `application/linkset+json` format.
//
// The JSON format represents the abstract link model (RFC 8288 Section 2)
// as a document whose top-level object has "linkset" as its sole member
// (RFC 9264 Section 4.2.1):
//
// { "linkset": [ , ... ] }
//
// A link context object (Section 4.2.2) groups links with the same context:
// it MAY carry an "anchor" member naming the context, and one additional
// member per distinct relation type — a link target object (Section 4.2.3)
// with an "href" member plus optional target attributes (Section 4.2.4).
//
// Two structural consequences are worth stating explicitly:
//
// - A model link with several relation types is emitted under each of
// those relation-type members, so JSON round-trips normalize to one
// relation per link. This matches the format's member-per-relation
// design; the text format (RFC 8288 `rel`) is the one that preserves a
// multi-value relation set in a single link.
// - A bare extension parameter (no value) cannot be represented as a JSON
// string member and serializes as the empty string, and back as a
// parameter with an empty value. Value-less parameters are the only
// information the JSON format cannot carry, per its own rules.
//
// Determinism: the serializer emits context objects in first-appearance
// order, relation members in sorted name order, and target objects in
// first-appearance order. Extension member names are also sorted. The parser
// sorts relation member names and extension member names the same way, so
// `serialize_linkset_json(parse_linkset_json(j)) == j` is a fixed point for
// every model that came out of the parser.
///|
/// Serializes a `LinkSet` as an `application/linkset+json` document. The
/// output is deterministic (stable member order, UTF-8, escaped strings).
pub fn serialize_linkset_json(linkset : LinkSet) -> String {
let links = linkset.links()
let contexts : Array[String?] = Array::new()
for link in links {
if !context_seen(contexts, link.anchor()) {
contexts.push(link.anchor())
}
}
let sb = StringBuilder()
sb.write_string("{\n")
sb.write_string(" \"linkset\": [\n")
for i = 0; i < contexts.length(); i = i + 1 {
if i > 0 {
sb.write_string(",\n")
}
sb.write_string(emit_context_object(links, contexts[i], 2))
}
sb.write_string("\n ]\n")
sb.write_string("}")
sb.to_string()
}
///|
/// Parses an `application/linkset+json` document into a `LinkSet`. Relation
/// members are visited in sorted name order so the result is independent of
/// JSON object key iteration order. Each (relation, target) pair becomes one
/// link carrying a single relation type.
pub fn parse_linkset_json(
input : String,
limits : Limits,
) -> Result[LinkSet, LinkError] {
let input_bytes = @utf8.encode(input)
if input_bytes.length() > limits.max_json_bytes() {
return Err(
link_error(Input, LimitExceeded, "JSON input exceeds max_json_bytes"),
)
}
let root = @json.parse(input) catch {
e => return Err(link_error(LinksetJson, InvalidJson, "invalid JSON: \{e}"))
}
parse_linkset_root(root)
}
///|
fn parse_linkset_root(root : Json) -> Result[LinkSet, LinkError] {
match json_object(root) {
None =>
Err(
link_error(
LinksetJson,
InvalidJsonShape,
"top-level value is not a JSON object",
),
)
Some(members) => {
// RFC 9264 Section 4.2.1: "linkset" must be the sole member.
let keys = map_keys(members)
if !array_contains(keys, "linkset") {
return Err(
link_error(
LinksetJson,
InvalidJsonShape,
"missing \"linkset\" member",
),
)
}
if keys.length() != 1 {
return Err(
link_error(
LinksetJson,
InvalidJsonShape,
"\"linkset\" must be the sole top-level member",
),
)
}
match members.get("linkset") {
None =>
Err(
link_error(
LinksetJson,
InvalidJsonShape,
"missing \"linkset\" member",
),
)
Some(arr) =>
match json_array(arr) {
None =>
Err(
link_error(
LinksetJson,
InvalidJsonShape,
"\"linkset\" value is not an array",
),
)
Some(contexts) => parse_context_array(contexts)
}
}
}
}
}
///|
fn parse_context_array(contexts : Array[Json]) -> Result[LinkSet, LinkError] {
let set = LinkSet::new()
for ctx in contexts {
let links = parse_context_object(ctx) catch {
e => return Err(unwrap_link_error(e))
}
for link in links {
set.add(link)
}
}
Ok(set)
}
///|
/// Parses one link context object. Raises `LinkError` on a structural
/// violation of RFC 9264 Section 4.2.2 / 4.2.3.
fn parse_context_object(ctx : Json) -> Array[WebLink] raise {
let members = match json_object(ctx) {
Some(m) => m
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"link context object is not a JSON object",
)
}
let anchor : String? = match members.get("anchor") {
None => None
Some(a) =>
match json_string(a) {
Some(s) => Some(s)
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"anchor\" is not a string",
)
}
}
let keys = map_keys(members)
let rel_keys : Array[String] = Array::new()
for key in keys {
if key != "anchor" {
rel_keys.push(key)
}
}
rel_keys.sort()
let out : Array[WebLink] = Array::new()
for rel in rel_keys {
match members.get(rel) {
None => ()
Some(targets_json) => {
let targets = match json_array(targets_json) {
Some(t) => t
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"relation member \"\{rel}\" is not an array",
)
}
for target_json in targets {
out.push(parse_target_object(target_json, rel, anchor))
}
}
}
}
out
}
///|
/// Parses one link target object into a single-relation `WebLink`.
fn parse_target_object(
target_json : Json,
relation : String,
anchor : String?,
) -> WebLink raise {
let members = match json_object(target_json) {
Some(m) => m
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"link target object is not a JSON object",
)
}
// RFC 9264 Section 4.2.3: "href" is mandatory.
let href = match members.get("href") {
Some(h) =>
match json_string(h) {
Some(s) => s
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"href\" is not a string",
)
}
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"link target object is missing \"href\"",
)
}
let hreflang : Array[String] = Array::new()
match members.get("hreflang") {
None => ()
Some(v) =>
match json_array(v) {
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"hreflang\" is not an array",
)
Some(arr) =>
for item in arr {
match json_string(item) {
Some(s) => hreflang.push(s)
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"hreflang\" value is not a string",
)
}
}
}
}
let media : String? = opt_string(members, "media")
let title : String? = opt_string(members, "title")
let media_type : String? = opt_string(members, "type")
let title_star : ExtendedValue? = match members.get("title*") {
None => None
Some(v) => Some(parse_intl_array(v, "title*"))
}
// Every remaining member is an extension target attribute (repeatable).
let extensions : Array[LinkParameter] = Array::new()
let keys = map_keys(members)
keys.sort()
for key in keys {
if key == "href" ||
key == "hreflang" ||
key == "media" ||
key == "title" ||
key == "title*" ||
key == "type" {
continue
}
let value_json = match members.get(key) {
Some(v) => v
None => continue
}
let arr = match json_array(value_json) {
Some(a) => a
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"extension attribute \"\{key}\" is not an array",
)
}
if is_ext_value_name(key) {
for item in arr {
let ev = parse_intl_array_item(item, key)
extensions.push({
name: key,
value: Some(serialize_extended_value(ev)),
quoted: false,
})
}
} else {
for item in arr {
let s = match json_string(item) {
Some(s) => s
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"extension attribute \"\{key}\" value is not a string",
)
}
extensions.push({ name: key, value: Some(s), quoted: false })
}
}
}
{
target: href,
relations: [make_relation(relation)],
anchor,
hreflang,
media,
title,
title_star,
media_type,
extensions,
}
}
///|
/// Reads an optional string member, raising on a non-string value.
fn opt_string(members : Map[String, Json], key : String) -> String? raise {
match members.get(key) {
None => None
Some(v) =>
match json_string(v) {
Some(s) => Some(s)
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" is not a string",
)
}
}
}
///|
/// Parses an internationalized attribute value array (Section 4.2.4.2):
/// one or more objects with "value" and optional "language".
fn parse_intl_array(v : Json, key : String) -> ExtendedValue raise {
let arr = match json_array(v) {
Some(a) => a
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" is not an array",
)
}
if arr.is_empty() {
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" must contain at least one value",
)
}
if arr.length() > 1 {
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" (a non-repeatable attribute) has more than one value",
)
}
parse_intl_array_item(arr[0], key)
}
///|
fn parse_intl_array_item(item : Json, key : String) -> ExtendedValue raise {
let members = match json_object(item) {
Some(m) => m
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" element is not an object",
)
}
let value = match members.get("value") {
Some(v) =>
match json_string(v) {
Some(s) => s
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" element is missing a string \"value\"",
)
}
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" element is missing \"value\"",
)
}
let language : String? = match members.get("language") {
None => None
Some(v) =>
match json_string(v) {
Some(s) => Some(s)
None =>
raise link_error(
LinksetJson,
InvalidJsonShape,
"\"\{key}\" element has a non-string \"language\"",
)
}
}
// The JSON format does not carry the charset; it is always UTF-8.
{ charset: "UTF-8", language, value }
}
///|
/// Builds a relation type from a JSON member name: an absolute URI is an
/// extension relation type, everything else is a registered name.
fn make_relation(name : String) -> RelationType {
if is_absolute_uri_reference(name) {
Extension(name)
} else {
Registered(name)
}
}
///|
/// The keys of a JSON object, in map iteration order (sorted by callers).
fn map_keys(members : Map[String, Json]) -> Array[String] {
let out : Array[String] = Array::new()
for k in members.keys() {
out.push(k)
}
out
}
///|
/// Non-deprecated alternatives to `Json::as_*` (which the core library marks
/// deprecated). Pattern matching on the `Json` enum directly.
fn json_string(j : Json) -> String? {
match j {
String(s) => Some(s)
_ => None
}
}
///|
fn json_array(j : Json) -> Array[Json]? {
match j {
Array(a) => Some(a)
_ => None
}
}
///|
fn json_object(j : Json) -> Map[String, Json]? {
match j {
Object(m) => Some(m)
_ => None
}
}
// ---------------------------------------------------------------------------
// Serialization helpers
// ---------------------------------------------------------------------------
///|
fn emit_context_object(
links : Array[WebLink],
context : String?,
depth : Int,
) -> String {
let sb = StringBuilder()
sb.write_string(indent_str(depth))
sb.write_string("{\n")
let members : Array[String] = Array::new()
match context {
Some(a) => members.push("\"anchor\": \{json_quote(a)}")
None => ()
}
let rels : Array[String] = Array::new()
for link in links {
if contexts_equal(link.anchor(), context) {
for rt in link.relations() {
let text = relation_text(rt)
if !array_contains(rels, text) {
rels.push(text)
}
}
}
}
rels.sort()
for rel in rels {
members.push(emit_rel_member(links, context, rel, depth + 1))
}
for i = 0; i < members.length(); i = i + 1 {
if i > 0 {
sb.write_string(",\n")
}
sb.write_string(indent_str(depth + 1))
sb.write_string(members[i])
}
sb.write_string("\n")
sb.write_string(indent_str(depth))
sb.write_string("}")
sb.to_string()
}
///|
fn emit_rel_member(
links : Array[WebLink],
context : String?,
rel : String,
depth : Int,
) -> String {
let sb = StringBuilder()
sb.write_string("\"")
sb.write_string(json_escape(rel))
sb.write_string("\": [\n")
let mut first_target = true
for link in links {
if contexts_equal(link.anchor(), context) && link.has_relation(rel) {
if !first_target {
sb.write_string(",\n")
}
first_target = false
sb.write_string(indent_str(depth + 1))
sb.write_string(emit_target_object(link, depth + 1))
}
}
sb.write_string("\n")
sb.write_string(indent_str(depth))
sb.write_string("]")
sb.to_string()
}
///|
fn emit_target_object(link : WebLink, depth : Int) -> String {
let sb = StringBuilder()
sb.write_string("{\n")
let members : Array[String] = Array::new()
members.push("\"href\": \{json_quote(link.target())}")
if !link.hreflang().is_empty() {
let vals : Array[String] = Array::new()
for h in link.hreflang() {
vals.push(json_quote(h))
}
members.push("\"hreflang\": [\{join_strings(vals, ", ")}]")
}
match link.media() {
Some(m) => members.push("\"media\": \{json_quote(m)}")
None => ()
}
match link.title() {
Some(t) => members.push("\"title\": \{json_quote(t)}")
None => ()
}
match link.title_star() {
Some(ev) => members.push("\"title*\": [\{emit_intl_object(ev)}]")
None => ()
}
match link.media_type() {
Some(t) => members.push("\"type\": \{json_quote(t)}")
None => ()
}
for member in extension_members(link) {
members.push(member)
}
for i = 0; i < members.length(); i = i + 1 {
if i > 0 {
sb.write_string(",\n")
}
sb.write_string(indent_str(depth + 1))
sb.write_string(members[i])
}
sb.write_string("\n")
sb.write_string(indent_str(depth))
sb.write_string("}")
sb.to_string()
}
///|
/// Builds one JSON member per distinct extension parameter name, with the
/// parameter values in an array (RFC 9264 Section 4.2.4.3). Parameters with
/// a trailing `*` follow the internationalized form. Member names are sorted
/// so the emitted object does not depend on model extension order.
fn extension_members(link : WebLink) -> Array[String] {
let names : Array[String] = Array::new()
for p in link.extensions() {
if !array_contains(names, p.name()) {
names.push(p.name())
}
}
names.sort()
let members : Array[String] = Array::new()
for name in names {
let vals : Array[String] = Array::new()
for p in link.extensions() {
if p.name() == name {
vals.push(extension_value_json(name, p.value()))
}
}
members.push("\"\{json_escape(name)}\": [\{join_strings(vals, ", ")}]")
}
members
}
///|
fn extension_value_json(name : String, value : String?) -> String {
if is_ext_value_name(name) {
match value {
Some(raw) =>
match parse_extended_value_string(raw) {
Ok(ev) => emit_intl_object(ev)
Err(_) => json_quote("")
}
None => json_quote("")
}
} else {
match value {
Some(v) => json_quote(v)
None => json_quote("")
}
}
}
///|
fn emit_intl_object(ev : ExtendedValue) -> String {
let sb = StringBuilder()
sb.write_string("{ \"value\": \{json_quote(ev.value())}")
match ev.language() {
Some(l) => sb.write_string(", \"language\": \{json_quote(l)}")
None => ()
}
sb.write_string(" }")
sb.to_string()
}
// ---------------------------------------------------------------------------
// Small utilities
// ---------------------------------------------------------------------------
///|
fn relation_text(rt : RelationType) -> String {
match rt {
Registered(name) => name
Extension(uri) => uri
}
}
///|
fn context_seen(contexts : Array[String?], key : String?) -> Bool {
for c in contexts {
if contexts_equal(c, key) {
return true
}
}
false
}
///|
fn contexts_equal(a : String?, b : String?) -> Bool {
match (a, b) {
(Some(x), Some(y)) => x == y
(None, None) => true
_ => false
}
}
///|
fn array_contains(arr : Array[String], value : String) -> Bool {
for s in arr {
if s == value {
return true
}
}
false
}
///|
fn join_strings(arr : Array[String], sep : String) -> String {
let sb = StringBuilder()
for i = 0; i < arr.length(); i = i + 1 {
if i > 0 {
sb.write_string(sep)
}
sb.write_string(arr[i])
}
sb.to_string()
}
///|
fn indent_str(depth : Int) -> String {
let sb = StringBuilder()
for _i = 0; _i < depth * 2; _i = _i + 1 {
sb.write_char(' ')
}
sb.to_string()
}
///|
/// Wraps a string in JSON quotes with proper escaping.
fn json_quote(s : String) -> String {
let sb = StringBuilder()
sb.write_char('"')
sb.write_string(json_escape(s))
sb.write_char('"')
sb.to_string()
}
///|
/// Escapes a string for inclusion in JSON: `"` and `\` are escaped, control
/// characters below 0x20 become `\u00XX`, everything else (including
/// non-ASCII) is emitted as UTF-8.
fn json_escape(s : String) -> String {
let sb = StringBuilder()
for c in s.iter() {
let v = c.to_int()
if v == 34 {
sb.write_string("\\\"")
} else if v == 92 {
sb.write_string("\\\\")
} else if v < 32 {
sb.write_string("\\u00")
sb.write_char(hex_digit(v / 16))
sb.write_char(hex_digit(v % 16))
} else {
sb.write_char(c)
}
}
sb.to_string()
}
///|
fn hex_digit(v : Int) -> Char {
if v < 10 {
(48 + v).unsafe_to_char()
} else {
(65 + v - 10).unsafe_to_char()
}
}