/edn-json

Tools to convert back and forth between EDN and JSON, optimised for storage and JSON tooling friendliness.

Primary LanguageClojure

edn-json

edn json cljdoc

This is an opinionated EDN to/from JSON converter.

The functionality provided by this library makes an effort to support conversion between EDN to JSON and back without losing data (as much as possible).

Motivation

While working with ClojureScript immutable data sources is great inside Clojure land, most of the Javascript Ecosystem is designed and optimized to work around JSON. For example, if you want store data in the IndexedDB on the browser, the documents that you store must be JSON objects.

You could try to go around this by having a object with a single key that has the EDN encoded as string (or maybe transit?), but if you do so, you can’t leverage the IndexedDB indexes, that expect json structures to work upon.

There is the standard clj→js, but it has some problems:

  • it loses the keyword namespaces data

  • types are constrained by JSON (loses type information for UUID’s, dates, etc…​)

This library tries to address this problem by making an opinionated encoder/decoder, by taking some arbitrary decisions (and sticking to them) we can send and recover this data.

Encoding decisions

Since JSON is not as rich or extensible as EDN, some decisions have to be made to enable the reconstruction of the original EDN data from the JSON encoded one.

The decisions taken by this library focus on the following properties:

  • Scalar shared types must be transparent (encode and decode as-is)

  • EDN values are encoded as strings, prefixed with __edn-value|

  • Nested structures should be reflected as JSON

  • Support encoding of different sequence types sets, lists and vectors

  • Support map keys as strings, keywords, symbols, maps, sets and vectors

  • Since most map keys are keywords, this library assumes that JSON keys starting with : are keywords

ℹ️
that last point means that if you encode {":string" 42}, that key string is going to be decoded as a keyword instead of a string. Considering JSON strings starting with : are quite rare, this is a trade off this library is willing to take.

What it looks like?

Some examples of data encoded using this library:

(ns my-ns
  (:require [com.wsscode.edn-json :refer [edn->json json->edn]))

; simple scalars
(= (edn->json 42) 42)
(= (edn->json true) true)
(= (edn->json nil) null)
(= (edn->json "string") "string")

; edn scalars
(= (edn->json :keyword) "__edn-value|:keyword")
(= (edn->json :ns/keyword) "__edn-value|:ns/keyword")
(= (edn->json 'symb) "__edn-value|symb")
(= (edn->json 'foo/symb) "__edn-value|foo/symb")

; injection prevention
(= (edn->json "__edn-value|:foo") "__edn-value|\"__edn-value|:foo\"")

; edn default extensions
(= (edn->json #uuid"ca37585a-73cb-48c3-a8a4-7868ebc31801") "__edn-value|#uuid\"ca37585a-73cb-48c3-a8a4-7868ebc31801\"")
(= (edn->json #inst"2020-01-08T03:20:26.984-00:00") "__edn-value|#inst \"2020-01-08T03:20:26.984-00:00\"")

; sequences
(= (edn->json []) #js [])
(= (edn->json [42]) #js [42])
(= (edn->json #{true}) #js ["__edn-list-type|set" true])
(= (edn->json '(nil :kw)) #js ["__edn-list-type|list" null "__edn-value|:kw"])

; maps
(= (edn->json {}) #js {})
(= (edn->json {2 42}) #js {"2" 42})
(= (edn->json {nil 42}) #js {"__edn-key:nil" 42})
(= (edn->json {true 42}) #js {"__edn-key:true" 42})
(= (edn->json {"foo" 42}) #js {"foo" 42})
(= (edn->json {:foo 42}) #js {":foo" 42})
(= (edn->json {:foo/bar 42}) #js {":foo/bar" 42})
(= (edn->json {:foo {:bar 42}}) #js {":foo" #js {":bar" 42}})

; maps with complex keys
(= (edn->json {'sym 42}) #js {"__edn-key:sym" 42})
(= (edn->json {[3 5] 42}) #js {"__edn-key:[3 5]" 42})
(= (edn->json {#{:a :c} 42}) #js {"__edn-key:#{:a :c}" 42})

To decode, use the json→edn function:

(json->edn #js {":foo" #js {":bar" 42}}) ; => {:foo {:bar 42}}
numeric keys on maps are not going to be restored as numbers with json→edn, instead their string counterpart will be used, this is trade off made this way to keep sane number keys on the JSON side.

Encoding options

The encoder functions accept a map with configurations for encoding, here are the available options:

Option Default Description

::encode-list-type?

true

Encode special list types (sets and lists) by adding a special first element.

When disable all sequences will be encoded as vectors.

::encode-value

nil

Encode EDN special value types with custom encoding.

This is called for EDN keywords, symbols, uuids, instant and any other thing
that isn’t a simple JSON atomic type.

::encode-map-key

nil

Encode EDN special value on map keys with custom encoding.

::decode-edn-values?

true

If set to false, will not try to decode EDN values.

JSON Like

If you are Clojure land, it still useful to encode and decode maps in ways that are JSON friendly, although not native JSON (since there is no standard support for it in Java).

Example use cases of this is when interoping with document stores that expect JSON, for example interoping with Elastic Search. For those there are the functions edn→json-like and its counter part json-like→edn, those use EDN data in and out, but massage it so its friendly for storing in JSON-like supported stores.

When to use this library

Don’t use this library for transport layers. Unless your transport layer can do some sort of optimization on top JSON structures, you are better using Transit directly. Transit produces JSON suitable for faithful round-trips in and out of EDN types, but it’s not good for consuming as JSON.

On the other hand this library gives up some round-tripability to get something a native JSON environment could consume comfortably, e.g. storing EDN as JSON in stores that take advantage of the JSON structure to function/optimize. Most cases will be around document stores (IndexedDB, Mongo, PouchDB, etc…​).