Wednesday 16 September 2020

Jackson, YAML, and Kotlin data classes, and SnakeYaml formatting

 There have been a few articles on how to use YAML parsing with kotlin data classes, including early frustrations with snakeyaml, how to do it with Jackson's wrapping of SnakeYaml, and generally you can get a functional setup going. 

Cool as far as it goes, but Jackson doesn't really expose much of SnakeYaml's DumperOptions (where all the fun config is). SnakeYaml has quite a few formatting options available to it which are essential to writing readable YAML, but these aren't exposed in the Jackson-supplied APIs. So, how to get this configuration exposed?

Unfortunately the answer is, while not hard, messy. Thankfully, Jackson's code is pretty open to extension. This is necessary, because to get your DumperOptions configuration in, you have to intercept where this option class is created, and you have to do that in an override of YAMLGenerator. But... YAMLGenerator is constructed by YAMLFactory, so you also need to intercept THAT in a subclass which overrides the creation method, in order to plumb through your custom YAMLGenerator. Messy, but doable. 

Here's what mine looked like:

val mapper: YAMLMapper = YAMLMapper(MyYAMLFactory()).apply {
registerModule(KotlinModule())
setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
}


class MyYAMLGenerator(
ctx: IOContext,
jsonFeatures: Int,
yamlFeatures: Int,
codec: ObjectCodec,
out: Writer,
version: DumperOptions.Version?
): YAMLGenerator(ctx, jsonFeatures, yamlFeatures, codec, out, version) {
override fun buildDumperOptions(
jsonFeatures: Int,
yamlFeatures: Int,
version: DumperOptions.Version?
): DumperOptions {
return super.buildDumperOptions(jsonFeatures, yamlFeatures, version).apply {
defaultScalarStyle = ScalarStyle.LITERAL;
defaultFlowStyle = FlowStyle.BLOCK
indicatorIndent = 2
nonPrintableStyle = ESCAPE
indent = 4
isPrettyFlow = true
width = 100
this.version = version
}
}
}

class MyYAMLFactory(): YAMLFactory() {
@Throws(IOException::class)
override fun _createGenerator(out: Writer, ctxt: IOContext): YAMLGenerator {
val feats = _yamlGeneratorFeatures
return MyYAMLGenerator(ctxt, _generatorFeatures, feats,_objectCodec, out, _version)
}
}

This is pretty gross. It's overriding some internal methods in YAMLFactory and YAMLGenerator, but thankfully, it's only two classes, and not terribly deep into the mess.  As a result, I managed to make use of the FlowStyle.BLOCK option, which fixes a known problem in Jackson's YAML handling, where instead of this:

employees:
 - name: John
   age: 26
 - name: Sally
   age: 31

you get this:

employees:
 -
  name: John
  age: 26
 -
  name: Sally
  age: 31

There are other formatting niceties that you can do with SnakeYaml that aren't exposed by Jackson - no longer!

Anyway - this can always be factored into something improved.  I'm honestly not sure how easy the Jackson project is to contribute to, but this could be exposed in public APIs without too much irritation. Regardless, I'm licensing the above code with MIT license (as permissive as I know how to make it) and also available at this github gist so you can just use it if you feel like it.

P.S. I really wish Moshi did YAML.