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)) -
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
valueatpath. 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
pathwithvalue. The path must already exist — unlikeAdd, replace never creates a new location. -
Copy(from: String, to: String)Copy the value found at
fromand add it atto. -
Move(from: String, to: String)Move the value at
fromtoto(a remove followed by an add). -
Test(path: String, expect: Doc)Succeed only if the value at
pathequalsexpect; otherwise the wholeapplyfails withTestFailed. 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.
-
CannotRemoveRootCannot remove the root document.
-
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"