Moshi: Accumulate error strategy

Created on 17 Apr 2019  路  9Comments  路  Source: square/moshi

The KotlinJsonAdapter fails fast when it encounters an error. This mean if there is a problem with the JSON we only get the first error. Of course the benefit of this is we don't parse additional data when we know the whole process is gonna fail.

In some cases it might be useful to accumulate the errors and fail at the end of the whole process. This means more information about everything that is wrong with the data.

Since this is a tradeoff I suggest a new adapter that accumulates errors or a setting in the existing one.

Would you interested in a PR that adds that functionality?

enhancement

Most helpful comment

Iterated on this a bit - once we get stable inline classes and contracts, we can do a really clever thing with zero allocations (unless we do find errors, in which case we're bombing out anyway) and tricking the compiler to avoid unnecessary nullchecks

inline fun <T> accumulateErrors(block: ErrorsReporter.() -> T): T {
  return ErrorsReporter().block()
}

inline class ErrorsReporter(var errors: List<String> = emptyList()) {
  fun <T> wasNull(name: String, jsonReader: JsonReader): T? {
    errors += "$name was null at ${jsonReader.path}"
    return null
  }

  fun checkErrors() {
    check(errors.isEmpty()) { errors.joinToString("\n") }
  }
}

// Lie to the compiler here, knowing that we'll manually bomb out if any errors were recorded
public inline fun <T : Any> ErrorsReporter.require(name: String, value: T?) {
  contract {
    returns() implies (value != null)
  }
  if (value == null) {
    errors += "$name was absent"
  }
}

Then we can do this in code gen

override fun fromJson(reader: JsonReader): KCGResponse = accumulateErrors {
    var users: List<KCGUser>? = null
    var status: String? = null
    var isRealJson: Boolean? = null
    reader.beginObject()
    while (reader.hasNext()) {
      when (reader.selectName(options)) {
        0 -> users = listOfKCGUserAdapter.fromJson(reader) ?: wasNull("users", reader)
        1 -> status = stringAdapter.fromJson(reader) ?: wasNull("status", reader)
        2 -> isRealJson = booleanAdapter.fromJson(reader) ?: wasNull("isRealJson", reader)
        -1 -> {
          // Unknown name, skip it.
          reader.skipName()
          reader.skipValue()
        }
      }
    }
    reader.endObject()
    require("users", users)
    require("status", status)
    require("isRealJson", isRealJson)
    checkErrors()
    var result = KCGResponse(
        users = users,
        status = status,
        isRealJson = isRealJson)
    return@accumulateErrors result
  }
}

All 9 comments

I don鈥榯 think we can do this well without adding significant complexity or performance costs. We might be able to tell you locally about all of the fields in a single class, but even that is awkward.

Good point. When I wrote this I was thinking about locally - in a single class. The way auto-value does it.

      String missing = "";
      if (this.name == null) {
        missing += " name";
      }
      if (this.numberOfLegs == null) {
        missing += " numberOfLegs";
      }
      if (!missing.isEmpty()) {
        throw new IllegalStateException("Missing required properties:" + missing);
      }

Hmm... that isn't so bad actually. Maybe for single classes the cost is low enough?

It should not be that hard/costly for a single class. And it could be quite useful to have all errors in one go.

I'll try and do a prototype next week and we can go from there.

Cool! Kotlin reflect, then codegen?

Lets do it like this:

  fun fromJson(reader: JsonReader): GetterAndSetter {
    var a: Int? = null
    var b: Int? = null
    var total: Int? = null
    accumulateErrors {
      reader.beginObject()
      while (reader.hasNext()) {
        when (reader.selectName(options)) {
          0 -> a = intAdapter.fromJson(reader) ?: wasNull("a", reader)
          1 -> b = intAdapter.fromJson(reader) ?: wasNull("b", reader)
          2 -> total = intAdapter.fromJson(reader) ?: wasNull("total", reader)
          -1 -> reader.skipNameAndValue()
        }
      }
      reader.endObject()
      require("a", a)
      require("b", b)
    }
    var result = GetterAndSetter(
        a!!,
        b!!
    )
    result.total = total ?: result.total
    return result
  }

with this support:

inline fun accumulateErrors(block: ErrorsReporter.() -> Unit)

class ErrorsReporter {
  fun <T> wasNull(name: String, jsonReader: JsonReader): T?
  fun <T> require(name: String, value: T?)
}

We鈥檒l need some Kotlin-specific runtime stuff for this.

Iterated on this a bit - once we get stable inline classes and contracts, we can do a really clever thing with zero allocations (unless we do find errors, in which case we're bombing out anyway) and tricking the compiler to avoid unnecessary nullchecks

inline fun <T> accumulateErrors(block: ErrorsReporter.() -> T): T {
  return ErrorsReporter().block()
}

inline class ErrorsReporter(var errors: List<String> = emptyList()) {
  fun <T> wasNull(name: String, jsonReader: JsonReader): T? {
    errors += "$name was null at ${jsonReader.path}"
    return null
  }

  fun checkErrors() {
    check(errors.isEmpty()) { errors.joinToString("\n") }
  }
}

// Lie to the compiler here, knowing that we'll manually bomb out if any errors were recorded
public inline fun <T : Any> ErrorsReporter.require(name: String, value: T?) {
  contract {
    returns() implies (value != null)
  }
  if (value == null) {
    errors += "$name was absent"
  }
}

Then we can do this in code gen

override fun fromJson(reader: JsonReader): KCGResponse = accumulateErrors {
    var users: List<KCGUser>? = null
    var status: String? = null
    var isRealJson: Boolean? = null
    reader.beginObject()
    while (reader.hasNext()) {
      when (reader.selectName(options)) {
        0 -> users = listOfKCGUserAdapter.fromJson(reader) ?: wasNull("users", reader)
        1 -> status = stringAdapter.fromJson(reader) ?: wasNull("status", reader)
        2 -> isRealJson = booleanAdapter.fromJson(reader) ?: wasNull("isRealJson", reader)
        -1 -> {
          // Unknown name, skip it.
          reader.skipName()
          reader.skipValue()
        }
      }
    }
    reader.endObject()
    require("users", users)
    require("status", status)
    require("isRealJson", isRealJson)
    checkErrors()
    var result = KCGResponse(
        users = users,
        status = status,
        isRealJson = isRealJson)
    return@accumulateErrors result
  }
}

Any news for this? It would avoid a lot of code being written by hand in my company just to check nullability of fields.

No, the solution above can't be used until inline classes and contracts are stable

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ayedo picture ayedo  路  4Comments

rephiscorth picture rephiscorth  路  5Comments

piorrro33 picture piorrro33  路  3Comments

Speedrockracer picture Speedrockracer  路  4Comments

trevjonez picture trevjonez  路  4Comments