json::serialization: add an example for building a JSON object from a Map
[nit.git] / lib / json / serialization.nit
index 081c13b..d32b00e 100644 (file)
@@ -47,8 +47,6 @@
 # end
 #
 # var bob = new Person("Bob", 1986)
-# var alice = new Person("Alice", 1978, bob)
-#
 # assert bob.serialize_to_json(pretty=true, plain=true) == """
 #{
 #      "name": "Bob",
@@ -56,6 +54,7 @@
 #      "next_of_kin": null
 #}"""
 #
+# var alice = new Person("Alice", 1978, bob)
 # assert alice.serialize_to_json(pretty=true, plain=true) == """
 #{
 #      "name": "Alice",
 #              "next_of_kin": null
 #      }
 #}"""
+#
+# # You can also build JSON objects as a `Map`
+# var charlie = new Map[String, nullable Serializable]
+# charlie["name"] = "Charlie"
+# charlie["year_of_birth"] = 1968
+# charlie["next_of_kin"] = alice
+# assert charlie.serialize_to_json(pretty=true, plain=true) == """
+#{
+#      "name": "Charlie",
+#      "year_of_birth": 1968,
+#      "next_of_kin": {
+#              "name": "Alice",
+#              "year_of_birth": 1978,
+#              "next_of_kin": {
+#                      "name": "Bob",
+#                      "year_of_birth": 1986,
+#                      "next_of_kin": null
+#              }
+#      }
+#}"""
 # ~~~
 #
-# ## JSON to Nit objects
+# ## Read JSON to create Nit objects
+#
+# The `JsonDeserializer` supports reading JSON code with or without metadata.
+# It can create Nit objects from a remote service returning JSON data
+# or to read local configuration files as Nit objects.
+# However, it needs to know which Nit class to recreate from each JSON object.
+# The class is either declared or inferred:
 #
-# The `JsonDeserializer` support reading JSON code with minimal metadata
-# to easily create Nit object from client-side code or configuration files.
-# Each JSON object must define the `__class` attribute with the corresponding
-# Nit class and the expected attributes with its name in Nit followed by its value.
+# 1. The JSON object defines a `__class` key with the name of the Nit class as value.
+#    This attribute is generated by the `JsonSerializer` with other metadata,
+#    it can also be specified by other external tools.
+# 2. A refinement of `JsonDeserializer::class_name_heuristic` identifies the Nit class.
+# 3. If all else fails, `JsonDeserializer` uses the static type of the attribute.
 #
 # ### Usage Example
 #
 # ~~~nitish
 # import json::serialization
 #
-# class MeetupConfig
+# class Triangle
 #     serialize
 #
-#     var description: String
-#     var max_participants: nullable Int
-#     var answers: Array[FlatString]
+#     var corners = new Array[Point]
+#     redef var to_s is serialize_as("name")
 # end
 #
-# var json_code = """
-# {"__class": "MeetupConfig", "description": "My Awesome Meetup", "max_participants": null, "answers": ["Pepperoni", "Chicken"]}"""
-# var deserializer = new JsonDeserializer(json_code)
+# class Point
+#     serialize
+#
+#     var x: Int
+#     var y: Int
+# end
 #
-# var meet = deserializer.deserialize
+# # Metadata on each JSON object tells the deserializer what is its Nit type,
+# # and it supports special types such as generic collections.
+# var json_with_metadata = """{
+#     "__class": "Triangle",
+#     "corners": {"__class": "Array[Point]",
+#                 "__items": [{"__class": "Point", "x": 0, "y": 0},
+#                             {"__class": "Point", "x": 3, "y": 0},
+#                             {"__class": "Point", "x": 2, "y": 2}]},
+#     "name": "some triangle"
+# }"""
 #
-# # Check for errors
+# var deserializer = new JsonDeserializer(json_with_metadata)
+# var object = deserializer.deserialize
 # assert deserializer.errors.is_empty
