diff --git a/.github/actions/warmup-jitpack/action.yml b/.github/actions/warmup-jitpack/action.yml index aa32e382..b2446858 100644 --- a/.github/actions/warmup-jitpack/action.yml +++ b/.github/actions/warmup-jitpack/action.yml @@ -5,6 +5,14 @@ inputs: tag: description: "Tag to build on JitPack (e.g. 2.21.5-beta38)" required: true + attempts: + description: "How many times to ask JitPack before giving up on the tag" + required: false + default: '10' + interval: + description: "Seconds to wait between those attempts" + required: false + default: '60' runs: using: composite @@ -12,11 +20,32 @@ runs: - shell: bash env: TAG: ${{ inputs.tag }} + ATTEMPTS: ${{ inputs.attempts }} + INTERVAL: ${{ inputs.interval }} run: | set -euo pipefail base="https://jitpack.io/com/github/${GITHUB_REPOSITORY}/${TAG}" + log=$(mktemp) # JitPack builds on demand. Requesting the log starts the build and streams its output, # so that the first consumer of a new version does not have to wait for it. - curl -fsSL --max-time 900 "${base}/build.log" | tail -n 50 + # + # JitPack answers with 404 while the tag is missing from the ref list it caches from + # GitHub, which is the normal state for a tag that was pushed moments ago, so that answer + # is waited out. The status has to be read from the response instead of relying on + # curl --fail, because it does not tell 404 apart from a timeout or a 5xx. + for attempt in $(seq 1 "$ATTEMPTS"); do + code=$(curl -sSL --max-time 900 -o "$log" -w '%{http_code}' "${base}/build.log") || code=000 + case "$code" in + 2??) break ;; + 404 | 5?? | 000) + if [ "$attempt" -eq "$ATTEMPTS" ]; then + echo "::error::JitPack did not pick up ${TAG} (last status ${code})"; exit 1 + fi + echo "::notice::JitPack does not know ${TAG} yet (${code}), retrying in ${INTERVAL}s (${attempt}/${ATTEMPTS})" + sleep "$INTERVAL" ;; + *) echo "::error::Requesting the JitPack build log failed with ${code}"; exit 1 ;; + esac + done + tail -n 50 "$log" # The log alone does not reliably report the result, so the artifact itself is requested. curl -fsS --max-time 60 -o /dev/null "${base}/${GITHUB_REPOSITORY#*/}-${TAG}.pom" diff --git a/.github/workflows/lint-and-test-dev.yml b/.github/workflows/lint-and-test-dev.yml index 37b109cd..c21671e1 100644 --- a/.github/workflows/lint-and-test-dev.yml +++ b/.github/workflows/lint-and-test-dev.yml @@ -43,7 +43,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 - name: Set up java - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '17' distribution: 'corretto' diff --git a/.github/workflows/release-jackson-lines.yml b/.github/workflows/release-jackson-lines.yml index 0679d6a6..ea83e4e6 100644 --- a/.github/workflows/release-jackson-lines.yml +++ b/.github/workflows/release-jackson-lines.yml @@ -17,6 +17,7 @@ on: permissions: contents: write # for pushing tags and creating releases + checks: write # for action-junit-report jobs: release-jackson-lines: @@ -28,7 +29,7 @@ jobs: jackson-line: [ '2.21', '2.22' ] name: "Jackson ${{ matrix.jackson-line }}" runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Plan id: plan @@ -63,7 +64,7 @@ jobs: ref: ${{ steps.plan.outputs.base_tag }} - name: Set up java if: steps.plan.outputs.skip == 'false' - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '17' distribution: 'corretto' diff --git a/.github/workflows/test-main.yml b/.github/workflows/test-main.yml index f7bfc69b..ec6ea4a3 100644 --- a/.github/workflows/test-main.yml +++ b/.github/workflows/test-main.yml @@ -40,7 +40,7 @@ jobs: # LTS versions, latest version (if exists) java-version: [ '17', '21', '25', '26' ] # Minimum version, latest release version, latest pre-release version (if exists) - kotlin: ['2.1.21', '2.2.21', '2.3.21', '2.4.20-RC'] + kotlin: ['2.1.21', '2.2.21', '2.3.21', '2.4.21', '2.5.0-Beta1'] env: KOTLIN_VERSION: ${{ matrix.kotlin }} name: "Kotlin ${{ matrix.kotlin }} - Java ${{ matrix.java-version }}" @@ -50,7 +50,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 - name: 'Set up java ${{ matrix.java-version }}' - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '${{ matrix.java-version }}' distribution: 'corretto' diff --git a/.github/workflows/warmup-jitpack.yml b/.github/workflows/warmup-jitpack.yml index 6fe6e6b5..289f4fbe 100644 --- a/.github/workflows/warmup-jitpack.yml +++ b/.github/workflows/warmup-jitpack.yml @@ -21,7 +21,7 @@ jobs: warmup-jitpack: name: warmup-jitpack runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v7 diff --git a/build.gradle.kts b/build.gradle.kts index 7667c3a8..6b337a91 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ val jacksonVersion = libs.versions.jackson.get() val generatedSrcPath = "${layout.buildDirectory.get()}/generated/kotlin" group = groupStr -version = "${jacksonVersion}-beta38" +version = "${jacksonVersion}-beta39" repositories { mavenCentral() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e2109cf1..9f9b0db5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] kotlin = "2.1.21" # The lowest supported version. Mainly for CI, it can be rewritten by environment variable. -jackson = "2.21.6" +jackson = "2.21.7" # test libs junit = "6.1.3" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2e..5097068a 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 69dd0d04..9b7cb4af 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt index 12cffa8c..e1a73f02 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt @@ -2,12 +2,15 @@ package io.github.projectmapk.jackson.module.kogera.ser.serializers import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser.NumberType import com.fasterxml.jackson.databind.BeanDescription import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.SerializationConfig import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper import com.fasterxml.jackson.databind.module.SimpleSerializers +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer import com.fasterxml.jackson.databind.ser.std.StdSerializer import io.github.projectmapk.jackson.module.kogera.ANY_TO_ANY_METHOD_TYPE import io.github.projectmapk.jackson.module.kogera.GenericValueClassUnboxConverter @@ -29,28 +32,37 @@ import java.lang.reflect.Method import java.lang.reflect.Modifier import java.math.BigInteger -internal object UByteSerializer : StdSerializer(UByte::class.java) { +// The unsigned integer serializers extend StdScalarSerializer so that polymorphic type information +// (e.g. default typing on an `Any` property) is written as for other scalar values, +// and report the integer format to schema generators (StdScalarSerializer assumes a string by default). +internal object UByteSerializer : StdScalarSerializer(UByte::class.java) { private fun readResolve(): Any = UByteSerializer override fun serialize(value: UByte, gen: JsonGenerator, provider: SerializerProvider) = gen .writeNumber(value.toShort()) + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.INT) } -internal object UShortSerializer : StdSerializer(UShort::class.java) { +internal object UShortSerializer : StdScalarSerializer(UShort::class.java) { private fun readResolve(): Any = UShortSerializer override fun serialize(value: UShort, gen: JsonGenerator, provider: SerializerProvider) = gen .writeNumber(value.toInt()) + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.INT) } -internal object UIntSerializer : StdSerializer(UInt::class.java) { +internal object UIntSerializer : StdScalarSerializer(UInt::class.java) { private fun readResolve(): Any = UIntSerializer override fun serialize(value: UInt, gen: JsonGenerator, provider: SerializerProvider) = gen .writeNumber(value.toLong()) + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.LONG) } -internal object ULongSerializer : StdSerializer(ULong::class.java) { +internal object ULongSerializer : StdScalarSerializer(ULong::class.java) { private fun readResolve(): Any = ULongSerializer override fun serialize(value: ULong, gen: JsonGenerator, provider: SerializerProvider) { @@ -60,6 +72,8 @@ internal object ULongSerializer : StdSerializer(ULong::class.java) { else -> gen.writeNumber(BigInteger(value.toString())) } } + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.BIG_INTEGER) } // Class must be UnboxableValueClass. diff --git a/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersJsonFormatVisitorTest.kt b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersJsonFormatVisitorTest.kt new file mode 100644 index 00000000..a0c1aed7 --- /dev/null +++ b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersJsonFormatVisitorTest.kt @@ -0,0 +1,64 @@ +package io.github.projectmapk.jackson.module.kogera.zPorted.test + +import com.fasterxml.jackson.core.JsonParser.NumberType +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonAnyFormatVisitor +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonStringFormatVisitor +import io.github.projectmapk.jackson.module.kogera.defaultMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The unsigned integer serializers report the integer format to schema generators + * (previously "any", the StdSerializer default). + */ +internal class UnsignedNumbersJsonFormatVisitorTest { + private class RecordingVisitor : JsonFormatVisitorWrapper.Base() { + var format: String? = null + var numberType: NumberType? = null + + override fun expectIntegerFormat(type: JavaType): JsonIntegerFormatVisitor { + format = "integer" + + return object : JsonIntegerFormatVisitor.Base() { + override fun numberType(type: NumberType) { + numberType = type + } + } + } + + override fun expectAnyFormat(type: JavaType): JsonAnyFormatVisitor? { + format = "any" + return super.expectAnyFormat(type) + } + + override fun expectStringFormat(type: JavaType): JsonStringFormatVisitor? { + format = "string" + return super.expectStringFormat(type) + } + } + + private fun visit(type: Class<*>): RecordingVisitor = + RecordingVisitor().also { defaultMapper.acceptJsonFormatVisitor(type, it) } + + private fun assertInteger(type: Class<*>, numberType: NumberType) { + val visitor = visit(type) + + assertEquals("integer", visitor.format) + assertEquals(numberType, visitor.numberType) + } + + @Test + fun `UByte is an integer`() = assertInteger(UByte::class.java, NumberType.INT) + + @Test + fun `UShort is an integer`() = assertInteger(UShort::class.java, NumberType.INT) + + @Test + fun `UInt is an integer`() = assertInteger(UInt::class.java, NumberType.LONG) + + @Test + fun `ULong is an integer`() = assertInteger(ULong::class.java, NumberType.BIG_INTEGER) +} diff --git a/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersTypeInfoTest.kt b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersTypeInfoTest.kt new file mode 100644 index 00000000..642e15c5 --- /dev/null +++ b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersTypeInfoTest.kt @@ -0,0 +1,142 @@ +package io.github.projectmapk.jackson.module.kogera.zPorted.test + +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.JsonTypeInfo.As +import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator +import io.github.projectmapk.jackson.module.kogera.defaultMapper +import io.github.projectmapk.jackson.module.kogera.jacksonMapperBuilder +import io.github.projectmapk.jackson.module.kogera.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * Unsigned numbers stored in a position that requires polymorphic type information + * (an `Any` property under default typing, or a property annotated with `@JsonTypeInfo`) + * are written with a type id, as other scalar values are, e.g. `["kotlin.UInt",1]`. + * + * Reading such input already worked before, so the round-trip tests check that + * the written form is the one the deserializers expect. + */ +internal class UnsignedNumbersTypeInfoTest { + data class AnyHolder(val value: Any) + + data class AnnotatedHolder(@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) val value: Any) + + data class AnnotatedWrapperObjectHolder( + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = As.WRAPPER_OBJECT) val value: Any, + ) + + private val typeValidator = BasicPolymorphicTypeValidator.builder().allowIfBaseType(Any::class.java).build() + + private val defaultTypingMapper = jacksonMapperBuilder() + .activateDefaultTyping(typeValidator, DefaultTyping.OBJECT_AND_NON_CONCRETE, As.PROPERTY) + .build() + + // The default validator denies `Any` as base type, so one that allows it is needed for the `Any` properties. + private val annotatedMapper = jacksonMapperBuilder().polymorphicTypeValidator(typeValidator).build() + + abstract inner class ForType(private val value: T, private val json: String) { + private val id = value::class.java.name + + @Test + fun `default typing - type id is written`() { + assertEquals("""{"value":["$id",$json]}""", defaultTypingMapper.writeValueAsString(AnyHolder(value))) + } + + @Test + fun `default typing - round trip`() { + val expected = AnyHolder(value) + val json = defaultTypingMapper.writeValueAsString(expected) + + assertEquals(expected, defaultTypingMapper.readValue(json)) + } + + @Test + fun `JsonTypeInfo as property - type id is written`() { + assertEquals("""{"value":["$id",$json]}""", annotatedMapper.writeValueAsString(AnnotatedHolder(value))) + } + + @Test + fun `JsonTypeInfo as property - round trip`() { + val expected = AnnotatedHolder(value) + val json = annotatedMapper.writeValueAsString(expected) + + assertEquals(expected, annotatedMapper.readValue(json)) + } + + @Test + fun `JsonTypeInfo as wrapper object - type id is written`() { + assertEquals( + """{"value":{"$id":$json}}""", + annotatedMapper.writeValueAsString(AnnotatedWrapperObjectHolder(value)), + ) + } + + @Test + fun `JsonTypeInfo as wrapper object - round trip`() { + val expected = AnnotatedWrapperObjectHolder(value) + val json = annotatedMapper.writeValueAsString(expected) + + assertEquals(expected, annotatedMapper.readValue(json)) + } + } + + @Nested + inner class ForUByte : ForType(UByte.MAX_VALUE, "255") + + @Nested + inner class ForUShort : ForType(UShort.MAX_VALUE, "65535") + + @Nested + inner class ForUInt : ForType(UInt.MAX_VALUE, "4294967295") + + @Nested + inner class ForULong : ForType(ULong.MAX_VALUE, "18446744073709551615") + + data class AnnotatedListHolder(@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) val values: List) + + // @JsonTypeInfo on a collection property applies to its contents, + // so here the serializer is selected by the declared unsigned type rather than by the runtime type. + @Test + fun `JsonTypeInfo on collection contents`() { + val expected = AnnotatedListHolder(listOf(1u, UInt.MAX_VALUE)) + val json = annotatedMapper.writeValueAsString(expected) + + assertEquals("""{"values":[["kotlin.UInt",1],["kotlin.UInt",4294967295]]}""", json) + assertEquals(expected, annotatedMapper.readValue(json)) + } + + data class DeclaredHolder(val value: UInt, val nullable: UInt?) + + // The unsigned types are final, so default typing other than EVERYTHING + // does not apply to properties declared with them. + @Test + fun `not applied to declared properties under NON_FINAL`() { + val mapper = jacksonMapperBuilder() + .activateDefaultTyping(typeValidator, DefaultTyping.NON_FINAL, As.PROPERTY) + .build() + val expected = DeclaredHolder(1u, 2u) + val json = mapper.writeValueAsString(expected) + + assertEquals("""{"value":1,"nullable":2}""", json) + assertEquals(expected, mapper.readValue(json)) + } + + data class NullableHolder(val nullable: UInt?) + + // A nullable unsigned property is boxed on the JVM, so EVERYTHING applies type information to it. + // EVERYTHING also adds a type id to the root object, so only the property is compared. + @Test + fun `applied to nullable declared property under EVERYTHING`() { + val mapper = jacksonMapperBuilder() + .activateDefaultTyping(typeValidator, DefaultTyping.EVERYTHING, As.PROPERTY) + .build() + val expected = NullableHolder(2u) + val json = mapper.writeValueAsString(expected) + + assertEquals(defaultMapper.readTree("""["kotlin.UInt",2]"""), defaultMapper.readTree(json)["nullable"]) + assertEquals(expected, mapper.readValue(json)) + } +}