sksamuel / hoplite

A boilerplate-free Kotlin config library for loading configuration files as data classes
Apache License 2.0
924 stars 74 forks source link

NullPointerException on Config load #389

Closed kotlinlukas closed 1 year ago

kotlinlukas commented 1 year ago

Hello,

I'm running into a bit of an issue with classes whose hashcode implementation relies on the Configuration, but who are themselves part of the Configuration.

For example, I have a ValueClass that wraps a String with some validation, which I use - among other places - in my configuration. However, the hashcode implementation of that class calls .lowercase(MyConfig.configuredLocale) on the string, to make sure strings are treated case-insensitively. Now this crashes on load, because Hoplite calls the hashcode method on the object during config loading ( com.sksamuel.hoplite.NodeState.hashCode(DecoderContext.kt) ), and finds that the CONFIG does not exist yet and throws a runtime NPE (on startup) because of it.

I can fix this inside the hashcode method by doing something like .lowercase(MyConfig?.configuredLocale ?: Locale.US), but this is highlighted by the IDE as bad because MyConfig is not nullable and thus "can never be null". Alternatively I can abstract the configuration in some method that returns a nullable config, but then I have to deal with nullability and call chains on nested properties MyConfig?.look?.like?.this?.and?.the?.question?.marks?.are?.making?.me?.question?.my ?: life

This all feels bad to just work around the case that some functions are called during config initialisation on a value that regularly is guaranteed non-null. Do you have any ideas on how I can do this cleanly?

sksamuel commented 1 year ago

Can you give me an example config class and file so I can see this in action ?

kotlinlukas commented 1 year ago

Here is the most minimal example I could come up with:

object MyDecoder: Decoder<MyClass> {
  override fun decode(node: Node, type: KType, context: DecoderContext): ConfigResult<MyClass> =
    when(node) {
      is StringNode -> MyClass(node.value).valid()
      else -> ConfigFailure.DecodeError(node, type).invalid()
    }

  override fun supports(type: KType): Boolean = type.isSubtypeOf(MyClass::class.starProjectedType)
}

val CONFIG = ConfigLoaderBuilder.default()
  .addResourceSource("/application.yml")
  .addDecoder(MyDecoder)
  .build()
  .loadConfigOrThrow<MyConfig>()

data class MyConfig(
  val someInt: Int,
  val test: MyClass,
)

class MyClass(val myString: String) {
  override fun hashCode(): Int =
    myString.hashCode() + CONFIG.someInt
}

fun main() {
  println(CONFIG)
}

Error is:

Exception in thread "main" java.lang.ExceptionInInitializerError
Caused by: java.lang.NullPointerException: Cannot invoke "MyConfig.getSomeInt()" because the return value of "MyConfigKt.getCONFIG()" is null
    at MyClass.hashCode(MyConfig.kt:32)
    at com.sksamuel.hoplite.NodeState.hashCode(DecoderContext.kt)
    at java.base/java.util.HashMap.hash(HashMap.java:338)
        ...
sksamuel commented 1 year ago

I think with this kind of circular dependency you're always going to run the risk of having brittle code, so I would first try to avoid it. But that being said, we can avoid calling hash code on your own class. That's not really needed and I have a PR up that avoids it.