+# assert object != null
+# print object
 #
-# assert meet isa MeetupConfig
-# assert meet.description == "My Awesome Meetup"
-# assert meet.max_participants == null
-# assert meet.answers == ["Pepperoni", "Chicken"]
+# # However most non-Nit services won't add the metadata and instead produce plain JSON.
+# # Without a "__class", the deserializer relies on `class_name_heuristic` and the static type.
+# # The type of the root object to deserialize can be specified by calling
+# # its deserialization constructor `from_deserializer`.
+# var plain_json = """{
+#     "corners": [{"x": 0, "y": 0},
+#                 {"x": 3, "y": 0},
+#                 {"x": 2, "y": 2}],
+#     "name": "the same triangle"
+# }"""
+#
+# deserializer = new JsonDeserializer(plain_json)
+# object = new Triangle.from_deserializer(deserializer)
+# assert deserializer.errors.is_empty # If false, `obj` is invalid
+# print object
+# ~~~
+#
+# ### Missing attributes and default values
+#
+# When reading JSON, some attributes expected by Nit classes may be missing.
+# The JSON object may come from an external API using optional attributes or
+# from a previous version of your program without the attributes.
+# When an attribute is not found, the deserialization engine acts in one of three ways:
+#
+# 1. If the attribute has a default value or if it is annotated by `lazy`,
+#    the engine leave the attribute to the default value. No error is raised.
+# 2. If the static type of the attribute is nullable, the engine sets
+#    the attribute to `null`. No error is raised.
+# 3. Otherwise, the engine raises an error and does not set the attribute.
+#    The caller must check for `errors` and must not read from the attribute.
+#
+# ~~~nitish
+# import json::serialization
+#
+# class MyConfig
+#     serialize
+#
+#     var width: Int # Must be in JSON or an error is raised
+#     var height = 4
+#     var volume_level = 8 is lazy
+#     var player_name: nullable String
+#     var tmp_dir: nullable String = "/tmp" is lazy
+# end
+#
+# # ---
+# # JSON object with all expected attributes -> OK
+# var plain_json = """
+# {
+#     "width": 11,
+#     "height": 22,
+#     "volume_level": 33,
+#     "player_name": "Alice",
+#     "tmp_dir": null
+# }"""
+# var deserializer = new JsonDeserializer(plain_json)
+# var obj = new MyConfig.from_deserializer(deserializer)
+#
+# assert deserializer.errors.is_empty
+# assert obj.width == 11
+# assert obj.height == 22
+# assert obj.volume_level == 33
+# assert obj.player_name == "Alice"
+# assert obj.tmp_dir == null
+#
+# # ---
+# # JSON object missing optional attributes -> OK
+# plain_json = """
+# {
+#     "width": 11
+# }"""
+# deserializer = new JsonDeserializer(plain_json)
+# obj = new MyConfig.from_deserializer(deserializer)
+#
+# assert deserializer.errors.is_empty
+# assert obj.width == 11
+# assert obj.height == 4
+# assert obj.volume_level == 8
+# assert obj.player_name == null
+# assert obj.tmp_dir == "/tmp"
+#
+# # ---
+# # JSON object missing the mandatory attribute -> Error
+# plain_json = """
+# {
+#     "player_name": "Bob",
+# }"""
+# deserializer = new JsonDeserializer(plain_json)
+# obj = new MyConfig.from_deserializer(deserializer)
+#
+# # There's an error, `obj` is partial
+# assert deserializer.errors.length == 1
+#
+# # Still, we can access valid attributes
+# assert obj.player_name == "Bob"
 # ~~~
 module serialization
 
@@ -242,6 +372,9 @@ class JsonDeserializer
        # Depth-first path in the serialized object tree.
        private var path = new Array[Map[String, nullable Object]]
 
+       # Names of the attributes from the root to the object currently being deserialized
+       var attributes_path = new Array[String]
+
        # Last encountered object reference id.
        #
        # See `id_to_object`.
