//// RFC 6901 — JavaScript Object Notation (JSON) Pointer. //// //// A pointer identifies a single value within a JSON document. Reference tokens //// are separated by `/`; within a token `~1` encodes `/` and `~0` encodes `~`. //// Evaluation resolves against a `gleam/dynamic.Dynamic` (as produced by //// `gleam/json`'s `parse`), descending object members by name and array //// elements by zero-based index. import gleam/dict import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import gleam/uri /// A parsed JSON Pointer: an ordered list of decoded reference tokens. The empty /// list is the pointer `""`, which references the whole document. pub opaque type Pointer { Pointer(tokens: List(String)) } pub type Error { /// The pointer string does not conform to RFC 6901 syntax. InvalidSyntax(String) /// The pointer is valid but does not resolve to a value in the document. NotFound(token: String) } // --- construction ----------------------------------------------------------- /// Parse a JSON Pointer in its JSON string form (Section 5), e.g. `"/foo/0"`. /// The empty string is the whole-document pointer. pub fn parse(pointer: String) -> Result(Pointer, Error) { case pointer { "" -> Ok(Pointer([])) "/" <> rest -> split_and_decode(rest, percent: False) _ -> Error(InvalidSyntax("a non-empty pointer must start with '/'")) } } /// Parse a JSON Pointer in its URI fragment form (Section 6), e.g. `"#/foo/0"`. /// A leading `#` is optional. pub fn parse_fragment(fragment: String) -> Result(Pointer, Error) { let body = case fragment { "#" <> rest -> rest _ -> fragment } case body { "" -> Ok(Pointer([])) "/" <> rest -> split_and_decode(rest, percent: True) _ -> Error(InvalidSyntax("a non-empty fragment pointer must start with '/'")) } } /// Build a pointer from already-decoded reference tokens. pub fn from_tokens(tokens: List(String)) -> Pointer { Pointer(tokens) } /// The decoded reference tokens of a pointer. pub fn tokens(pointer: Pointer) -> List(String) { pointer.tokens } // `rest` is everything after the leading `/`; splitting on `/` yields the // reference tokens (still escaped), at least one because of the leading `/`. fn split_and_decode( rest: String, percent percent: Bool, ) -> Result(Pointer, Error) { string.split(rest, "/") |> list.try_map(fn(raw) { decode_token(raw, percent) }) |> result.map(Pointer) } fn decode_token(raw: String, percent: Bool) -> Result(String, Error) { use decoded <- result.try(case percent { True -> uri.percent_decode(raw) |> result.replace_error(InvalidSyntax("invalid percent-encoding: " <> raw)) False -> Ok(raw) }) use _ <- result.try(check_escaping(decoded)) // Unescape `~1` -> `/` first, then `~0` -> `~` (order matters, Section 4). Ok( decoded |> string.replace("~1", "/") |> string.replace("~0", "~"), ) } // Every `~` must be part of a `~0` or `~1` escape sequence. fn check_escaping(token: String) -> Result(Nil, Error) { let stripped = token |> string.replace("~0", "") |> string.replace("~1", "") case string.contains(stripped, "~") { True -> Error(InvalidSyntax("invalid '~' escape in token: " <> token)) False -> Ok(Nil) } } // --- serialization ---------------------------------------------------------- /// Render a pointer in its JSON string form (Section 5). pub fn to_string(pointer: Pointer) -> String { pointer.tokens |> list.map(fn(t) { "/" <> escape_token(t) }) |> string.concat } /// Render a pointer in its URI fragment form (Section 6). pub fn to_fragment(pointer: Pointer) -> String { let body = pointer.tokens |> list.map(fn(t) { "/" <> uri.percent_encode(escape_token(t)) }) |> string.concat "#" <> body } // Escape a raw token for the string form: `~` -> `~0` first, then `/` -> `~1`. fn escape_token(token: String) -> String { token |> string.replace("~", "~0") |> string.replace("/", "~1") } // --- evaluation ------------------------------------------------------------- /// Evaluate a pointer against a parsed JSON value (Section 4), returning the /// referenced value. Descends object members by name and array elements by /// zero-based index; `-` (the position after the last element) and any missing /// member / out-of-range index fail with `NotFound`. pub fn get(document: Dynamic, pointer: Pointer) -> Result(Dynamic, Error) { descend(document, pointer.tokens) } /// Parse a JSON document and a pointer string, then evaluate. pub fn get_from_string( document: String, pointer: String, ) -> Result(Dynamic, Error) { use ptr <- result.try(parse(pointer)) use doc <- result.try( json.parse(document, decode.dynamic) |> result.replace_error(InvalidSyntax("invalid JSON document")), ) get(doc, ptr) } fn descend(current: Dynamic, tokens: List(String)) -> Result(Dynamic, Error) { case tokens { [] -> Ok(current) [token, ..rest] -> { use next <- result.try(step(current, token)) descend(next, rest) } } } fn step(current: Dynamic, token: String) -> Result(Dynamic, Error) { case decode.run(current, decode.dict(decode.string, decode.dynamic)) { Ok(object) -> dict.get(object, token) |> result.replace_error(NotFound(token)) Error(_) -> case decode.run(current, decode.list(decode.dynamic)) { Ok(array) -> index_array(array, token) // A scalar (string / number / bool / null) has no children. Error(_) -> Error(NotFound(token)) } } } fn index_array(array: List(Dynamic), token: String) -> Result(Dynamic, Error) { case array_index(token) { // `-` references the (nonexistent) element after the last one. Ok(None) -> Error(NotFound("-")) Ok(Some(index)) -> list.drop(array, index) |> list.first |> result.replace_error(NotFound(token)) Error(e) -> Error(e) } } // Parse an array index per Section 4: `0`, or digits without a leading zero; // `-` is the special after-last position. Anything else is invalid. fn array_index(token: String) -> Result(Option(Int), Error) { case token { "-" -> Ok(None) "0" -> Ok(Some(0)) _ -> case is_index(token) { True -> int.parse(token) |> result.map(Some) |> result.replace_error(NotFound(token)) False -> Error(NotFound(token)) } } } fn is_index(token: String) -> Bool { case string.to_graphemes(token) { [] -> False // multi-digit indices may not have a leading zero ["0", ..] -> False chars -> list.all(chars, is_digit) } } fn is_digit(grapheme: String) -> Bool { case grapheme { "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True _ -> False } }