0

I have a standard clojure map of things. The keys are keywords, and the values are arbitrary values. They can be nil, numbers, strings, or any other kind of JVM object/class.

I need to know how to encode this map into JSON, so that "normal" values map to the usual JSON values (e.g. keywords -> strings, integers -> JSON numbers, etc.), while values of any other class map to the string representations of those values, like this:

{
  :a 1
  :b :myword
  :c "hey"
  :d <this is an "unprintable" java File object>
}

gets encoded thus:

{ "a": 1, "b": "myword", "c": "hey", "d": "#object[java.io.File 0x6944e53e foo]" }

I want to do this because my program is a CLI parsing library and I'm working with the caller of the library to build this map up, so I don't exactly know what types of data will be in it. However, I would like to print it to the screen all the same to aid the caller in debugging. I have tried to naively give this map to cheshire, but when I do, cheshire keeps choking with this error:

Exception in thread "main" com.fasterxml.jackson.core.JsonGenerationException: Cannot JSON encode object of class: class java.io.File: foo

Bonus: I am trying to keep my dependency counts down and I have already vetted cheshire as my JSON library, but full marks if you can find a way to do the above without it.

djhaskin987
  • 9,741
  • 4
  • 50
  • 86

4 Answers4

2

With cheshire you can add an encoder for java.lang.Object

user> (require ['cheshire.core :as 'cheshire])
nil

user> (require ['cheshire.generate :as 'generate])
nil

user> (generate/add-encoder Object (fn [obj jsonGenerator] (.writeString jsonGenerator (str obj))))
nil

user> (def json (cheshire/generate-string {:a 1 :b nil :c "hello" :d (java.io.File. "/tmp")}))
#'user/json

user> (println json)
{"a":1,"b":null,"c":"hello","d":"/tmp"}
jas
  • 10,715
  • 2
  • 30
  • 41
1

you can also override the print-method for some objects you're interested in:

(defmethod print-method java.io.File [^java.io.File f ^java.io.Writer w]
  (print-simple (str "\"File:" (.getCanonicalPath f) "\"") w))

the method gets called by the print subsystem every time it needs to print out the object of this type:

user> {:a 10 :b (java.io.File. ".")}

;;=> {:a 10,
;;    :b "File:/home/xxxx/dev/projects/clj"}
leetwinski
  • 17,408
  • 2
  • 18
  • 42
0

Cheshire includes Custom Encoders that you can create and register to serialize arbitrary classes.

OTOH if you want to read the JSON back and reproduce the same types back in Java, you'll need to add some metadata too. A common pattern is to encode the type as some field like __type or *class*, like this, so that the deserializer can find the right types:

{
  __type: "org.foo.User"
  name: "Jane Foo"
  ...
}
Denis Fuenzalida
  • 3,271
  • 1
  • 17
  • 22
0

Unless I'm missing something, there is no need for JSON here. Just use prn:

(let [file (java.io.File. "/tmp/foo.txt")]
    (prn {:a 1 :b "foo" :f file})

=> {:a 1, 
    :b "foo", 
    :f #object[java.io.File 0x5d617472 "/tmp/foo.txt"]}

Perfectly readable.

As Denis said, you'll need more work if you want to read back the data, but that is impossible for things like a File object anyway.


You can get the result as a string (suitable for println etc) using a related function pretty-str if you prefer:

(ns tst.demo.core
  (:use tupelo.test)
  (:require
    [tupelo.core :as t] ))

(dotest
  (let [file (java.io.File. "/tmp/foo.txt")]
    (println (t/pretty-str {:a 1 :b "foo" :f file}))
    ))

=> {:a 1, 
    :b "foo", 
    :f #object[java.io.File 0x17d96ed9 "/tmp/foo.txt"]}

Update

Here is a technique I frequently use when I need to coerce data into another form, esp. for debugging or unit tests:

(ns tst.demo.core
  (:use tupelo.core tupelo.test)
  (:require
    [clojure.walk :as walk]))

(defn walk-coerce-jsonable
  [edn-data]
  (let [coerce-jsonable (fn [item]
                          (cond
                            ; coerce collections to simplest form
                            (sequential? item) (vec item)
                            (map? item) (into {} item)
                            (set? item) (into #{} item)

                            ; coerce leaf values to String if can't JSON them
                            :else (try
                                    (edn->json item)
                                    item ; return item if no exception
                                    (catch Exception ex
                                      (pr-str item))))) ; if exception, return string version of item
        result          (walk/postwalk coerce-jsonable edn-data)]
    result))

(dotest
  (let [file (java.io.File. "/tmp/foo.txt")
        m    {:a 1 :b "foo" :f file}]
    (println :converted (edn->json (walk-coerce-jsonable m)))
    ))

with result

-------------------------------
   Clojure 1.10.1    Java 14
-------------------------------

Testing tst.demo.core
:converted {"a":1,"b":"foo","f":"#object[java.io.File 0x40429f12 \"/tmp/foo.txt\"]"}
Alan Thompson
  • 29,276
  • 6
  • 41
  • 48
  • I apologize for not making this clear, but all the output and input for the particular library I'm writing is to standard out and standard in via json. The map would be actually part of a bigger map that is output in json set, so it can be thought of the json is simply a requirement of the build. – djhaskin987 May 19 '20 at 04:33