diff --git a/kondor-jackson/build.gradle.kts b/kondor-jackson/build.gradle.kts new file mode 100644 index 0000000..7e1f101 --- /dev/null +++ b/kondor-jackson/build.gradle.kts @@ -0,0 +1,105 @@ +import org.gradle.api.tasks.testing.logging.TestLogEvent + +plugins { + `common-kotlin` + id("maven-publish") + id("signing") +} + +tasks.withType { + enabled = false +} + +java { + withJavadocJar() + withSourcesJar() +} + +dependencies { + implementation(project(":kondor-core")) + api(libs.jUnit.api) + + implementation(libs.kotlin.jdk8) + implementation(libs.kotlin.reflect) //needed for source generator + implementation(libs.kotlinPoet) + implementation(group = "com.fasterxml.jackson.core", name = "jackson-databind", version = "2.18.2") +} + +@Suppress("UnstableApiUsage") +testing { + suites { + getByName("test") { + useJUnitJupiter(libs.versions.jUnit.get()) + dependencies { + implementation(libs.striKt) + implementation(project(":kondor-tools")) + } + targets { + all { + testTask.configure { + maxHeapSize = "2g" + testLogging { + events = setOf( + TestLogEvent.SKIPPED, + TestLogEvent.FAILED, + TestLogEvent.PASSED + ) + } + } + } + } + } + } +} + + +publishing { + publications { + create("mavenJava") { + from(components.getByName("java")) + groupId = project.group.toString() + artifactId = project.name + version = project.version.toString() + + pom { + name = "kondor-tools" + description = "A suite of tools to work with Kondor json library" + url = "https://github.com/uberto/kondor-json" + inceptionYear = "2021" + scm { + url = "https://github.com/uberto/kondor-json" + connection = "https://github.com/uberto/kondor-json.git" + developerConnection = "git@github.com:uberto/kondor-json.git" + } + licenses { + license { + name = "The Apache Software License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + distribution = "repo" + } + } + developers { + developer { + id = "UbertoBarbini" + name = "Uberto Barbini" + email = "uberto.gama@gmail.com" + } + } + } + } + } + repositories { + maven { + name = "OSSRH" + url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") + credentials { + username = findProperty("nexusUsername").toString() + password = findProperty("nexusPassword").toString() + } + } + } +} + +signing { + sign(publishing.publications.getByName("mavenJava")) +} \ No newline at end of file diff --git a/kondor-jackson/src/main/kotlin/com/ubertob/kondor/jackson/KondorAdaptors.kt b/kondor-jackson/src/main/kotlin/com/ubertob/kondor/jackson/KondorAdaptors.kt new file mode 100644 index 0000000..b506ceb --- /dev/null +++ b/kondor-jackson/src/main/kotlin/com/ubertob/kondor/jackson/KondorAdaptors.kt @@ -0,0 +1,83 @@ +package com.ubertob.kondor.jackson + +import com.fasterxml.jackson.databind.node.* +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.BooleanNode +import com.fasterxml.jackson.databind.node.NullNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ubertob.kondor.json.ObjectNodeConverter +import com.ubertob.kondor.json.jsonnode.* +import java.math.BigDecimal +import java.math.BigInteger +import com.fasterxml.jackson.databind.JsonNode as JJsonNode +import com.ubertob.kondor.json.jsonnode.JsonNode as KJsonNode + +fun ObjectNodeConverter.toJacksonJsonNode(t: T): ObjectNode = this.toJsonNode(t).toJacksonJsonNode() + +fun KJsonNode.toJacksonJsonNode(): JJsonNode { + val json: JsonNodeFactory = JsonNodeFactory.instance + + return when (this) { + is JsonNodeArray -> toJacksonJsonNode(json) + is JsonNodeBoolean -> toJacksonJsonNode(json) + is JsonNodeNull -> toJacksonJsonNode(json) + is JsonNodeNumber -> toJacksonJsonNode(json) + is JsonNodeObject -> toJacksonJsonNode(json) + is JsonNodeString -> toJacksonJsonNode(json) + } +} + +fun JsonNodeString.toJacksonJsonNode(json: JsonNodeFactory = JsonNodeFactory.instance) = json.textNode(this.text) +fun JsonNodeNumber.toJacksonJsonNode(json: JsonNodeFactory = JsonNodeFactory.instance) = this.num.toNumericNode(json) +fun JsonNodeBoolean.toJacksonJsonNode(json: JsonNodeFactory = JsonNodeFactory.instance) = json.booleanNode(this.boolean) +fun JsonNodeNull.toJacksonJsonNode(json: JsonNodeFactory = JsonNodeFactory.instance) = json.nullNode() +fun JsonNodeObject.toJacksonJsonNode(json: JsonNodeFactory = JsonNodeFactory.instance) = _fieldMap.entries + .fold(json.objectNode()) { obj, node -> + obj.set(node.key, node.value.toJacksonJsonNode()) + obj + } + +fun JsonNodeArray.toJacksonJsonNode(json: JsonNodeFactory = JsonNodeFactory.instance): ArrayNode = + elements.fold(json.arrayNode()) { array, node -> array.add(node.toJacksonJsonNode()) } + +fun TextNode.toKondorJsonNode() = JsonNodeString(textValue()) +fun NumericNode.toKondorJsonNode() = JsonNodeNumber(decimalValue()) +fun BooleanNode.toKondorJsonNode() = JsonNodeBoolean(booleanValue()) +fun NullNode.toKondorJsonNode() = JsonNodeNull +fun ArrayNode.toKondorJsonNode() = + JsonNodeArray(mapIndexedNotNull { i, node -> node.toKondorJsonNode() }) + +fun JJsonNode.toKondorJsonNode(): KJsonNode { + return when (this) { + is TextNode -> toKondorJsonNode() + is NumericNode -> toKondorJsonNode() + is BooleanNode -> toKondorJsonNode() + is NullNode -> toKondorJsonNode() + is ArrayNode -> toKondorJsonNode() + is ObjectNode -> toKondorJsonNode() + else -> throw IllegalArgumentException("Unknown Jackson JsonNode: $this") + } +} + +fun ObjectNode.toKondorJsonNode(): JsonNodeObject { + val map = properties().associate { (key, node) -> key to node.toKondorJsonNode() } + + return JsonNodeObject(map) +} + +private fun Number.toNumericNode(json: JsonNodeFactory): ValueNode { + return when (this) { + is Byte-> json.numberNode(this) + is Short-> json.numberNode(this) + is Int -> json.numberNode(this) + is Long -> json.numberNode(this) + is BigInteger -> json.numberNode(this) + is Float -> json.numberNode(this) + is Double -> json.numberNode(this) + is BigDecimal -> json.numberNode(this) + else -> throw IllegalArgumentException("Unknown number type: $this") + } +} + +fun T.intoJacksonJsonNode(converter: ObjectNodeConverter): ObjectNode = converter.toJacksonJsonNode(this) + diff --git a/kondor-jackson/src/test/kotlin/com/ubertob/kondor/jackson/KondorAdaptorsTests.kt b/kondor-jackson/src/test/kotlin/com/ubertob/kondor/jackson/KondorAdaptorsTests.kt new file mode 100644 index 0000000..1aeab89 --- /dev/null +++ b/kondor-jackson/src/test/kotlin/com/ubertob/kondor/jackson/KondorAdaptorsTests.kt @@ -0,0 +1,253 @@ +package com.ubertob.kondor.jackson + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.BaseJsonNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.ubertob.kondor.json.* +import com.ubertob.kondor.json.JsonStyle.Companion.prettyWithNulls +import com.ubertob.kondor.json.jsonnode.* +import com.ubertob.kondortools.expectSuccess +import com.ubertob.kondortools.isEquivalentJson +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import strikt.api.Assertion +import strikt.api.expectThat +import strikt.assertions.isEqualTo +import java.math.BigDecimal +import java.util.stream.Stream +import com.fasterxml.jackson.databind.JsonNode as JJsonNode +import com.ubertob.kondor.json.jsonnode.JsonNode as KJsonNode + + +class KondorAdaptorsTests { + @ParameterizedTest + @MethodSource("kondorToJackson") + fun `kondor JsonNode to jackson JsonNode`(kondorNode: KJsonNode, expectedJacksonNode: JJsonNode) { + val actualJacksonNode = kondorNode.toJacksonJsonNode() + expectThat(actualJacksonNode).equalJsonTo(expectedJacksonNode) + expectThat(actualJacksonNode).isEqualTo(expectedJacksonNode) + } + + @ParameterizedTest + @MethodSource("kondorToJackson") + fun `jackson JsonNode to kondor JsonNode`(expectedKondorNode: KJsonNode, jacksonNode: JJsonNode) { + val actualKondorNode = jacksonNode.toKondorJsonNode() + expectThat(actualKondorNode).isEqualTo(expectedKondorNode) + } + + @ParameterizedTest + @MethodSource("jsons") + fun `jackson JsonNode to kondor JsonNode - from JSON string`(json: String) { + val j: JJsonNode = mapper.readTree(json) + + val kJson = j.toKondorJsonNode().render(prettyWithNulls) + val jJson = j.toPrettyString() + + jJson isEquivalentJson json + kJson isEquivalentJson json + kJson isEquivalentJson jJson + } + + @ParameterizedTest + @MethodSource("jsons") + fun `kondor JsonNode to jackson JsonNode - from JSON string`(json: String) { + val k: KJsonNode = parseJsonNode(json).expectSuccess() + + val jJson = k.toJacksonJsonNode().toPrettyString() + val kJson = k.render(prettyWithNulls) + + jJson isEquivalentJson json + kJson isEquivalentJson json + kJson isEquivalentJson jJson + } + + @Test + fun `extension function to aid interop`() { + val exampleToJacksonToKondor = JExample.toJacksonJsonNode(example).toKondorJsonNode() + val result = JExample.fromJsonNode(exampleToJacksonToKondor, NodePathRoot).expectSuccess() + expectThat(result).isEqualTo(example) + } + + @Test + fun `test complex json going to Jackson and back`() { + val node = nodeObject( + "stringField" toNode "stringValue", + "nullStringField" toNode null as String?, + "intField" toNode 123.toInt(), + "nullIntField" toNode null as Int?, + "doubleField" toNode 123.456, + "nullDoubleField" toNode null as Double?, + "longField" toNode 123L, + "nullLongField" toNode null as Long?, + "booleanField" toNode true, + "nullBooleanField" toNode null as Boolean?, + "objectField" toNode mapOf( + "nestedStringField" to "nestedStringValue", + "nestedNullField" to null + ) + ) + + val expectedJson = """ + { + "booleanField": true, + "doubleField": 123.456, + "intField": 123, + "longField": 123, + "nullBooleanField": null, + "nullDoubleField": null, + "nullIntField": null, + "nullLongField": null, + "nullStringField": null, + "objectField": { + "nestedNullField": null, + "nestedStringField": "nestedStringValue" + }, + "stringField": "stringValue" + } + """ + + prettyWithNulls.render(node.toJacksonJsonNode().toKondorJsonNode()) isEquivalentJson expectedJson + } + + + companion object { + @JvmStatic + private fun kondorToJackson(): Stream = k2j().toPairOfArguments() + + @JvmStatic + private fun jsons(): Stream = jsons.toStringArgument() + } + + val mapper = ObjectMapper() + val example = Example( + name = "Example", + age = 20, + salary = 1.4, + children = listOf(Example("child1", 1, 0.0, emptyList()), Example("child2", -2, -10.1, emptyList())), + favoriteChild = Example("child3", -2, -10.1, emptyList()) + ) +} + +val jsons = listOf( + """ + { + "first": "first" + } + """, + """ + { + "array": [ "one" ] + } + """, + """ + 1 + """, + """ + [ 1 ] + """, + """ + { + "array": [ 1 ] + } + """, + """ + 0.1 + """, + """ + { + "first": { + "second": { + "third": { + "array": [ "text", 1 ], + "string": "string", + "boolean": true, + "number": 1, + "decimal": 0.1 + } + } + } + } + """, +) + +fun k2j(): List> { + val json = JsonNodeFactory.instance + return listOf( + JsonNodeString("a string") + to + json.textNode("a string"), + + JsonNodeNumber(BigDecimal(13)) + to + json.numberNode(BigDecimal(13)), + + JsonNodeBoolean(true) + to + json.booleanNode(true), + + JsonNodeNull + to + json.nullNode(), + + JsonNodeArray(listOf()) + to + json.arrayNode(), + + JsonNodeArray(listOf(JsonNodeString("a string"))) + to + json.arrayNode().add("a string"), + + JsonNodeArray(listOf(JsonNodeNumber(BigDecimal(1)))) + to + json.arrayNode().add(BigDecimal(1)), + + JsonNodeArray(listOf(JsonNodeArray(listOf(JsonNodeString("a string"))))) + to + json.arrayNode().add(json.arrayNode().add("a string")), + + JsonNodeObject(mapOf()) + to + json.objectNode(), + + JsonNodeObject(mapOf("one" to JsonNodeNumber(BigDecimal(1)))) + to + json.objectNode().put("one", BigDecimal(1)) + ) +} + +fun List>.toPairOfArguments(): Stream = map { (a, b) -> Arguments.of(a, b) }.stream() +fun List.toStringArgument(): Stream = map { Arguments.of(it) }.stream() + +fun Assertion.Builder.equalJsonTo(expected: JJsonNode): Assertion.Builder { + val mapper = ObjectMapper() + return assertThat("have the same JSON") { + mapper.writerWithDefaultPrettyPrinter().writeValueAsString(this.subject) == mapper.writerWithDefaultPrettyPrinter().writeValueAsString(expected) + } +} + +data class Example( + val name: String, + val age: Int, + val salary: Double, + val children: List, + val favoriteChild: Example? = null, +) + +object JExample : JAny() { + private val name by str(Example::name) + private val age by num(Example::age) + private val salary by num(Example::salary) + private val children by array(JExample, Example::children) + private val favoriteChild by obj(JExample, Example::favoriteChild) + + override fun JsonNodeObject.deserializeOrThrow(): Example = + Example( + name = +name, + age = +age, + salary = +salary, + children = +children, + favoriteChild = +favoriteChild + ) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c19caf..080cb87 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,3 +5,4 @@ include("kondor-outcome") include("kondor-tools") include("kondor-examples") include("kondor-mongo") +include("kondor-jackson")