@@ -253,19 +386,33 @@ class JsonDeserializer
                self.root = root
        end
 
-       redef fun deserialize_attribute(name)
+       redef fun deserialize_attribute(name, static_type)
        do
-               assert not path.is_empty # This is an internal error, abort
+               if path.is_empty then
+                       # The was a parsing error or the root is not an object
+                       if not root isa Error then
+                               errors.add new Error("Deserialization Error: parsed JSON value is not an object.")
+                       end
+                       deserialize_attribute_missing = false
+                       return null
+               end
+
                var current = path.last
 
                if not current.keys.has(name) then
-                       errors.add new Error("Deserialization Error: JSON object has not attribute '{name}'.")
+                       # Let the generated code / caller of `deserialize_attribute` raise the missing attribute error
+                       deserialize_attribute_missing = true
                        return null
                end
 
                var value = current[name]
 
-               return convert_object(value)
+               attributes_path.add name
+               var res = convert_object(value, static_type)
+               attributes_path.pop
+
+               deserialize_attribute_missing = false
+               return res
        end
 
        # This may be called multiple times by the same object from constructors
@@ -277,8 +424,8 @@ class JsonDeserializer
                cache[id] = new_object
        end
 
-       # Convert from simple Json object to Nit object
-       private fun convert_object(object: nullable Object): nullable Object
+       # Convert the simple JSON `object` to a Nit object
+       private fun convert_object(object: nullable Object, static_type: nullable String): nullable Object
        do
                if object isa JsonParseError then
                        errors.add object
@@ -333,6 +480,14 @@ class JsonDeserializer
                                if class_name == null then
                                        # Fallback to custom heuristic
                                        class_name = class_name_heuristic(object)
+
+                                       if class_name == null and static_type != null then
+                                               # Fallack to the static type, strip the `nullable` prefix
+                                               var prefix = "nullable "
+                                               if static_type.has(prefix) then
+                                                       class_name = static_type.substring_from(prefix.length)
+                                               else class_name = static_type
+                                       end
                                end
 
                                if class_name == null then
@@ -381,6 +536,23 @@ class JsonDeserializer
 
                # Simple JSON array without serialization metadata
                if object isa Array[nullable Object] then
+                       # Can we use the static type?
+                       if static_type != null then
+                               var prefix = "nullable "
+                               var class_name = if static_type.has(prefix) then
+                                               static_type.substring_from(prefix.length)
+                                       else static_type
+
+                               opened_array = object
+                               var value = deserialize_class(class_name)
+                               opened_array = null
+                               return value
+                       end
+
+                       # This branch should rarely be used:
+                       # when an array is the root object which is accepted but illegal in standard JSON,
+                       # or in strange custom deserialization hacks.
+
                        var array = new Array[nullable Object]
                        var types = new HashSet[String]
                        var has_nullable = false
@@ -428,24 +600,32 @@ class JsonDeserializer
                                return typed_array
                        end
 
-                       # Uninferable type, return as `Array[nullable Object]`
+                       # Uninferrable type, return as `Array[nullable Object]`
                        return array
                end
 
                return object
        end
 
+       # Current array open for deserialization, used by `SimpleCollection::from_deserializer`
+       private var opened_array: nullable Array[nullable Object] = null
+
        redef fun deserialize
        do
                errors.clear
                return convert_object(root)
        end
 
-       # User customizable heuristic to get the name of the Nit class to deserialize `json_object`
+       # User customizable heuristic to infer the name of the Nit class to deserialize `json_object`
        #
        # This method is called only when deserializing an object without the metadata `__class`.
