squirtle

JSON Patch (RFC 6902) for Gleam.

A JSON Patch is a sequence of operations — add, remove, replace, copy, move, and test — that transforms one JSON document into another. With squirtle you can apply a patch to a document, compute the patch between two documents (diff), read individual nodes (query), and convert documents and patches to and from JSON.

Documents are the Doc type — a plain value you can pattern match on, unlike gleam/json’s opaque Json. Operations are the Patch type. Paths are JSON Pointers, e.g. /users/0/name.

let assert Ok(doc) = squirtle.parse("{\"name\": \"John\"}")
squirtle.apply(doc, [squirtle.Replace(path: "/name", value: squirtle.String("Jane"))])
// => Ok(...)  // the document {"name":"Jane"}

Types

A JSON document.

Unlike gleam/json’s opaque Json, a Doc is a plain value you can pattern match on and traverse directly — wrap a value in a variant to build one, and match on it to take one apart. Arrays are ordered; objects are keyed by string, mirroring JSON exactly. Get one with parse, or build it by hand:

squirtle.Object(dict.from_list([
  #("name", squirtle.String("John")),
  #("pets", squirtle.Array([squirtle.String("Rex")])),
]))
pub type Doc {
  Null
  String(String)
  Int(Int)
  Bool(Bool)
  Float(Float)
  Array(List(Doc))
  Object(dict.Dict(String, Doc))
}

Constructors

  • Null
  • String(String)
  • Int(Int)
  • Bool(Bool)
  • Float(Float)
  • Array(List(Doc))
  • Object(dict.Dict(String, Doc))

A single RFC 6902 patch operation. Apply a list of these with apply.

Every path (and from/to) is a JSON Pointer: "" is the whole document, /foo an object key, /foo/0 an array index, and /foo/- the position just past the end of an array.

pub type Patch {
  Add(path: String, value: Doc)
  Remove(path: String)
  Replace(path: String, value: Doc)
  Copy(from: String, to: String)
  Move(from: String, to: String)
  Test(path: String, expect: Doc)
}

Constructors

  • Add(path: String, value: Doc)

    Add value at path. For an object key this inserts or overwrites it; for an array index it inserts before that index (use /- to append).

  • Remove(path: String)

    Remove the value at path. The path must exist.

  • Replace(path: String, value: Doc)

    Replace the value at path with value. The path must already exist — unlike Add, replace never creates a new location.

  • Copy(from: String, to: String)

    Copy the value found at from and add it at to.

  • Move(from: String, to: String)

    Move the value at from to to (a remove followed by an add).

  • Test(path: String, expect: Doc)

    Succeed only if the value at path equals expect; otherwise the whole apply fails with TestFailed. Handy as a guard before other operations.

Why an apply or query failed. Render one for humans with error_to_string.

pub type PatchError {
  PathNotFound(path: String)
  InvalidIndex(path: String, index: String)
  IndexOutOfBounds(path: String, index: Int)
  NotAContainer(path: String)
  CannotRemoveRoot
  TestFailed(path: String, expected: Doc, actual: Doc)
  InvalidPath(reason: String)
}

Constructors

  • PathNotFound(path: String)

    The specified path does not exist in the document.

  • InvalidIndex(path: String, index: String)

    An array index in the path is invalid (not a number, has leading zeros, etc).

  • IndexOutOfBounds(path: String, index: Int)

    An array index is outside the bounds of the array.

  • NotAContainer(path: String)

    Attempted to navigate into a value that is not an object or array.

  • CannotRemoveRoot

    Cannot remove the root document.

  • TestFailed(path: String, expected: Doc, actual: Doc)

    A test operation failed because the values didn’t match.

  • InvalidPath(reason: String)

    The JSON pointer path is malformed.

Values

pub fn apply(
  doc: Doc,
  patches: List(Patch),
) -> Result(Doc, PatchError)

Apply a list of patches to a document, in order.

Each patch is applied to the result of the previous one. If any patch fails — a missing path, a failed Test, an out-of-bounds index — application stops at once and returns that Error. Because a Doc is immutable, the document you passed in is never left partially modified.

Examples

let assert Ok(doc) = squirtle.parse("{\"name\": \"John\", \"age\": 30}")
squirtle.apply(doc, [
  squirtle.Replace(path: "/name", value: squirtle.String("Jane")),
  squirtle.Remove(path: "/age"),
])
// => Ok(...)  // the document {"name":"Jane"}

A failing Test aborts the whole sequence:

squirtle.apply(doc, [
  squirtle.Test(path: "/name", expect: squirtle.String("Bob")),
])
// => Error(TestFailed("/name", String("Bob"), String("John")))
pub fn decode(
  doc: Doc,
  with decoder: decode.Decoder(a),
) -> Result(a, List(decode.DecodeError))

Decode a Doc into one of your own types with a gleam/dynamic/decode decoder — just as you’d decode any dynamic data.

Examples

