Coder Social home page Coder Social logo

zulu-inuoe / jzon Goto Github PK

View Code? Open in Web Editor NEW
137.0 14.0 13.0 658 KB

A correct and safe(er) JSON RFC 8259 reader/writer with sane defaults.

License: MIT License

Common Lisp 98.30% Batchfile 1.34% Shell 0.36%
json parser common-lisp lisp serializer deserialization serialization encoder-decoder deserializer rfc8259

jzon's Introduction

jzon

A correct and safe(er) JSON RFC 8259 reader/writer with sane defaults.

Please see the section Motivation and Features for a set of motivations driving jzon and why you should consider it over the other hundred options available for JSON in CL.

Please see the changelog for a list of changes between versions.

Actions Status

Table of Contents

Quickstart

jzon is on both Quicklisp and Ultralisp, and can be loaded via

(ql:quickload '#:com.inuoe.jzon)

Most users will simply use jzon:parse for reading, and jzon:stringify for writing. These mirror the JSON methods in JavaScript.

Note: Examples in this README can be copy-pasted in your REPL if you've got a nickname set up for jzon. To follow along with the examples, use

(uiop:add-package-local-nickname '#:jzon '#:com.inuoe.jzon)

Reading

jzon:parse will parse JSON and produce a CL value

(defparameter *ht* (jzon:parse "{
  \"license\": null,
  \"active\": false,
  \"important\": true,
  \"id\": 1,
  \"xp\": 3.2,
  \"name\": \"Rock\",
  \"tags\":  [
    \"alone\"
  ]
}"))

(equalp 'null       (gethash "licence" *ht*))
(equalp nil         (gethash "active" *ht*))
(equalp t           (gethash "important" *ht*))
(equalp 1           (gethash "id" *ht*))
(equalp 3.2d0       (gethash "xp" *ht*))
(equalp "Rock"      (gethash "name" *ht*))
(equalp #("alone")  (gethash "tags" *ht*))

Writing

jzon:stringify will serialize a value to JSON:

(jzon:stringify #(null nil t 42 3.14 "Hello, world!") :stream t :pretty t)
[
  null,
  false,
  true,
  42,
  3.14,
  "Hello, world!"
 ]

Type Mappings

jzon cannonically maps types per the following chart:

JSON CL
true symbol t
false symbol nil
null symbol null
number integer or double-float
string simple-string
array simple-vector
object hash-table (equal)

Note the usage of symbol cl:null as a sentinel for JSON null

When writing, additional values are supported. Please see the section jzon:stringify.

Usage

As noted, jzon:parse and jzon:stringify suit most use-cases, this section goes into more detail, as well as an introduction to the jzon:writer interface.

jzon:parse

Function jzon:parse in &key max-depth allow-comments allow-trailing-comma allow-multiple-content max-string-length key-fn

=> value

  • in - a string, vector (unsigned-byte 8), stream, pathname, or jzon:span
  • max-depth - a positive integer, or a boolean
  • allow-comments - a boolean
  • allow-trailing-comma - a boolean
  • allow-multiple-content - a boolean
  • max-string-length - nil, t, or a positive integer
  • key-fn - a designator for a function of one argument, or a boolean

value - a jzon:json-element (see Type Mappings)

Description

Reads JSON from in and returns a jzon:json-element per Type Mappings.

in can be any of the following types:

  • string
  • (vector (unsigned-byte 8)) - octets in utf-8
  • stream - character or binary in utf-8
  • pathname - jzon:parse will open the file for reading in utf-8
  • jzon:span - denoting a part of a string/vector

Tip: You can also use a displaced array to denote a region of an array without copying it.

The keyword arguments control optional features when reading:

  • :allow-comments controls if we allow single-line // comments and /**/ multiline block comments.
  • :allow-trailing-comma controls if we allow a single comma , after all elements of an array or object.
  • :allow-multiple-content controls if we allow for more than one element at the 'toplevel' see below
  • :key-fn is a function of one value which is called on object keys as they are read, or a boolean (see below)
  • :max-depth controls the maximum depth allowed when nesting arrays or objects.
  • :max-string-length controls the maximum length allowed when reading a string key or value.

max-string-length may be an integer denoting the limit, or

  • nil - No limit barring array-dimension-limit
  • t - Default limit

When either max-depth or max-string-length is exceeded, jzon:parse shall signal a jzon:json-parse-limit-error error.

allow-multiple-content

JSON requires there be only one toplevel element. Using allow-multiple-content tells jzon:parse to stop after reading one full toplevel element:

(jzon:parse "1 2 3" :allow-multiple-content t) #| => 1 |#

When reading a stream we can call jzon:parse several times:

(with-input-from-string (s "1 2 3")
  (jzon:parse s :allow-multiple-content t)  #| => 1 |#
  (jzon:parse s :allow-multiple-content t)  #| => 2 |#
  (jzon:parse s :allow-multiple-content t)) #| => 3 |#

⚠️ When reading numbers, null, false, or true, they must be followed by whitespace. jzon:parse shall signal an error otherwise:

(jzon:parse "123[1, 2, 3]" :allow-multiple-content t) #| error |#

This is to prevent errors caused by the lookahead necessary for parsing non-delimited tokens.

This is not required when using jzon:parse-next.

key-fn

When parsing objects, key-fn is called on each of that object's keys (simple-string):

(jzon:parse "{ \"x\": 0, \"y\": 1 }" :key-fn #'print)
"x"
"y"
#| #<HASH-TABLE :TEST EQUAL :COUNT 2 {1006942E83}> |#

the role of key-fn is to allow the user to control how keys end up as hash table keys. The default key-fn will share object keys between all objects during parse. See Object Key Pooling.

As an example, alexandria:make-keyword can be used to make object keys into keywords:

(jzon:parse "[ { \"x\": 1, \"y\": 2 }, { \"x\": 3, \"y\": 4 } ]" :key-fn #'alexandria:make-keyword)

(defparameter *v* *)

(gethash :|x| (aref *v* 0)) #| => 1 |#
(gethash :|y| (aref *v* 0)) #| => 2 |#

Pass nil to key-fn in order to avoid key pooling:

(jzon:parse "[ { \"x\": 1, \"y\": 2 }, { \"x\": 3, \"y\": 4 } ]" :key-fn nil)

(defparameter *v* *)

(gethash "x" (aref *v* 0)) #| => 1 |#
(gethash "y" (aref *v* 0)) #| => 2 |#

This may help speed up parsing on highly heterogeneous JSON.

Note: It is recommended leave this as default. The performance improvement is usually not substantive enough to warrant duplicated strings, and interning strings from untrusted JSON is a security risk.

jzon:span

Function jzon:span in &key start end

=> span

  • in - a string, stream ' or vector (unsigned-byte 8)
  • start, end - bounding index designators of sequence. The defaults for start and end are 0 and nil, respectively.

span a span object representing the range.

Description

Create a span to be used in jzon:parse, or jzon:make-parser in order to specify a bounded start and end for a string, stream or vector.

NOTE: For streams, only input streams are allowed.

Example
(jzon:parse (jzon:span "garbage42moregarbage" :start 7 :end 9)) 
#| => 42 |#

jzon:stringify

Function jzon:stringify value &key stream pretty coerce-key replacer

=> result

  • value - a jzon:json-element, or other value (see below)
  • stream - a destination like in format, or a pathname
  • pretty - a boolean
  • replacer - a function of two arguments (see below)
  • coerce-key - a function of one argument, or nil (see below)
  • max-depth - a positive integer, or a boolean

result - nil, or a string

Description

Serializes value to JSON and writes it to stream.

If pretty is true, the output is formatted with spaces and newlines.

max-depth limits the depth of nesting arrays/objects. Use nil to disable it, or t to set to default.

In addition to serializing jzon:json-element values per Type Mappings, jzon:stringify allows other values. See Additionally Supported Types For Writing and Custom Serialization.

max-string-length may be an integer denoting the limit, or

  • nil - No limit barring array-dimension-limit
  • t - Default limit

When either max-depth is exceeded, jzon:stringify shall signal a jzon:json-write-limit-error error.

stream

stream is a destination as in format, or a pathname:

  • t - Writes to *standard-output*
  • nil - Writes to a fresh string and returns it
  • an open stream - Writes to that stream
  • a string with a fill-pointer - writes to that string as with-output-to-string
  • a pathname - Must designate a file. Creates or supersedes a new file and writes to it
coerce-key

A function for coercing keys to strings. See Custom Serialization.

replacer

A designator for a 'replacer' function as outlined in the JavaScript JSON.parse method.

However, due to the lack of undefined in CL, we make use of multiple return values to indicate replacement.

(jzon:stringify #("first" "second" "third")
                :stream t :pretty t
                :replacer (lambda (key value)
                            (case key
                              #| Always include toplevel |#
                              ((nil) t)
                              #| Do not include |#
                              (0 nil)
                              #| Include |#
                              (1 t)
                              #| Include, but replace |#
                              (2 (values t (format nil "Lupin the ~A" value))))))
[
  "second",
  "Lupin the third"
]

Additionally Supported Types For Writing

When writing, the following type mappings are also available:

CL JSON
symbol string (symbol-name, but see Symbol key case)
character string (string)
pathname string (uiop:native-namestring)
real number
array array* - multidimensional arrays are arrays-of-arrays
sequence array
standard-object object
structure-object† object

*: #2A((1 2) (3 4)) becomes [[1,2],[3,4]]

†: On supported implementations where structure slots are available via the MOP.

If you have an alist/plist you wish to write, we recommend the use of either alexandria:alist-hash-table or alexandria:plist-hash-table, or use one of the methods in Custom Serialization.

Previously, jzon attempted to detect alists/plists, but this was error-prone and came with many edge-cases.

Symbol key case

By default, when a symbol represents a key in a JSON object, its name will be downcased, unless it contains mixed-case characters.

For example:

(let ((ht (make-hash-table :test 'equal)))
  (setf (gethash 'only-keys ht) 'are-affected)
  (setf (gethash '|noChange| ht) '|when used|)
  (setf (gethash "AS A" ht) '|value|)

  (jzon:stringify ht :pretty t :stream t))
{
  "only-keys": "ARE-AFFECTED",
  "noChange": "when Used",
  "AS A": "value"
}

Please see Custom Serialization and write-value for more details.

jzon:writer

A second way of writing JSON is to use the jzon:writer API, which allows you to fully control the values, order, and types, including arbitrary logic.

An example to start:

(jzon:with-writer* (:stream *standard-output* :pretty t)
  (jzon:with-object*
    (jzon:write-key* :age)
    (jzon:write-value* 24)
    
    (jzon:write-property* :colour :blue)
    
    (jzon:write-properties* :outside nil
                            :interests #()
                            :talent 'null)

    (jzon:write-key* "an-array")
    (jzon:with-array*
      (jzon:write-values* :these :are :elements))

    (jzon:write-key* "another array")
    (jzon:write-array* :or "you" "can use this")))
{
  "age": 24,
  "colour": "BLUE",
  "outside": false,
  "interests": [],
  "talens": null,
  "an-array": [
    "THESE",
    "ARE",
    "ELEMENTS"
  ],
  "another array": [
    "OR",
    "you",
    "can use this"
  ]
}

jzon:make-writer and jzon:with-writer* accept the same arguments as jzon:stringify.

Note All writer-related functions are duplicated in ones suffixed with * which use the jzon:*writer* special variable, and ones lacking the suffix, where the writer is the first argument.

For example, these two are equivalent:

(jzon:with-writer* ()
  (write-value* "foo"))
(with-writer (writer)
  (write-value writer "foo"))

jzon:make-writer

Function jzon:make-writer &key stream pretty coerce-key replacer max-depth => writer

  • stream - an open character or binary output stream
  • pretty - a boolean
  • coerce-key - a function of one argument, or nil (see below)
  • replacer - a function of two arguments (see below)
  • max-depth - a positive integer, or a boolean

writer - a jzon:writer

Description

Construct a jzon:writer for writing JSON via subsequent calls to jzon:write-value.

If pretty is true, all output is indented with spaces and newlines.

max-depth limits the depth of nesting arrays/objects. Use nil to disable it, or t to set to default.

When either max-depth is exceeded, functions which increase the depth, such as jzon:begin-array or jzon:begin-object shall signal a jzon:json-write-limit-error error.

stream

stream is a destination as in format, or a pathname:

  • t - Writes to *standard-output*
  • nil - Writes to the void
  • an open stream - Writes to that stream
  • a string with a fill-pointer - writes to that string as with-output-to-string
  • a pathname - Must designate a file. Creates or supersedes a new file and writes to it
coerce-key

A function for coercing keys to strings. See Custom Serialization.

replacer

Please see the section in jzon:stringify.

⚠️ Because jzon:make-writer can open a file, it is recommended you use jzon:with-writer instead, unless you need indefinite extent.

jzon:close-writer

Function jzon:close-writer writer

=> writer

Description

Closes the writer and releases any held resources.

jzon:with-writer

Macro jzon:with-writer (var &rest args) declaration* form*

Macro jzon:with-writer* (&rest args) declaration* form*

  • var - a symbol
  • args - initialization arguments to jzon:make-writer
  • declaration - a declare expression, not evaluated
  • forms - an implicig progn

Description

As jzon:make-writer + unwind-protect + jzon:close-writer.

Use this like you would with-open-file.

jzon:with-writer* binds the variable jzon:*writer*

jzon:write-value

Generic Function jzon:write-value writer value

  • writer - a jzon:writer
  • value - a jzon:json-element, or other value (see below)

=> writer

Description

jzon:write-value writer value - Writes any value to the writer. Usable when writing a toplevel value, array element, or object property value.

(jzon:write-value writer "Hello, world")

value can be any jzon:json-element, but other values supported. See Custom Serialization.

Other Streaming Writer Functions

Here we briefly document all the additional helper functions for interfacing with the jzon:writer.

Because the entire API is duplicated, we only refer to the *-suffixed functions here for brevity.

Note: To save in verbosity in the following examples, we assume to have a jzon:*writer* bound in the following examples.

For trying at the REPL, use something like:

#| Bind `jzon:*writer*` |#
(setf jzon:*writer* (jzon:make-writer :stream *standard-output* :pretty t))

#| Start an array so we can write multiple values |#
(jzon:begin-array*)
Arrays

jzon:begin-array writer - Begin writing an array

json:end-array writer - Finish writing an array.

(jzon:begin-array*)
(jzon:write-value* 0)
(jzon:write-value* 1)
(jzon:write-value* 2)
(jzon:end-array*)

jzon:with-array writer - Open a block to begin writing array values.

(jzon:with-array*
  (jzon:write-value* 0)
  (jzon:write-value* 1)
  (jzon:write-value* 2))

jzon:write-values writer &rest values* - Write several array values.

(jzon:with-array*
  (jzon:write-values* 0 1 2))

jzon:write-array - Open a new array, write its values, and close it.

(jzon:write-array* 0 1 2)
Objects

Function jzon:begin-object writer - Begin writing an object.

Function json:end-object writer - Finish writing an object.

(jzon:begin-object*)
(jzon:write-property* "age" 42)
(jzon:end-object*)

Macro jzon:with-object writer - Open a block where you can begin writing object properties.

(jzon:with-object*
  (jzon:write-property* "age" 42))

Function jzon:write-key writer key - Write an object key.

(jzon:with-object*
  (jzon:write-key* "age")
  (jzon:write-value* 42))

Function json:write-property writer key value - Write an object key and value.

(jzon:with-object*
  (jzon:write-property* "age" 42))

Function jzon:write-properties writer &rest key* value* - Write several object keys and values.

(jzon:with-object*
  (jzon:write-properties* "age" 42
                          "colour" "blue"
                          "x" 0
                          "y" 10))

Function jzon:write-object writer &rest key* value* - Open a new object, write its keys and values, and close it.

(jzon:write-object* "age" 42
                    "colour" "blue"
                    "x" 0
                    "y" 10)

Streaming Writer Example

jzon:stringify could be approximately defined as follows:

(defun my/jzon-stringify (value)
  (labels ((recurse (value)
             (etypecase value
               (jzon:json-atom
                 (jzon:write-value* value))
               (vector
                 (jzon:with-array*
                   (map nil #'recurse value)))
               (hash-table
                 (jzon:with-object*
                   (maphash (lambda (k v)
                              (jzon:write-key* k)
                              (recurse v))
                            value))))))
    (with-output-to-string (s)
      (jzon:with-writer* (:stream s)
        (recurse value)))))

Custom Serialization

When using either jzon:stringify or jzon:write-value, you can customize writing of any values not covered in the Type Mappings in an few different ways.

The call graph looks like this:

jzon:write-value => (method standard-object) => jzon:coerced-fields

standard-object

By default, if your object is a standard-object, it will be serialized as a JSON object, using each of its bound slots as keys.

A slot's :type is used to interpret the meaning of nil in that slot:

  1. boolean - false
  2. list - []
  3. null - null

Note: When unspecified, a slot will serialize nil as null.

standard-object Serialization Example

Consider the following classes:

(defclass job ()
  ((company
    :initarg :company
    :reader company)
   (title
    :initarg :title
    :reader title)))

(defclass person ()
  ((name
     :initarg :name
     :reader name)
   (alias
     :initarg :alias)
   (job
     :initarg :job
     :reader job)
   (married
     :initarg :married
     :type boolean)
   (children
    :initarg :children
    :type list)))

Now consider the following scenarios:

(jzon:stringify (make-instance 'person :name "Anya" :job nil
                               :married nil :children nil)
                :pretty t :stream t)`
{
  "name": "Anya",
  "job": null,
  "married": false,
  "children": []
}
  1. alias is omitted, because it is unbound
  2. job serializes as null, because it has no specified :type
  3. married serializes as false, because it is specified as a :boolean
  4. children serializes as [], because it is specified as a list

A second example:

(jzon:stringify (make-instance 'person :name "Loid" :alias "Twilight" 
                               :job (make-instance 'job :company "WISE" :title "Agent")
                               :married t
                               :children (list (make-instance 'person :name "Anya"
                                                              :job nil :married nil 
                                                              :children nil)))
                :pretty t :stream t)
{
  "name": "Loid",
  "alias": "Twilight",
  "job": {
    "company": "WISE",
    "title": "Agent"
  },
  "married": true,
  "children": [
    {
      "name": "Anya",
      "job": null,
      "married": false,
      "children": []
    }
  ]
}

Here we can note:

  1. We now include alias as it is bound
  2. job recurses into the job object
  3. married is t, which serializes as true
  4. children now contains a child element

If you require more control, please see the generic function jzon:coerced-fields.

Specializing coerced-fields

The generic function jzon:coerced-fields is called by jzon when writing a value as a JSON object in order to find what properties that object has.

It is useful when the standard-object almost does what you want, but you want some more control.

jzon:coerced-fields should return a list of 'fields', which are two (or three) element lists of the form:

(name value &optional type)
  • name can be any suitable key name.
  • value can be any value - it'll be coerced if necessary.
  • type is used as :type above, in order to resolve ambiguities with nil.

coerced-fields Example

Consider our previous person class. Say we wish to:

  1. Show their name
  2. Add a type to specify they are a person
  3. Show false for their job if not applicable
(defmethod jzon:coerced-fields ((person person))
  (list (list "name" (name person))
        (list "type" "person")
        (list "job" (job person) 'boolean)))

now

(jzon:stringify (make-instance 'person :name "Anya" :job nil
                               :married nil :children nil) 
                :pretty t :stream t)`
{
  "name": "Anya",
  "type": "person",
  "job": false
}

If you require even more control, please see the section on (write-values)[#write-values] where we make use of the writer API.

Specializing jzon:write-value

The final way to support custom serialization, is the jzon:write-value generic function.

This allows you to emit whatever values you wish for a given object.

Once again considering our person and job classes above, we can specialize a method for jzon:write-value on job:

(defmethod jzon:write-value (writer (job job))
  (cond
    ((string= (company job) "WISE")
      (jzon:write-object writer
                         "company" "Eastern Healthcare"
                         "title" (aref #("Psychologist" "Physician" "Janitor" "Surgeon" "Receptionist") (random 5))))
    ((string= (title job) "Assassin")
      (jzon:with-object writer
        (jzon:write-properties writer
                               "company" "City Hall"
                               "title" "Clerk")
        (jzon:write-key writer "lifelines")
        (jzon:write-array writer "Yuri" "Camilla")))
    ((string= (company job) "State Police")
      (jzon:write-string "Classified"))
    (t #| Allow default to take over |#
      (call-next-method))))

And some examples:

(jzon:stringify (make-instance 'job :company "WISE" :title "Agent") :stream t :pretty t)
{
  "company": "Eastern Healthcare",
  "title": "Psychologist"
}
(jzon:stringify (make-instance 'job :company "The Butcher" :title "Assassin") :stream t :pretty t)
{
  "company": "City Hall",
  "title": "Clerk",
  "lifelines": [
    "Yuri",
    "Camilla"
  ]
}

And something that cannot be done with the other methods:

(jzon:stringify (make-instance 'job :company "State Police" :title "Interrogator") :stream t :pretty t)
"Classified"

jzon:parser

Similarly to jzon:writer, jzon:parser exists to parse JSON in parts by providing a simple, SAX-like streaming API.

An example:

(jzon:with-parser (parser "{\"x\": 1, \"y\": [2, 3], \"live\": false}")
  (jzon:parse-next parser)  #| :begin-object |#
  (jzon:parse-next parser)  #| :object-key, "x" |#
  (jzon:parse-next parser)  #| :value, 1 |#
  (jzon:parse-next parser)  #| :object-key, "y" |#
  (jzon:parse-next parser)  #| :begin-array |#
  (jzon:parse-next parser)  #| :value, 2 |#
  (jzon:parse-next parser)  #| :value, 3 |#
  (jzon:parse-next parser)  #| :end-array |#
  (jzon:parse-next parser)  #| :object-key, "live" |#
  (jzon:parse-next parser)  #| :value, nil |#
  (jzon:parse-next parser)  #| :end-object |#
  (jzon:parse-next parser)) #| nil |#

jzon:make-parser

Function jzon:make-parser in &key allow-comments allow-trailing-comma allow-multiple-content max-string-length key-fn

=> writer

  • in - a string, vector (unsigned-byte 8), stream, pathname, or jzon:span
  • allow-comments - a boolean
  • allow-trailing-comma - a boolean
  • allow-multiple-content - a boolean
  • max-string-length - a positive integer
  • key-fn - a designator for a function of one argument, or a boolean

value - a jzon:parser

Description

Creates a parser from in for use in subsequent jzon:parse-next.

The behaviour of jzon:parser is analogous to jzon:parse, except you control the interpretation of the JSON events.

in can be any of the following types:

  • string
  • (vector (unsigned-byte 8)) - octets in utf-8
  • stream - character or binary in utf-8
  • pathname - jzon:make-parser will open the file for reading in utf-8
  • jzon:span - denoting a part of a string/vector

Tip: You can also use a displaced array to denote a region of an array without copying it.

When max-string-length is exceeded, jzon:parse-next shall signal a jzon:json-parse-limit-error error.

JSON requires there be only one toplevel element. Using allow-multiple-content allows parsing of multiple toplevel JSON elements. See jzon:parse-next on how this affects the results.

⚠️ Because jzon:make-parser can open a file, it is recommended you use jzon:with-parser instead, unless you need indefinite extent.

jzon:close-parser

Function jzon:close-parser parser

=> parser

Description

Closes the parser and releases any held resources.

jzon:with-parser

Macro jzon:with-parser (var &rest args) declaration* form*

  • var - a symbol.
  • declaration - a declare expression; not evaluated.
  • form - an implicit progn

Description

As jzon:make-parser + unwind-protect + jzon:close-parser.

Use this like you would with-open-file.

jzon:parse-next

Function jzon:parse-next parser

=> event, value

  • event - a symbol, see below
  • value - a jzon:json-atom

Description

Read the next event from the jzon:parser.

Always returns two values indicating the next available event on the JSON stream:

event value
:value jzon:json-atom
:begin-array nil
:end-array nil
:begin-object nil
:object-key simple-string (depending on key-fn)
:end-object nil
nil nil

Note: The nil event represents conclusion of a toplevel value, and should be taken as "parsing has successfully completed".

When the parser's max-string-length is exceeded, jzon:parse-next shall signal a jzon:json-parse-limit-error error. See jzon:make-parser.

allow-multiple-content

When allow-multiple-content enabled in the jzon:parser, it shall emit the nil event after no more content is available.

(jzon:with-parser (parser "1 2")
  (jzon:parse-next parser)  #| :value, 1 |#
  (jzon:parse-next parser)  #| :value, 2 |#
  (jzon:parse-next parser)) #| nil, nil |#

jzon:parse-next-element

Function jzon:parse-next-element parser &key max-depth eof-error-p eof-value

=> value

  • parser - a jzon:parser.
  • max-depth - a positive integer, or a boolean
  • eof-error-p - a generalized boolean. The default is true.
  • eof-value - an object. The default is nil.

value - a jzon:json-element (see Type Mappings)

Description

Read the next element from the jzon:parser.

This is a utility function around jzon:parse-next that behaves similar to jzon:parse, reading a full jzon:json-element from a jzon:parser.

eof-error-p and eof-value

Similar to cl:read-line, eof-error-p controls whether we should signal an error when no more elements are available, or whether to return eof-value.

⚠️ These values are most relevant when reading array elements. See the following example:

(jzon:with-parser (p "[1, 2]")
  (jzon:parse-next p)  #| :begin-array, nil |#
  (jzon:parse-next-element p :eof-error-p nil)  #| 1 |#
  (jzon:parse-next-element p :eof-error-p nil)  #| 2 |#
  (jzon:parse-next-element p :eof-error-p nil)) #| nil |#

Use this when you want to read a full sub-object from a parser, as follows:

(jzon:with-parser (p "{ \"foo\": [1, 2, 3] }")
  (jzon:parse-next p)          #| :begin-object, nil |#
  (jzon:parse-next p)          #| :object-key, "foo" |#
  #| Saw `:object-key`, so next must be a value |#
  (jzon:parse-next-element p)  #| #(1 2 3) |#
  (jzon:parse-next p))         #| :end-object, nil |#

Streaming Parser Example

jzon:parse could be approximately defined as follows:

(defun my/jzon-parse (in)
  (jzon:with-parser (parser in)
    (let (top stack key)
      (flet ((finish-value (value)
                (typecase stack
                  (null                 (setf top value))
                  ((cons list)          (push value (car stack)))
                  ((cons hash-table)    (setf (gethash (pop key) (car stack)) value)))))
        (loop
          (multiple-value-bind (event value) (jzon:parse-next parser)
            (ecase event
              ((nil)          (return top))
              (:value         (finish-value value))
              (:begin-array   (push (list) stack))
              (:end-array     (finish-value (coerce (the list (nreverse (pop stack))) 'simple-vector)))
              (:begin-object  (push (make-hash-table :test 'equal) stack))
              (:object-key    (push value key))
              (:end-object    (finish-value (pop stack))))))))))

Motivation and Features

In writing jzon, we prioritize the following properties, in order:

Safety

RFC 8259 allows setting limits on things such as:

  • Number values accepted
  • Nesting level of arrays/objects
  • Length of strings

We should be safe in the face of untrusted JSON and will error on 'unreasonable' input out-of-the-box, such as deeply nested objects or overly long strings.

Type Safety

All of jzon's public API's are type safe, issuing cl:type-error as appropriate.

Some other JSON parsers will make dangerous use of features like optimize (safety 0) (speed 3) without type-checking their public API:

CL-USER> (parse 2)
; Debugger entered on #<SB-SYS:MEMORY-FAULT-ERROR {1003964833}>

Such errors are unreasonable.

Avoid Infinite Interning

jzon chooses to (by default) keep object keys as strings. Some libraries choose to intern object keys in some package. This is dangerous in the face of untrusted JSON, as every unique key read will be added to that package and never garbage collected.

Avoid Stack Exhaustion

jzon:parse is written in an iterative way which avoids exhausting the call stack. In addition, we provide :max-depth to guard against unreasonable inputs. For even more control, you can make use of the jzon:with-parser API's to avoid consing large amounts of user-supplied data to begin with.

Correctness

This parser is written against RFC 8259 and strives to adhere strictly for maximum compliance and few surprises.

It also has been tested against the JSONTestSuite. See the JSONTestSuite directory in this repo for making & running the tests.

In short, jzon is the only CL JSON library which correctly:

  • declines all invalid inputs per that suite
  • accepts all valid inputs per that suite

Additionally, jzon is one of a couple which never hard crash due to edge-cases like deeply nested objects/arrays.

Unambiguous values

Values are never ambiguous between [], false, {}, null, or a missing key.

Compatible Float IO

While more work is doubtlessly necessary to validate further, care has been taken to ensure floating-point values are not lost between (jzon:parse (jzon:stringify f)), even across CL implementations.

In particular, certain edge-case values such as subnormals shall parse === with JavaScript parsing libraries.

Convenience

You call jzon:parse, and you get a reasonably standard CL object back. You call jzon:stringify with a reasonably standard CL object and you should get reasonable JSON.

  • No custom data structures or accessors required
  • No worrying about key case auto conversion on strings, nor or hyphens/underscores replacement on symbols.
  • No worrying about what package symbols are interned in (no symbols).
  • No worrying about dynamic variables affecting a parse as in cl-json, jonathan, jsown. Everything affecting jzon:parse is given at the call-site.

jzon:parse also accepts either a string, octet vector, stream, or pathname for simpler usage over libraries requiring one or the other, or having separate parse functions.

Finally, all public API's strive to have reasonable defaults so things 'Just Work'.

Performance

While being the last emphasized feature, it is still important to for jzon to perform best-in-class when it comes to reducing parsing times and memory usage.

The general goal benchmark is for jzon to live in the 50% jsown range. This means that if jsown takes 1 second to parse, we will have succeeded if jzon takes <=2 seconds.

With this, jzon will generally outperform all other libraries.

Importantly, we also strive to be safe even in (optimize (speed 3) (safety 0)) environments.

vs jsown

jsown is used as the golden standard when it comes to performance, as it offers consistently fast parsing speeds on a wide variety of inputs.

However, with jzon, we have much higher scores when considering jzon's priorities.

Consider this REPL interaction when considering safety and correctness:

CL-USER(97): (jsown:parse ",1,]")

1
CL-USER(98): (jsown:parse ",1,what]")

1
CL-USER(99): (jsown:parse "[,1,what]")
fatal error encountered in SBCL pid 1238017716:
should not get access violation in dynamic space

Welcome to LDB, a low-level debugger for the Lisp runtime environment.
ldb>

jsown will gladly accept blatantly wrong JSON and produce incorrect results. It has faults such as thinking this is true, and believes no is acceptably null.

If Performance is you #1 concern, and you're not afraid of your entire process crashing on a stray comma, jsown might be for you.

Please see the JSONTestSuite results with jsown here for several other failures.

vs jonathan

jonathan boasts incredible performance .. on JSON smaller than 200 bytes, its performance on SBCL tanks on anything larger.

On my machine, parsing a 25MB JSON file, already pre-loaded into a simple-string, took over 19 minutes.

In addition, it shares similar correctness issues as jsown, though (usually) not landing me in the ldb:

CL-USER(39): (jonathan:parse "12,]???")
CL-USER(40): (jonathan:parse 2)

debugger invoked on a SB-SYS:MEMORY-FAULT-ERROR in thread
#<THREAD "main thread" RUNNING {10010E0073}>:
  Unhandled memory fault at #xFFFFFFFFFFFFFFFD.

Please see the JSONTestSuite results with Jonathan here for several other failures.

Object key pooling

By default, jzon will keep track of object keys each jzon:parse (or jzon:make-parser), causing string= keys in a nested JSON object to be shared (eq):

(jzon:parse "[{\"x\": 5}, {\"x\": 10}, {\"x\": 15}]")

In this example, the string x is shared (eq) between all 3 objects.

This optimizes for the common case of reading a JSON payload containing many duplicate keys.

Tip: This behaviour may be altered by supplying a different :key-fn to jzon:parse or jzon:make-parser.

base-string coercion

When possible, strings will be coerced to cl:simple-base-string. This can lead to upwards of 1/4 memory usage per string on implementations like SBCL, which store strings internally as UTF32, while base-string can be represented in 8 bits per char.

Dependencies

License

See LICENSE.

jzon was originally a fork of st-json, but I ended up scrapping all of the code except for for the function decoding Unicode.

Alternatives

There are many CL JSON libraries available, and I defer to Sabra Crolleton's definitive list and comparisons https://sabracrolleton.github.io/json-review.

But for posterity, included in this repository is a set of tests and results for the following libraries:

I believe jzon to be the superior choice and hope for it to become the new, true de-facto library in the world of JSON-in-CL once and for all.

jzon's People

Contributors

anranyicheng avatar cgay avatar fosskers avatar iamrasputin avatar kilianmh avatar louis77 avatar s-clerc avatar uthar avatar vvcarvalho avatar zulu-inuoe avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

jzon's Issues

Position-tracking parser

There should be a feature available for jzon:parse (or perhaps a separate entry point?) to allow tracking source location, whitespace, comments, and original literal text[1] while reading JSON data.

The use-case for this is using jzon to edit JSON files, such as a configuration file, while preserving all source formatting & comments.

[1]: For example, if the original JSON was 0.201e5, we don't want to output 20100.0, and if the input was "\u0020", we don't want to output " "

Necessarily, we'd need to either track this information in a custom data structure, or perhaps track it separately from the returned data.

While tracking it separately has the advantage of keeping the simple 'returns a hash-table/vector/etc' interface, it complicates any code wanting to manually edit this formatting.

Better subsequence support

I've been asked several times for how to use parse on a part of an existing vector ala :start/:end can do with CL functions like find.

While jzon works with displaced arrays for this purpose, they have the downsides of

  1. being incredibly unergonomic to construct: `(make-array (- (length array) start-offset) :element-type (array-element-type array) :displaced-to array :displaced-index-offset start-offset)
  2. bringing a performance penalty on access

I'd like to address this somehow. The options I can think of atm are:

  1. Add :start/:end to jzon:parse and jzon:make-parser
  2. Add a jzon:span function to represent 'spans' of a sequence using an dedicated jzon data type
  3. Add a jzon:span function to construct a displaced array for the user in a less painful way

Design-wise, I'm opposed to 1 on the basis that this argument only makes sense for a subset of inputs to parse and friends, and I'd like to further expand the inputs permitted in the future

From the perspective of the API, 2 and 3 are equivalent as the user sees the same: (jzon:parse (jzon:span seq :start 5 :end 10)).

Where the differences are more significant is that with 3, if we want to be fully compliant with the spec, there's not much room for optimization in the jzon:reader interface because technically the user could modify the input vector while reading.
That said, this is such an edge-case, that if a user encounters this issue it may be better to deal with it then and there.

I'm leaning on 2 to be conservative and future proof.

Provide restarts for parsing errors?

Should investigate being a Good CL Citizen and providing meaningful restarts where they Make Sense ™️ ©️ ®️ .

My main concern is how they might impact performance. Performance isn't the main concern of this library, but I don't want users to pay for features they don't use, and in my experience, restarts are primarily useful during repl development and not 'delivered code'

But that said, a good set of restarts is quite helpful during development.

Automated tests for ECL

It'd be nice to run the automated tests on ECL by updating test.bat and test.sh to also run on ECL

Add installation instructions to README

Hi! Could you add installation instructions to the 'jzon' README, please?

It took me longer than I'd care to admit tonight to realize that I couldn't:

(ql:quickload "jzon")

and instead needed to do:

(ql:quickload "com.inuoe.jzon")

Something simple like would do the trick:

Installation instructions:

(ql:quickload "com.inuoe.jzon")

Thank you for making an awesome, useful, compliant, library!

Stringify on nested lists

At times stringify tries too hard to recurse into the data structure. Consider:

(stringify '((foo . bar) (baz . ((1 2 3) (4 5 6)))))
"{\"foo\":\"BAR\",\"baz\":{\"1\":[2,3],\"4\":[5,6]}}"

I was hoping for a result like:

"{\"foo\":\"bar\",\"baz\":[[1,2,3],[4,5,6]]}"

which is what I would get from cl-json. Instead stringify tries to make a key value out of each sublist. Is there any way of getting there?

Non-square multi-dimensional arrays stringifying incorrectly

Looks like non-square multi-dimensional arrays aren't stringifying correctly:

; Square array (2×2) - works correctly
(jzon:stringify #2A((0 1) (2 3)))
=> "[[0,1],[2,3]]"

; Rectangular (2×3) - wrong elements
(jzon:stringify #2A((0 1 2) (3 4 5)))
=> "[[0,1,2],[2,3,4]]"
; Should be "[[0,1,2],[3,4,5]]"

; Rectangular (3×2) - error signaled
(jzon:stringify #2A((0 1) (2 3) (4 5)))

Invalid index 6 for (SIMPLE-ARRAY T (3 2)), should be a non-negative integer below 6.
   [Condition of type SB-INT:INVALID-ARRAY-INDEX-ERROR]
; Should be "[[0,1],[2,3],[4,5]]"

SBCL 2.3.1, using latest jzon from Quicklisp (jzon-20230215-git)

CCL/LW/ACL: DEFCONSTANT needs an EVAL-ALWAYS

Clozure Common Lisp Version 1.12 (v1.12) LinuxX8664

For more information about CCL, please see http://ccl.clozure.com.

CCL is free software.  It is distributed under the terms of the Apache
Licence, Version 2.0.
? (ql:quickload :com.inuoe.jzon)
To load "com.inuoe.jzon":
  Load 6 ASDF systems:
    asdf closer-mop documentation-utils flexi-streams
    trivial-gray-streams uiop
  Install 2 Quicklisp releases:
    float-features jzon
Downloading http://beta.quicklisp.org/archive/float-features/2023-02-14/float-features-20230214-git.tgz
##########################################################################
Downloading http://beta.quicklisp.org/archive/jzon/2023-02-15/jzon-20230215-git.tgz
##########################################################################
; Loading "com.inuoe.jzon"
[package trivial-indent]..........................
[package documentation-utils].....................
[package float-features]..........................
[package com.inuoe.jzon/eisel-lemire]..
Read error between positions 3533 and 3621 in /home/phoe/.roswell/lisp/quicklisp/dists/quicklisp/software/jzon-20230215-git/src/eisel-lemire.lisp.
> Error: Unbound variable: +%POW10-MIN+
> While executing: CCL::CHEAP-EVAL-IN-ENVIRONMENT, in process listener(1).
> Type :GO to continue, :POP to abort, :R for a list of available restarts.
> If continued: Retry getting the value of +%POW10-MIN+.
> Type :? for other options.
1 > 

These two need an EVAL-WHEN :COMPILE-TOPLEVEL around them so that the definition is always available at compilation time:

(defconstant +%pow10-min+ -348)
(defconstant +%pow10-max+ +347)

Can't load it into CLISP

:info:build          #<STANDARD-CLASS FUNDAMENTAL-CHARACTER-INPUT-STREAM>) are
:info:build          inconsistent
:info:build WARNING: (class-precedence-list #<STANDARD-CLASS FLEXI-IO-STREAM>) and
:info:build          (class-precedence-list #<STANDARD-CLASS FLEXI-OUTPUT-STREAM>) are
:info:build          inconsistent
:info:build WARNING: in CHECK in lines 53..59 : variable READER is assigned but not read
:info:build WARNING: in #:|69 71 (DEFMETHOD FORMAT-DOCUMENTATION (# TYPE VAR ...)
:info:build          ...)-18-1-1| in lines 69..71 : variable TYPE is not used.
:info:build          Misspelled or missing IGNORE declaration?
:info:build WARNING: in #:|69 71 (DEFMETHOD FORMAT-DOCUMENTATION (# TYPE VAR ...)
:info:build          ...)-18-1-1| in lines 69..71 : variable VAR is not used.
:info:build          Misspelled or missing IGNORE declaration?
:info:build WARNING: in WITH-FLOAT-TRAPS-MASKED in lines 178..262 : FIND was called with 1
:info:build          arguments, but it requires at least 2 arguments.
:info:build WARNING: in WITH-FLOAT-TRAPS-MASKED in lines 178..262 : variable TRAPS is not
:info:build          used.
:info:build          Misspelled or missing IGNORE declaration?
:info:build *** - DEFPACKAGE COM.INUOE.JZON/EISEL-LEMIRE: unknown option :LOCAL-NICKNAMES
:info:build Command failed: /opt/local/bin/clisp --quiet --quiet -x '(require "asdf")' -x '(setf asdf:*central-registry* (list* (quote *default-pathname-defaults*) #p"/opt/local/var/macports/build/_Users_catap_src_macports-ports_lisp_cl-com.inuoe.jzon/cl-com.inuoe.jzon/work/build/system/" #p"/opt/local/share/common-lisp/system/" asdf:*central-registry*))' -x '(asdf:operate (quote asdf:build-op) (quote com.inuoe.jzon))' 2>&1
:info:build Exit code: 1

I'm using clisp which is build from https://gitlab.com/gnu-clisp/clisp/-/commit/66924971790e4cbee3d58f36e530caa0ad568e5f

SBCL, ECL, ABCL and CCL works fine

Cannot stringify double-floats in LispWorks 8.0.1

Hello,

On LispWorks 8.0.1, I always get an error when trying to stringify a double-float.
For example:

(jzon:stringify 2.77D3)
=> In LOGBITP of (63 2770.0D0) arguments should be of type INTEGER.

FLOAT-FEATURES are loaded in a very very dramatic manner

(eval-when (:compile-toplevel :load-toplevel :execute)
(flet ((#1=#:|| (package)
(unless (find-package package)
(cond
((and (find-package '#:ql) (find-symbol (string '#:quickload) '#:ql))
(funcall (find-symbol (string '#:quickload) '#:ql) package))
((and (find-package '#:asdf) (find-symbol (string '#:load-system) '#:asdf))
(funcall (find-symbol (string '#:load-system) '#:asdf) package))
(t
(require package))))))
(#1# '#:float-features)))

Isn't an ASDF dependency on float-features enough?

Improved error reporting

I want jzon:parse to have much better errors, at the very least notifying the source position as col/row of an offending character or token.

Custom stringify

One thing that I think that jonathan has over this library is the custom to-json methods. While the current stringify is a great default I find that sometimes I need to be able to leave slots out for client programs, either because they don't need or shouldn't have some information. Are there plans to implement something like this?

Tests failed on ECL for v1.0.0

Hi,

While packing JZON for Guix I've faced with an issue where tests failed on ECL implementation:

  Running test STRINGIFY-COERCE-KEY-WRITES-DOUBLE-FLOATS-WITHOUT-D0 An error occurred during initialization:
Cannot print object Implementation not supported. readably..
error: in phase 'check': uncaught exception:
%exception #<&invoke-error program: "/gnu/store/cawsqjfnnv7qd0qdjq7jmlqp440jqx8m-ecl-21.2.1/bin/ecl" arguments: ("--eval" "(require :asdf)" "--eval" "(asdf:initialize-source-registry (list :source-registry (list :tree (uiop:ensure-pathname \"/gnu/store/a0f8grgfa341lpz0n3xqkv1kvssasv0m-ecl-jzon-1.0.0/share/common-lisp/ecl/jzon\" :truenamize t :ensure-directory t)) :inherit-configuration))" "--eval" "(asdf:test-system \"com.inuoe.jzon-tests\")" "--eval" "(quit)") exit-status: 1 term-signal: #f stop-signal: #f>

Thanks,
Oleg

coercing `parse`

A common feature in JSON parsers is automatic object mapping into language data types such as structures, classes, etc.

While not everyone (myself included) likes automatic conversions like this, it is without question incredibly useful & convenient when developing & doing exploratory programming.

For example, consider the following:

(defpackage #:com.inuoe.jzon.coerce-example
  (:use #:cl)
  (:local-nicknames
   (#:jzon #:com.inuoe.jzon)))

(in-package #:com.inuoe.jzon.coerce-example)

(defclass coordinate ()
  ((reference
    :initarg :reference)
   (x
    :initform 0
    :initarg :x
    :accessor x)
   (y
    :initform 0
    :initarg :y
    :accessor y)))

(defclass object ()
  ((alive
    :initform nil
    :initarg :alive
    :type boolean)
   (coordinate
    :initform nil
    :initarg :coordinate
    :type (or null coordinate))
   (children
    :initform nil
    :initarg :children
    :type list)))

(let ((obj (jzon:parse (jzon:stringify (make-instance 'object :coordinate (make-instance 'coordinate))) :type 'object)))
  (with-slots (alive coordinate children) obj
    (print alive) ; nil
    (print coordinate) ; #<coordinate ...>
    (print children) ; nil

    (with-slots (x y) coordinate
      (print (slot-boundp coordinate 'reference)) ; nil
      (print x) ; 0
      (print y) ; 0
      )))

An alternative approach is to offer object coercion post-parse on the returned hash-table. And yet a third approach is to provide both options: allow a :type option to parse, but also export a coerce-value or equivalent function that users can use on say, a hash table.

Given the following JSON

{  
   "authenticationResultCode":"ValidCredentials",  
   "brandLogoUri":"http:\/\/dev.virtualearth.net\/Branding\/logo_powered_by.png",  
   "copyright":"Copyright © 2012 Microsoft and its suppliers. All rights reserved. This API cannot be accessed and the content and any results may not be used, reproduced or transmitted in any manner without express written permission from Microsoft Corporation.",  
   "resourceSets":[  
      {  
         "estimatedTotal":1,  
         "resources":[  
            {  
               "__type":"ElevationData:http:\/\/schemas.microsoft.com\/search\/local\/ws\/rest\/v1",  
               "elevations":[1776,1775,1777,1776],  
               "zoomLevel":14  
            }  
         ]  
      }  
   ],  
   "statusCode":200,  
   "statusDescription":"OK",  
   "traceId":"8d57dbeb0bb94e7ca67fd25b4114f5c3"  
}

from Bing Maps API

This allows the following:

(defclass elevation-data ()
  ((elevations
    :type (vector integer))
   (|zoomLevel|
    :type integer)))

(let* ((data (json:parse "..."))
       (data (gethash "resourceSets" data))
       (data (gethash "resources" data))
       (data (aref data 0))
       (obj (jzon:coerce-into data 'elevation-data)))
  (format t "Zoom level is ~D~%" (slot-value obj '|zoomLevel|)))

where we don't need to coerce parse the full object hierarchy, just the deeply nested elevation-data object

STRINGIFY a class doesn't work with LispWorks 8.0.1

I have a call like this:

(stringify (make-instance 'ping-response :message "pong"))

Class definition:

(defclass ping-response ()
  ((message
    :initarg :message
    :type string)))

I'm getting the error message:

Call to ERROR {offset 152}
  SYSTEM::ESTRING : CONDITIONS::SLOT-MISSING-ERROR
  SYSTEM::EARGS   : (:NAME #<STANDARD-EFFECTIVE-SLOT-DEFINITION OB.API::MESSAGE 82205E0C6B> :INSTANCE #<OB.API::PING-RESPONSE 80100B6223> :CLASS #<STANDARD-CLASS OB.API::PING-RESPONSE 82204D905B> :OPERATION SLOT-BOUNDP :NEW-VALUE NIL)

Call to CLOS::SLOT-BOUNDP-MISSING {offset 188}
  CLOS::WRAPPER   : #(2367 (OB.API::MESSAGE) NIL #<STANDARD-CLASS OB.API::PING-RESPONSE 82204D905B> (#<STANDARD-EFFECTIVE-SLOT-DEFINITION OB.API::MESSAGE 82205E0C6B>) 1)
  CLOS::OBJECT    : #<OB.API::PING-RESPONSE 80100B6223>
  CLOS::SLOT-NAME : #<STANDARD-EFFECTIVE-SLOT-DEFINITION OB.API::MESSAGE 82205E0C6B>

Backtrace:

Call to INVOKE-DEBUGGER
Call to ERROR
Call to CLOS::SLOT-BOUNDP-MISSING
Call to REMOVE-IF-NOT
Call to (METHOD COM.INUOE.JZON:COERCED-FIELDS (STANDARD-OBJECT))
Call to (METHOD COM.INUOE.JZON:WRITE-VALUE (COM.INUOE.JZON:WRITER T))
Call to CLOS::NEXT-METHOD-CALL-2
Call to (METHOD COM.INUOE.JZON:WRITE-VALUE :AROUND (COM.INUOE.JZON:WRITER T))
Call to (HARLEQUIN-COMMON-LISP:SUBFUNCTION (FLET COM.INUOE.JZON::STRINGIFY-TO) STRINGIFY)
Call to STRINGIFY
Call to EVAL
Call to CAPI::CAPI-TOP-LEVEL-FUNCTION
Call to CAPI::INTERACTIVE-PANE-TOP-LOOP
Call to MP::PROCESS-SG-FUNCTION

The corresponding code is in https://github.com/Zulu-Inuoe/jzon/blob/d6428d6602752d44d5b08e9c0a51d31f92aee2ab/src/jzon.lisp#L1134C1-L1135C70 , however I couldn't figure out the reason.

stringify on recursive structures

stringify dies on recursive structures:

(h:define-easy-handler (test :uri (h:create-prefix-dispatcher "/test" t)) ()
  (standard-page "Hello?"
    (jzon:stringify h:*request* :pretty t)))
Control stack guard page temporarily disabled: proceed with caution
[2021-03-21 21:45:02 [ERROR]] Error while processing connection: The condition The condition Control stack exhausted (no more space for function call frames).
This is probably due to heavily nested or infinitely recursive function
calls, or a tail call that SBCL cannot or has not optimized away.

PROCEED WITH CAUTION. occurred with errno: 0. occurred with errno: 0.

At the very least we should have circularity detection by default. per what to do when it's encountered, I am leaning towards having these two options available:

  1. Silently produce a string with an 'obviously' wrong value, such as "recursive-ref__request->easy-acceptor->one-thread-per-connection-taskmaster->easy-acceptor".
  2. Signal an error with a restart available asking for a value to substitute.

By default, I prefer the 'fail silently' option since it's more DWIM when a user is simply trying to inspect some object

cannot encode a dotted pair

Surprisingly JZON fails to encode a dotted pair, as it assumes it will be able to call length on the cdr

(jzon:stringify '(1 . 2))

ECL test failure: type mismatch `character` vs `base-char`

When running the tests with ECL I get one failure:

 Failure Details:
 --------------------------------
 PARSE-RETURNS-BASE-STRINGS in PARSING []:

(ARRAY-ELEMENT-TYPE (COM.INUOE.JZON:PARSE "\"COMMON-LISP\""))

 evaluated to

CHARACTER

 which is not

EQ

 to

BASE-CHAR


 --------------------------------

Do you know to what this could be related?

Investigate better binary data support

jzon performs great when given strings, but when given binary data it falls back on flexi-streams to provide a translation layer.
Unfortunately, this causes jzon to slow down tremendously.

I'd hope there's more performant ways to do this parsing that don't involve a mountain of effort. Off the top of my head, we may ditch flexi-streams and rely on babel functions to iterate over a UB8 vector and/or stream.

While a decrease of performance is tolerable, here are some benchmarks on my system when using a simple-string - jzon, and (simple-array (unsigned-byte 8) (*)) - jzon-ub8:

Testing 'countries'
Repeat = '40'

JZON
Evaluation took:
  0.862 seconds of real time
  0.859375 seconds of total run time (0.796875 user, 0.062500 system)
  99.65% CPU
  3,017,972,224 processor cycles
  312,920,896 bytes consed


JZON-UB8
Evaluation took:
  21.695 seconds of real time
  21.625000 seconds of total run time (21.312500 user, 0.312500 system)
  [ Run times consist of 0.015 seconds GC time, and 21.610 seconds non-GC time. ]
  99.68% CPU
  52 lambdas converted
  75,912,864,352 processor cycles
  1,960,107,360 bytes consed

Testing 'small-file'
Repeat = '2000'

JZON
Evaluation took:
  0.017 seconds of real time
  0.031250 seconds of total run time (0.031250 user, 0.000000 system)
  182.35% CPU
  61,817,016 processor cycles
  9,214,640 bytes consed


JZON-UB8
Evaluation took:
  0.411 seconds of real time
  0.406250 seconds of total run time (0.390625 user, 0.015625 system)
  98.78% CPU
  1,440,014,925 processor cycles
  39,246,112 bytes consed

Testing 'large-file'
Repeat = '1'

JZON
Evaluation took:
  0.463 seconds of real time
  0.437500 seconds of total run time (0.421875 user, 0.015625 system)
  94.60% CPU
  1,622,016,973 processor cycles
  135,756,384 bytes consed


JZON-UB8
Evaluation took:
  16.775 seconds of real time
  16.718750 seconds of total run time (16.484375 user, 0.234375 system)
  [ Run times consist of 0.093 seconds GC time, and 16.626 seconds non-GC time. ]
  99.67% CPU
  58,697,360,841 processor cycles
  1,224,720,768 bytes consed

Testing 'numbers'
Repeat = '1'

JZON
Evaluation took:
  0.272 seconds of real time
  0.265625 seconds of total run time (0.265625 user, 0.000000 system)
  97.79% CPU
  951,784,533 processor cycles
  39,986,704 bytes consed


JZON-UB8
Evaluation took:
  9.844 seconds of real time
  9.843750 seconds of total run time (9.625000 user, 0.218750 system)
  [ Run times consist of 0.062 seconds GC time, and 9.782 seconds non-GC time. ]
  100.00% CPU
  34,446,031,815 processor cycles
  749,442,064 bytes consed

FLOAT-FEATURES is loaded in a really desperate manner

(eval-when (:compile-toplevel :load-toplevel :execute)
(flet ((#1=#:|| (package)
(unless (find-package package)
(cond
((and (find-package '#:ql) (find-symbol (string '#:quickload) '#:ql))
(funcall (find-symbol (string '#:quickload) '#:ql) package))
((and (find-package '#:asdf) (find-symbol (string '#:load-system) '#:asdf))
(funcall (find-symbol (string '#:load-system) '#:asdf) package))
(t
(require package))))))
(#1# '#:float-features)))

This block of code is maybe superfluous because ASDF should take care of this: see the dependency at

#:float-features

Better name for `:pool-key`

I hate this name, as I think it's very non-descriptive & misleading since you don't need to pool the keys at all.
It was inherited from the previous :key-pool, which was a hash table the user passed in.

A couple of candidates:

  • key-handler
  • key-intern
  • key-fn

Add with-writer-to-string

I think it would be nice to have the macro with-writer-to-string (similar to the one in com.inuoe.jzon-tests) included and exported from com.inuoe.jzon. The same is probably also the case for a with-writer-to-string* macro.

Or is there a specific reason why you didn't include it @Zulu-Inuoe ?

Automated tests for CCL

It'd be nice to run the automated tests on CCL by updating test.bat and test.sh to also run on CCL

Number types

I want to establish reasonable rules when reading numbers from JSON.

In JavaScript, there is a singular number type (no distinction between Integers & Floats), but in CL we can have integers, floats, ratios, etc.
Given the JSON is data coming from outside the application, a user of jzon should should write code such that it can work with any representation, performing coercions (eg truncate or float calls), but I think it's reasonable to be somewhat consistent in what's returned so that in the general case it 'Just Works'

Currently the logic is:

If the number contains no decimal mark nor exponent, it is an integer. Otherwise double-float

This is the same logic applied when interpreting number tokens in the standard CL reader.

But I don't know if it's reasonable to be a bit more clever about this, for example, maybe it's reasonable for the following to be integers?

  1. 5e6
  2. 5e0
  3. 4.2e1
  4. 40e-1

.. Or would those all be too surprising?

bug when `stringifying` exponent markers

Hi
Firstly thank you for this library!
There is probably a bug when stringifying floats with (any) exponent markers:

(jzon:stringify 1.0d0)                ;; =>  "�^@.0"
(jzon:stringify 12.0d0))            ;; =>   "�^@1.0"
(jzon:stringify 123456.0s0)     ;; =>   "^@12345.0"

;; 

I'll try to come up with a PR; but in the meantime, would appreciate if anyone has an idea.
Thank you!!

quicklisp's jzon does not compile in allegro common lisp

The current version of jzon in quicklisp does not compile in allegro common lisp. This is the error I get:

Attempt to take the value of the unbound variable `+%POW10-MIN+'.

However, compiling the tip of master from source runs just fine. Would it possible to update the version of jzon in quicklisp?

Thanks in advance!

Incremental JSON generation

https://www.reddit.com/r/Common_Lisp/comments/rp5lik/what_was_your_favorite_common_lisp_release/hq7obiv/

Yason supports splitting up the generation of a json into a bunch of incremental parts, something like this:

(yason:with-object () (yason:encode-object-element key value))

This is pretty useful when you’re using generic functions because a PROGN method combination will be able to generate JSON output for all the fields of a class. It’s also useful when you’re generating a lot of JSON, because you don’t have to create a single data structure with all the data.

Just double-checking - does jzon have anything like that right now?

Implement `replacer` equivalent in `stringify`

Using JSON.stringify in JS, there is a replacer function that can be provided.
This serves for as a point-of-serialize coercion of values and offers an alternative way to handle the hard problem of coercing to JSON.

I think it'd be a good idea to implement here.
For the case of excluding keys, I've settled on the behaviour of

  1. Exclude the field - nil
  2. Include the field - t
  3. Include the field, and use a replacement value (values t "replacement")

Binary stream/Octet vector support

It might be useful to some people to have binary streams support (read-byte rather than read-char).
Because RFC 8259 specifically requires UTF-8, it should be doable without too much effort.

This is primarily in cases where the user already has a stream open from another source, or has a vector of ub8 they'd like to parse as JSON (eg an SQL column)

Is there a way to convert lisp case to camel case?

Thanks for writing this library!

I'm trying to transition from cl-json to jzon. I have projects where the javascript keys are all camel case because cl-json maps hyphen case to camel case.

Is there an option we can pass to jzon to produce camel case json strings for the keys of json objects?

If not, is there a method one can override to achieve this?

Shouldn't test precision of short-float on non-integer-divided-by-power-of-2 (maybe intended to test single-float)

(is (string= "1.2" (jzon:stringify 1.2s0))))

This test fails on LispWorks 32bit, because the accuracy of short-float is not good enough to represent 1.2. Only numbers that are integers divided by powers of 2 can be confidently assumed to be represented accurately with short-float (which is why it works with 1.5s0).

Looking at these tests, it looks to me like they actually meant to test single-float, rather than short-float. single-float is the standard 32-bit float, while short-float is either shorter or the same thing (depends on the implementation).

https://www.lispworks.com/documentation/lw80/CLHS/Body/t_short_.htm#short-float

Assuming it is intended to test single-float, it should use the exponent marker 'f', as in 1.2f0 (instead of 1.2s0). With 1.2f0 it works "by luck" (you still cannot really rely on it for such numbers, but for 1.2 it works). The same apply for all the other tests with the exponent marker 's'.

https://www.lispworks.com/documentation/lw80/CLHS/Body/26_glo_e.htm#exponent_marker

Cannot `jzon:parse` from pathname on LispWorks

Error: External format (:UTF-8 :EOL-STYLE :LF) produces characters of type CHARACTER, which is not a subtype of the specified element-type BASE-CHAR.
  1 (abort) Return to top loop level 0.

LispWorks' cl:open defaults :element-type to lispworks:*default-character-element-type* per http://www.lispworks.com/documentation/lw70/LW/html/lw-387.htm

When this is base-char and providing :external-format :utf-8, it complains that it may not be able to decode everything into a base-char

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.