-       # Return the class name as a `String` when it can be inferred.
-       # Return `null` when the class name cannot be found.
+       # Use the content of `json_object` to identify what Nit class it should be deserialized into.
+       # Or use `self.attributes_path` indicating where the deserialized object will be stored,
+       # is is less reliable as some objects don't have an associated attribute:
+       # the root/first deserialized object and collection elements.
+       #
+       # Return the class name as a `String` when it can be inferred,
+       # or `null` when the class name cannot be found.
        #
        # If a valid class name is returned, `json_object` will then be deserialized normally.
        # So it must contain the attributes of the corresponding class, as usual.
@@ -461,6 +641,7 @@ class JsonDeserializer
        #     serialize
        #
        #     var error: String
+       #     var related_data: MyData
        # end
        #
        # class MyJsonDeserializer
@@ -468,18 +649,26 @@ class JsonDeserializer
        #
        #     redef fun class_name_heuristic(json_object)
        #     do
+       #         # Infer the Nit class from the content of the JSON object.
        #         if json_object.keys.has("error") then return "MyError"
        #         if json_object.keys.has("data") then return "MyData"
+       #
+       #         # Infer the Nit class from the attribute where it will be stored.
+       #         # This line duplicates a previous line, and would only apply when
+       #         # `MyData` is within a `MyError`.
+       #         if attributes_path.not_empty and attributes_path.last == "related_data" then return "MyData"
+       #
        #         return null
        #     end
        # end
        #
-       # var json = """{"data": "some other data"}"""
+       # var json = """{"data": "some data"}"""
        # var deserializer = new MyJsonDeserializer(json)
        # var deserialized = deserializer.deserialize
        # assert deserialized isa MyData
        #
-       # json = """{"error": "some error message"}"""
+       # json = """{"error": "some error message",
+       #            "related_data": {"data": "some other data"}"""
        # deserializer = new MyJsonDeserializer(json)
        # deserialized = deserializer.deserialize
        # assert deserialized isa MyError
@@ -509,7 +698,31 @@ redef class Text
                return res
        end
 