let doc = squirtle.Object(dict.from_list([#("name", squirtle.String("John"))]))
squirtle.decode(doc, decode.at(["name"], decode.string))
// => Ok("John")
pub fn decoder() -> decode.Decoder(Doc)

A gleam/dynamic/decode decoder that turns any JSON value into a Doc.

parse uses this internally; reach for it directly to embed a Doc inside a larger decoder of your own.

Examples

json.parse("[1, true, null]", squirtle.decoder())
// => Ok(Array([Int(1), Bool(True), Null]))
pub fn diff(from from: Doc, to to: Doc) -> List(Patch)

Compute a patch that turns from into to.

Applying the result to from always reproduces to — that is, apply(from, diff(from, to)) == Ok(to). Objects are compared key by key; arrays are compared by position, so an insertion near the front shows up as a run of replaces plus an add rather than a minimal edit script.

Examples

let from = squirtle.Object(dict.from_list([#("name", squirtle.String("John"))]))
let to =
  squirtle.Object(dict.from_list([
    #("name", squirtle.String("Jane")),
    #("age", squirtle.Int(30)),
  ]))

squirtle.diff(from, to)
// => [Replace("/name", String("Jane")), Add("/age", Int(30))]
pub fn error_to_string(error: PatchError) -> String

Render a PatchError as a human-readable message — for logs or for showing to a user.

Examples

squirtle.error_to_string(squirtle.PathNotFound("/age"))
// => "Path not found: /age"
pub fn parse(
  json_string: String,
) -> Result(Doc, json.DecodeError)

Parse a JSON string into a Doc.

Returns an Error if the input is not valid JSON.

Examples

squirtle.parse("{\"name\": \"John\", \"age\": 30}")
// => Ok(Object(...))
squirtle.parse("{not json")
// => Error(...)
pub fn parse_patches(
  json_string: String,
) -> Result(List(Patch), json.DecodeError)

Parse a JSON array of operations (the on-the-wire RFC 6902 format) into a list of Patch.

Returns an Error if the JSON is invalid or contains an unknown op.

Examples

squirtle.parse_patches("[{\"op\": \"add\", \"path\": \"/name\", \"value\": \"John\"}]")
// => Ok([Add("/name", String("John"))])
pub fn patch_decoder() -> decode.Decoder(Patch)

A decoder for a single RFC 6902 operation object. Fails on an unrecognized op. Combine with decode.list to read a whole patch — which is exactly what parse_patches does.

Examples

json.parse("{\"op\":\"remove\",\"path\":\"/age\"}", squirtle.patch_decoder())
// => Ok(Remove("/age"))
pub fn patch_to_doc(patch: Patch) -> Doc

Convert a patch to the Doc (a JSON object) that represents it in RFC 6902 form. Handy if you want to serialize patches yourself instead of via patch_to_string.

Examples

squirtle.patch_to_doc(squirtle.Remove(path: "/age"))
// => Object({"op": "remove", "path": "/age"})
pub fn patch_to_string(patch: Patch) -> String

Serialize a single patch to its RFC 6902 JSON object string.

Examples

squirtle.patch_to_string(squirtle.Remove(path: "/age"))
// => "{\"op\":\"remove\",\"path\":\"/age\"}"
pub fn patches_to_string(patches: List(Patch)) -> String

Serialize a list of patches to a JSON array string — the same format parse_patches reads back.

Examples

squirtle.patches_to_string([
  squirtle.Add(path: "/name", value: squirtle.String("John")),
])
// => "[{\"op\":\"add\",\"path\":\"/name\",\"value\":\"John\"}]"
pub fn query(doc: Doc, path: String) -> Result(Doc, PatchError)

Read the node at a JSON Pointer path.

"" returns the whole document; otherwise each segment descends into an object key or an array index. Returns an Error if the path is malformed or points at something that isn’t there. To pull the result out as a typed value, pass it to decode.

Examples

let assert Ok(doc) = squirtle.parse("{\"users\": [{\"name\": \"John\"}]}")
squirtle.query(doc, "/users/0/name")
// => Ok(String("John"))
squirtle.query(doc, "/users/5")
// => Error(IndexOutOfBounds("/users/5", 5))
pub fn to_dynamic(doc: Doc) -> dynamic.Dynamic

Convert a Doc to a Dynamic so it can be fed to a gleam/dynamic/decode decoder. In most cases decode is the friendlier entry point — it wraps this for you.

Examples

squirtle.String("hi") |> squirtle.to_dynamic |> decode.run(decode.string)
// => Ok("hi")
pub fn to_json(doc: Doc) -> json.Json

Convert a Doc to a gleam/json Json value.

Reach for this when you need to hand a document to code that already speaks gleam/json — for example to nest it in a larger payload or use json.to_string_tree. If you just want a string, to_string is more direct.

Examples

import gleam/json
squirtle.Int(30) |> squirtle.to_json |> json.to_string
// => "30"
pub fn to_string(doc: Doc) -> String

Serialize a Doc to a compact JSON string (no extra whitespace).

Examples

let doc = squirtle.Object(dict.from_list([#("name", squirtle.String("John"))]))
squirtle.to_string(doc)
// => "{\"name\":\"John\"}"
Search Document