json-traits 0.1.0

Server-side helper for leaf-validator style PATCH updates: flatten JSON, diff documents, and match dotted paths
Documentation

json-traits

Server-side helper for frontends that address JSON the way leaf-validator does, and that send a PATCH of the leaves that changed.

leaf-validator is the client library this crate was written to match. A screen binds a control to a dotted location such as person.contact.phoneNumber. Its leafDiff turns an edit into one entry per leaf, { location, updatedValue }, instead of one blob for a whole new object. The client sends that list as a PATCH. Concurrent editors then overwrite only the leaves they changed. Those locations are the same dotted paths MongoDB calls dot notation. mongo-leaf-validator-example shows a server applying them. leaf-validator encourages the normalized state shape: each value lives in one place, and an update names that place.

This crate is the Rust server helper for that PATCH. JsonElementExtensions is the .NET one. Both flatten a stored document into the same leaf paths the client uses, diff two documents, and match paths against patterns so the server can accept only the leaves a caller is allowed to change. * matches one path segment.

serde_json::Value stands in for .NET's JsonElement. The methods are traits. Import a trait and its methods are available on Value, the same way a using brings an extension method into scope.

use json_traits::prelude::*;
use serde_json::json;

let left = json!({
    "prop1": { "prop2": 1 },
    "contacts": [
        { "info": { "name": "Stewie" } },
        { "info": { "number": 12 } }
    ]
});
let right = json!({
    "prop1": { "prop2": "value2" },
    "contacts": [
        { "info": { "name": "Stewie" } },
        { "info": { "number": 13 } }
    ]
});

// "prop1.prop2", "contacts.0.info.name", "contacts.1.info.number"
let _paths = left.paths_and_values();

// prop1.prop2 and contacts.1.info.number differ
let _diff = left.diff_with(&right);

let patterns = ["prop1.prop2", "contacts.*.info.name"];
assert!("contacts.0.info.name".is_supported_by(patterns));

Rust on this machine

Stable Rust is installed with rustup. New terminals pick it up from your shell profile. In a terminal that was already open, run:

. "$HOME/.cargo/env"
rustc --version

On another machine:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

This repository pins the stable toolchain in rust-toolchain.toml, including rustfmt and clippy. The first cargo command inside the directory installs that toolchain if it is missing.

Useful commands while learning the layout:

Command What it does
cargo test Runs the unit, integration, and documentation tests
cargo bench Runs the Criterion benchmarks
cargo doc --open Builds the API docs and opens them
cargo run --example list_paths < document.json Prints every leaf path in a file
cargo fmt --all -- --check Checks formatting
cargo clippy --all-targets --all-features -- -D warnings Lints the crate
cargo package --all-features Builds the crate archive
cargo publish --dry-run Checks that crates.io would accept the archive

Add it to another project

Once version 0.1.0 is on crates.io:

[dependencies]
json-traits = "0.1"
serde_json = "1"

Until then, depend on the git repository:

[dependencies]
json-traits = { git = "https://github.com/stewie1570/JsonTraits" }
serde_json = "1"

Bring the methods into scope with use json_traits::prelude::*;, or import JsonPaths, DiffJson, and PathPattern individually.

What the methods do

A leaf-validator PATCH is a list of leaf locations. These methods are how the server speaks that same shape.

paths_and_values walks a value and returns a BTreeMap<String, JsonScalar> of leaves. Object keys and array indexes become path segments:

prop1.prop2
contacts.0.info.name
contacts.1.info.number
contacts.2.info.isAwesome

A root scalar such as "value" is stored at the empty path "". Empty objects and empty arrays have no leaves, so they produce no paths.

diff_with compares those leaves. Each difference is (left, right). A path that exists on only one side uses JsonScalar::Undefined for the other side. That is the server's view of the same leaf diff the client would send. The method also works on the maps returned by paths_and_values, which is how you diff after filtering.

is_supported_by reports whether a PATCH path is one the server allows. is_a_path_match_with is called on the pattern, not the path:

"contacts.*.info.name".is_a_path_match_with("contacts.0.info.name");

* matches one whole segment. It does not match across dots, so contacts.*.info.name does not match contacts.0.info.name.last.

Behavior worth knowing

  • Paths are sorted, because the map is a BTreeMap.
  • Dots inside an object key are not escaped. "a.b" and {"a":{"b": ...}} become the same path. That matches the C# library.
  • JSON null equals JSON null. The C# DiffWith calls .Equals on the boxed value and throws when that value is null; this port treats null as a real leaf.
  • Integer 1 and decimal 1.0 are the same value. Two integers still compare exactly.

Checks before a release

.github/workflows/ci.yml gives each check its own job. Format, Clippy, tests, the list_paths example, and benchmarks start together. Packaging waits until format, Clippy, tests, and the example have succeeded. The packaged crate is tested in a later job. The publish dry run waits until that packaged test and the benchmarks have both succeeded.

.github/workflows/publish.yml runs when a pull request is merged into master. It reads version from Cargo.toml. If the tag v plus that version is not already on the repository, the version is new: the same checks run, then cargo publish, then the workflow pushes that tag. A later merge that leaves the version unchanged finds the tag and does not publish again.

Publish the crate

  1. Create the GitHub repository stewie1570/JsonTraits and push this project, including the workflow files.
  2. Create a crates.io account and verify your email.
  3. Configure publishing in one of these ways:
    • Trusted publishing (no long-lived token). On crates.io, add a trusted publisher for repository stewie1570/JsonTraits, workflow publish.yml, and environment crates-io. Create a GitHub environment named crates-io on the repository. The workflow already requests id-token: write.
    • API token. Create a crates.io token and save it as the CARGO_REGISTRY_TOKEN repository secret.
  4. Merge a pull request into master. The workflow publishes when v plus the Cargo.toml version is not already a tag. The first such merge publishes 0.1.0 and pushes v0.1.0. To release 0.2.0 later, change version in Cargo.toml and merge that pull request.

A crates.io version cannot be overwritten. If a published version is broken, cargo yank --version 0.1.0 hides it from new dependents without deleting it.

Project map

src/lib.rs                 crate root
src/scalar.rs              JsonScalar, one JSON leaf
src/flatten.rs             JsonPaths::paths_and_values
src/diff.rs                DiffJson::diff_with
src/path_pattern.rs        PathPattern
tests/                     the C# cases, ported, plus edge cases
benches/json_paths.rs      Criterion benchmarks
examples/list_paths.rs     read JSON from stdin, print paths
.github/workflows/         CI jobs and crates.io publish