-       redef fun accept_json_serializer(v) do v.stream.write(to_json)
+       redef fun accept_json_serializer(v)
+       do
+               v.stream.write "\""
+               for i in [0 .. self.length[ do
+                       var char = self[i]
+                       if char == '\\' then
+                               v.stream.write "\\\\"
+                       else if char == '\"' then
+                               v.stream.write "\\\""
+                       else if char < ' ' then
+                               if char == '\n' then
+                                       v.stream.write "\\n"
+                               else if char == '\r' then
+                                       v.stream.write "\\r"
+                               else if char == '\t' then
+                                       v.stream.write "\\t"
+                               else
+                                       v.stream.write char.escape_to_utf16
+                               end
+                       else
+                               v.stream.write char.to_s
+                       end
+               end
+               v.stream.write "\""
+       end
 end
 
 redef class Serializable
@@ -538,6 +751,16 @@ redef class Serializable
                return stream.to_s
        end
 
+       # Serialize `self` to plain JSON
+       #
+       # Compatibility alias for `serialize_to_json(plain=true)`.
+       fun to_json: String do return serialize_to_json(plain=true)
+
+       # Serialize `self` to plain pretty JSON
+       #
+       # Compatibility alias for `serialize_to_json(plain=true, pretty=true)`.
+       fun to_pretty_json: String do return serialize_to_json(plain=true, pretty=true)
+
        # Refinable service to customize the serialization of this class to JSON
        #
        # This method can be refined to customize the serialization by either
@@ -600,23 +823,23 @@ redef class Collection[E]
        # Utility to serialize a normal Json array
        private fun serialize_to_pure_json(v: JsonSerializer)
        do
-                       v.stream.write "["
-                       v.indent_level += 1
-                       var is_first = true
-                       for e in self do
-                               if is_first then
-                                       is_first = false
-                               else v.stream.write ","
-                               v.new_line_and_indent
+               v.stream.write "["
+               v.indent_level += 1
+               var is_first = true
+               for e in self do
+                       if is_first then
+                               is_first = false
+                       else v.stream.write ","
+                       v.new_line_and_indent
 
-                               if not v.try_to_serialize(e) then
-                                       assert e != null # null would have been serialized
-                                       v.warn("element of type {e.class_name} is not serializable.")
-                               end
+                       if not v.try_to_serialize(e) then
+                               assert e != null # null would have been serialized
+                               v.warn("element of type {e.class_name} is not serializable.")
                        end
-                       v.indent_level -= 1
-                       v.new_line_and_indent
-                       v.stream.write "]"
+               end
+               v.indent_level -= 1
+               v.new_line_and_indent
+               v.stream.write "]"
        end
 end
 
@@ -656,10 +879,37 @@ redef class SimpleCollection[E]
                        v.notify_of_creation self
                        init
 
-                       var arr = v.path.last["__items"].as(SequenceRead[nullable Object])
-                       for o in arr do
-                               var obj = v.convert_object(o)
-                               self.add obj
+                       var open_array: nullable SequenceRead[nullable Object] = v.opened_array
+                       if open_array == null then
+                               # With metadata
+                               var arr = v.path.last.get_or_null("__items")
+                               if not arr isa SequenceRead[nullable Object] then
+                                       # If there is nothing, we consider that it is an empty collection.
+                                       if arr != null then v.errors.add new Error("Deserialization Error: invalid format in {self.class_name}")
+                                       return
+                               end
+                               open_array = arr
+                       end
+
+                       # Try to get the name of the single parameter type assuming it is E.
+                       # This does not work in non-generic subclasses,
+                       # when the first parameter is not E, or
+                       # when there is more than one parameter. (The last one could be fixed)
+                       var class_name = class_name
+                       var items_type = null
+                       var bracket_index = class_name.index_of('[')
+                       if bracket_index != -1 then
+                               var start = bracket_index + 1
+                               var ending = class_name.last_index_of(']')
+                               items_type = class_name.substring(start, ending-start)
+                       end
+
+                       # Fill array
+                       for o in open_array do
+                               var obj = v.convert_object(o, items_type)
+                               if obj isa E then
+                                       add obj
+                               else v.errors.add new AttributeTypeError(self, "items", obj, "E")
                        end
                end
        end
@@ -726,12 +976,44 @@ redef class Map[K, V]
                        v.notify_of_creation self
                        init
 
-                       var length = v.deserialize_attribute("__length").as(Int)
-                       var keys = v.path.last["__keys"].as(SequenceRead[nullable Object])
-                       var values = v.path.last["__values"].as(SequenceRead[nullable Object])
+                       var length = v.deserialize_attribute("__length")
+                       var keys = v.path.last.get_or_null("__keys")
+                       var values = v.path.last.get_or_null("__values")
+
+                       # Length is optional
+                       if length == null and keys isa SequenceRead[nullable Object] then length = keys.length
+
+                       # Consistency check
+                       if not length isa Int or length < 0 or
+                          not keys isa SequenceRead[nullable Object] or
+                          not values isa SequenceRead[nullable Object] or
+                          keys.length != values.length or length != keys.length then
+
+                               # If there is nothing or length == 0, we consider that it is an empty Map.
+                               if (length != null and length != 0) or keys != null or values != null then
+                                       v.errors.add new Error("Deserialization Error: invalid format in {self.class_name}")
+                               end
+                               return
+                       end
+
                        for i in length.times do
                                var key = v.convert_object(keys[i])
                                var value = v.convert_object(values[i])
+
+                               if not key isa K then
+                                       v.errors.add new AttributeTypeError(self, "keys", key, "K")
+                                       continue
+                               end
+
+                               if not value isa V then
+                                       v.errors.add new AttributeTypeError(self, "values", value, "V")
+                                       continue
+                               end
+
+                               if has_key(key) then
+                                       v.errors.add new Error("Deserialization Error: duplicated key '{key or else "null"}' in {self.class_name}, previous value overwritten")
+                               end
+
                                self[key] = value
                        end
                end