javajsonkotlinunmarshallingmoshi

Moshi: How to Deserialize JSON with a mix of fixed and dynamic properties


For a JSON like this consider the properties other than attributes are dynamic:

"records": [
  {
    "attributes": {
      "type": "...",
      "url": "..."
    },
    "Id": "...",
    "Name": "...",
    "...": "..."
  }
]

How can I Desrialize or Unmarshall into a Dataclass like this such that all the dynamic keys go into a recordBody: Map<String, Any>

@JsonClass(generateAdapter = true)
data class Body(
  val records: List<Record>,
)

@JsonClass(generateAdapter = true)
data class Record(
  val attributes: Attributes,
  val recordBody: Map<String, Any>
)

@JsonClass(generateAdapter = true)
data class Attributes(
  val type: String,
  val url: String
)

I cannot find an annotation similar to @JsonAnySetter


Solution

  • The current answer assumes that the "attributes" property will always be the first property and that the recordBody map values are always strings (that doesn't seem to be the case in the original question?), and it also could use the selectName and other Moshi JsonReader features.

    Here's my take on a more resilient adapter.

    @JsonClass(generateAdapter = true)
    data class Body(
      val records: List<Record>
    )
    
    @JsonClass(generateAdapter = true)
    data class Record(
      val attributes: Attributes,
      val recordBody: Map<String, Any>
    )
    
    @JsonClass(generateAdapter = true)
    data class Attributes(
      val type: String,
      val url: String
    )
    
    object RecordAdapter {
      val options = JsonReader.Options.of("attributes")
    
      @FromJson
      fun fromJson(reader: JsonReader, attributesJsonAdapter: JsonAdapter<Attributes>): Record {
        reader.beginObject()
        var attributes: Attributes? = null
        val recordBody = mutableMapOf<String, Any>()
        while (reader.hasNext()) {
          when (reader.selectName(options)) {
            0 -> {
              if (attributes != null) {
                throw JsonDataException("Duplicate attributes.")
              }
              attributes = attributesJsonAdapter.fromJson(reader)
            }
    
            -1 -> {
              recordBody[reader.nextName()] = reader.readJsonValue()!!
            }
    
            else -> {
              throw AssertionError()
            }
          }
        }
        reader.endObject()
        return Record(attributes!!, recordBody)
      }
    
      @ToJson
      fun toJson(
        writer: JsonWriter,
        value: Record,
        attributesJsonAdapter: JsonAdapter<Attributes>,
        dynamicJsonAdapter: JsonAdapter<Any>
      ) {
        writer.beginObject()
        writer.name("attributes")
        attributesJsonAdapter.toJson(writer, value.attributes)
        for (entry in value.recordBody.entries) {
          writer.name(entry.key)
          dynamicJsonAdapter.toJson(writer, entry.value)
        }
        writer.endObject()
      }
    }
    
    fun main() {
      val moshi = Moshi.Builder().add(RecordAdapter).build()
      val idResponseJsonAdapter = moshi.adapter(Body::class.java)
      val encoded = """
        {
          "records": [
            {
              "attributes": {
              "type": "...",
              "url": "..."
            },
            "Id": "...",
            "Name": "...",
            "...": "..."
          }
        ]
      }""".trimIndent()
      val decoded = Body(
        listOf(
          Record(
            Attributes(
              type = "...",
              url = "..."
            ),
            mapOf(
              "Id" to "...",
              "Name" to "...",
              "..." to "..."
            )
          )
        )
      )
      println(idResponseJsonAdapter.fromJson(encoded))
      println(idResponseJsonAdapter.toJson(decoded))
    }
    

    Prints:

    Body(records=[Record(attributes=Attributes(type=..., url=...), recordBody={Id=..., Name=..., ...=...})])
    {"records":[{"attributes":{"type":"...","url":"..."},"Id":"...","Name":"...","...":"..."}]}