Skip to content

Latest commit

 

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

json

A JSON parser and serializer for Carp.

Installation

(load "git@github.com:carpentry-org/json@0.6.0")

Usage

Parsing

Parse a JSON string into a JSON value using JSON.parse:

(match (JSON.parse "{\"name\": \"carp\", \"version\": 1}")
  (Result.Success j) (println* &j)
  (Result.Error e) (IO.errorln &(JSON.parse-error-str &e)))

JSON.parse returns a (Result JSON ParseError). The error carries both a ParseErrorKind (one of 16 variants describing what went wrong) and a byte position. Use JSON.parse-error-str to format it for display, or match-ref on the kind to react programmatically.

Building JSON values

You can construct JSON values directly:

(def j (JSON.obj [(JSON.entry @"name" (JSON.Str @"carp"))
                  (JSON.entry @"version" (JSON.Num 1.0))
                  (JSON.entry @"active" (JSON.Bool true))]))

Or convert native Carp values via the to-json interface, which is implemented for Bool, Int, Long, Float, Double, String, and Array:

(to-json @"hello")    ; => (JSON.Str "hello")
(to-json [1 2 3])     ; => (JSON.Arr [...])
(to-json [@"a" @"b"]) ; => (JSON.Arr [(JSON.Str "a") (JSON.Str "b")])

Serialization

Convert any JSON value back to a string with str:

(match (JSON.str &j)
  (Result.Success s) (println &s)
  (Result.Error e) (IO.errorln &(JSON.serialize-error-str &e)))

JSON.str returns a (Result String SerializeError). It only fails when a JSON.Num contains NaN or infinity, neither of which is representable in JSON.

Accessing values

; Look up a key in an object
(JSON.get &j "name")             ; => (Maybe.Just (JSON.Str "carp"))

; Index into an array
(JSON.nth &arr 0)                ; => (Maybe.Just ...)

; Nested key lookup
(JSON.get-in &j &[@"data" @"users"])

; Extract typed values
(JSON.as-str &(JSON.Str @"hi"))  ; => (Maybe.Just "hi")
(JSON.as-num &(JSON.Num 3.14))   ; => (Maybe.Just 3.14)
(JSON.as-bool &(JSON.Bool true))  ; => (Maybe.Just true)

; Compare structurally: arrays by order, objects independent of member order
(= &(JSON.Num 1.0) &(JSON.Num 1.0))  ; => true

JSON Pointer

JSON.Pointer implements RFC 6901, which addresses a value inside a document with a string like /foo/0/bar:

(JSON.Pointer.get &doc "/foo/0")  ; => (Maybe.Just ...)
(JSON.Pointer.get &doc "")        ; => the whole document
(JSON.Pointer.get &doc "/a~1b")   ; => the value under the key "a/b"

Array tokens must be 0 or a positive integer without a leading zero; the - end-of-array token, out-of-range indices, and invalid pointers all return Nothing. Use escape/unescape to build or decode a single reference token (~ becomes ~0, / becomes ~1):

(JSON.Pointer.escape "a/b")     ; => "a~1b"
(JSON.Pointer.unescape "m~0n")  ; => "m~n"

JSON Patch

JSON.Patch implements RFC 6902, which expresses a change to a document as a JSON array of operation objects addressed by JSON Pointer:

(def patch
  (Result.unsafe-from-success
    (JSON.parse "[{\"op\": \"add\", \"path\": \"/tags/-\", \"value\": \"lisp\"},
                  {\"op\": \"replace\", \"path\": \"/version\", \"value\": 2},
                  {\"op\": \"move\", \"from\": \"/old\", \"path\": \"/new\"}]")))

(match (JSON.Patch.apply &doc &patch)
  (Result.Success patched) (println* &patched)
  (Result.Error e) (IO.errorln &(JSON.patch-error-str &e)))

All six operations are supported. add inserts into an array (shifting the rest right, with - appending) and inserts or replaces an object member; remove and replace require the location to exist; move may not move a location into one of its own children; test compares the addressed value with JSON.=. The empty pointer "" addresses the whole document.

Patches are atomic: if any operation fails, the returned PatchError names the index of the failing operation and the input document is left untouched.

Two limits bound the work a patch can ask for, since copy duplicates a subtree and a short patch could otherwise build a very large document. An operation that would nest the document deeper than JSON.parse accepts is rejected with DepthLimitExceeded, and a patch may insert at most json-max-patch-nodes nodes beyond the combined size of the document and the patch before SizeLimitExceeded. Neither binds on a hand-written patch.

JSON.Patch.diff goes the other way, building a patch that takes one document to another:

(def a (Result.unsafe-from-success (JSON.parse "{\"a\":1,\"b\":{\"c\":2}}")))
(def b (Result.unsafe-from-success (JSON.parse "{\"a\":1,\"b\":{\"c\":3}}")))

(JSON.Patch.diff &a &b)  ; => [{"op":"replace","path":"/b/c","value":3}]
(JSON.Patch.apply &a &(JSON.Patch.diff &a &b))  ; => (Success b)

Objects are compared member by member and arrays element by element, so a change deep inside a document costs one operation addressed by pointer rather than a copy of the value enclosing it. A key only in the target becomes an add, a key only in the source a remove, and anything else that differs a replace, including two documents of different kinds, which replace the whole document at the empty pointer. Keys are escaped, and a shrinking array is trimmed in descending index order so that every pointer still names the element it did before the trim.

Every operation diff emits is one apply accepts, so the round trip holds for any pair of documents, null members and array edits included.

JSON Merge Patch

JSON.merge-patch implements RFC 7386, which is what an HTTP API almost always means by application/merge-patch+json. The patch is a document shaped like the target, and merging it replaces the members it mentions:

(def doc (Result.unsafe-from-success (JSON.parse "{\"a\":1,\"b\":{\"c\":2}}")))
(def patch (Result.unsafe-from-success (JSON.parse "{\"b\":{\"c\":3},\"d\":4}")))

(JSON.merge-patch @&doc &patch)  ; => {"a":1,"b":{"c":3},"d":4}

A patch that is not an object replaces the target outright, a null member deletes that key, and a target that is not an object is treated as an empty one. Arrays are replaced whole, never merged element by element.

JSON.merge-diff builds the smallest patch taking one document to another, emitting null for dropped members and omitting unchanged ones:

(JSON.merge-diff &doc &other)      ; => {"b":{"c":3},"d":4}
(JSON.merge-patch @&doc &(JSON.merge-diff &doc &other))  ; => other

Two things the format cannot express: setting a member to null, since that spelling already means delete, and reaching inside an array. So a merge-diff whose target introduces a null member does not survive the round trip, and an array edit costs the whole array. Reach for JSON.Patch when you need either.

Type predicates

(JSON.null? &(JSON.Null))  ; => true
(JSON.bool? &(JSON.Null))  ; => false
(JSON.num? &(JSON.Num 1.0)) ; => true
(JSON.str? &(JSON.Str @"")) ; => true
(JSON.arr? &(JSON.Arr []))  ; => true
(JSON.obj? &(JSON.Obj {}))  ; => true

The JSON type

JSON is a sum type with six variants:

(deftype JSON
  (Null [])
  (Bool [Bool])
  (Num [Double])
  (Str [String])
  (Arr [(Array (Box JSON))])
  (Obj [(Map String (Box JSON))]))

Arrays and objects contain Boxed values because the type is recursive.

Testing

carp -x test/json.carp

Benchmarking

carp -x bench/json_bench.carp

Have fun!

About

a json library for carp

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages