From b180ed63facc7f0f1b040c3f1a5b8ce7c05b40e7 Mon Sep 17 00:00:00 2001 From: Damian Malczewski Date: Sat, 14 Mar 2026 22:03:16 +0100 Subject: [PATCH 1/2] Add tests for kotlin interop --- buildSrc/build.gradle.kts | 1 + ...ernal.kotlin-interop-convention.gradle.kts | 9 + gradle/libs.versions.toml | 4 + problem4j-spring-webflux/build.gradle.kts | 3 + .../app/rest/BindingKotlinController.java | 140 ++++++++ .../integration/BindingKotlinWebFluxTest.java | 305 ++++++++++++++++++ .../spring/webflux/app/model/KotlinModel.kt | 54 ++++ problem4j-spring-webmvc/build.gradle.kts | 3 + .../app/rest/BindingKotlinController.java | 140 ++++++++ .../integration/BindingKotlinWebMvcTest.java | 303 +++++++++++++++++ .../spring/webmvc/app/model/KotlinModel.kt | 54 ++++ 11 files changed, 1016 insertions(+) create mode 100644 buildSrc/src/main/kotlin/internal.kotlin-interop-convention.gradle.kts create mode 100644 problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/rest/BindingKotlinController.java create mode 100644 problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java create mode 100644 problem4j-spring-webflux/src/test/kotlin/io/github/problem4j/spring/webflux/app/model/KotlinModel.kt create mode 100644 problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/rest/BindingKotlinController.java create mode 100644 problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java create mode 100644 problem4j-spring-webmvc/src/test/kotlin/io/github/problem4j/spring/webmvc/app/model/KotlinModel.kt diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 3fe40904..0252baf8 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -14,6 +14,7 @@ repositories { dependencies { implementation(plugin(libs.plugins.errorprone)) + implementation(plugin(libs.plugins.kotlin.jvm)) } fun plugin(plugin: Provider): Provider = plugin.map { diff --git a/buildSrc/src/main/kotlin/internal.kotlin-interop-convention.gradle.kts b/buildSrc/src/main/kotlin/internal.kotlin-interop-convention.gradle.kts new file mode 100644 index 00000000..1161091b --- /dev/null +++ b/buildSrc/src/main/kotlin/internal.kotlin-interop-convention.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("org.jetbrains.kotlin.jvm") +} + +kotlin { + compilerOptions { + javaParameters = true + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfe4aecf..0cd0a0c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ jspecify = "1.0.0" errorprone = "2.48.0" errorprone-plugin = "5.1.0" +kotlin = "2.3.10" nmcp = "1.4.4" nullaway = "0.13.1" problem4j-core = "1.4.3" @@ -12,6 +13,7 @@ spring-boot = "4.0.3" [plugins] errorprone = { id = "net.ltgt.errorprone", version.ref = "errorprone-plugin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } nmcp = { id = "com.gradleup.nmcp", version.ref = "nmcp" } nmcp-aggregation = { id = "com.gradleup.nmcp.aggregation", version.ref = "nmcp" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } @@ -23,6 +25,7 @@ spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-depe # direct versions errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone" } jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" } problem4j-core = { module = "io.github.problem4j:problem4j-core", version.ref = "problem4j-core" } problem4j-jackson2 = { module = "io.github.problem4j:problem4j-jackson2", version.ref = "problem4j-jackson2" } @@ -45,6 +48,7 @@ spring-boot-starter-webmvc-test = { module = "org.springframework.boot:spring-bo spring-web = { module = "org.springframework:spring-web" } jackson2-databind = { module = "com.fasterxml.jackson.core:jackson-databind" } jackson3-dataformat-xml = { module = "tools.jackson.dataformat:jackson-dataformat-xml" } +jackson3-module-kotlin = { module = "tools.jackson.module:jackson-module-kotlin" } jakarta-servlet-api = { module = "jakarta.servlet:jakarta.servlet-api" } jakarta-validation-api = { module = "jakarta.validation:jakarta.validation-api" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } diff --git a/problem4j-spring-webflux/build.gradle.kts b/problem4j-spring-webflux/build.gradle.kts index 4a167085..391102ca 100644 --- a/problem4j-spring-webflux/build.gradle.kts +++ b/problem4j-spring-webflux/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("internal.errorprone-convention") id("internal.jacoco-convention") id("internal.java-library-convention") + id("internal.kotlin-interop-convention") id("internal.publishing-convention") alias(libs.plugins.nmcp) } @@ -25,6 +26,8 @@ dependencies { testImplementation(libs.spring.boot.starter.webflux) testImplementation(libs.spring.boot.starter.webflux.test) testImplementation(libs.spring.boot.validation) + testImplementation(libs.jackson3.module.kotlin) + testImplementation(libs.kotlin.reflect) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/rest/BindingKotlinController.java b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/rest/BindingKotlinController.java new file mode 100644 index 00000000..3fa8d487 --- /dev/null +++ b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/rest/BindingKotlinController.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025-2026 The Problem4J Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.github.problem4j.spring.webflux.app.rest; + +import io.github.problem4j.spring.webflux.app.model.KotlinModel; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/binding-kotlin") +public class BindingKotlinController { + + @PostMapping(path = "/int", consumes = MediaType.APPLICATION_JSON_VALUE) + public String intValue(@RequestBody KotlinModel.KotlinIntRequest request) { + return "OK"; + } + + @PostMapping(path = "/long", consumes = MediaType.APPLICATION_JSON_VALUE) + public String longValue(@RequestBody KotlinModel.KotlinLongRequest request) { + return "OK"; + } + + @PostMapping(path = "/short", consumes = MediaType.APPLICATION_JSON_VALUE) + public String shortValue(@RequestBody KotlinModel.KotlinShortRequest request) { + return "OK"; + } + + @PostMapping(path = "/byte", consumes = MediaType.APPLICATION_JSON_VALUE) + public String byteValue(@RequestBody KotlinModel.KotlinByteRequest request) { + return "OK"; + } + + @PostMapping(path = "/float", consumes = MediaType.APPLICATION_JSON_VALUE) + public String floatValue(@RequestBody KotlinModel.KotlinFloatRequest request) { + return "OK"; + } + + @PostMapping(path = "/double", consumes = MediaType.APPLICATION_JSON_VALUE) + public String doubleValue(@RequestBody KotlinModel.KotlinDoubleRequest request) { + return "OK"; + } + + @PostMapping(path = "/boolean", consumes = MediaType.APPLICATION_JSON_VALUE) + public String booleanValue(@RequestBody KotlinModel.KotlinBooleanRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/int", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedInt(@RequestBody KotlinModel.KotlinNestedIntRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/long", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedLong(@RequestBody KotlinModel.KotlinNestedLongRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/short", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedShort(@RequestBody KotlinModel.KotlinNestedShortRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/byte", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedByte(@RequestBody KotlinModel.KotlinNestedByteRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/float", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedFloat(@RequestBody KotlinModel.KotlinNestedFloatRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/double", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedDouble(@RequestBody KotlinModel.KotlinNestedDoubleRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/boolean", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedBoolean(@RequestBody KotlinModel.KotlinNestedBooleanRequest request) { + return "OK"; + } + + @PostMapping(path = "/complex", consumes = MediaType.APPLICATION_JSON_VALUE) + public String complex(@RequestBody KotlinModel.KotlinComplexRequest request) { + return "OK"; + } + + @PostMapping(path = "/nullable", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nullable(@RequestBody KotlinModel.KotlinNullableRequest request) { + return "OK"; + } + + @PostMapping(path = "/default", consumes = MediaType.APPLICATION_JSON_VALUE) + public String defaultValue(@RequestBody KotlinModel.KotlinDefaultRequest request) { + return "OK"; + } + + @PostMapping(path = "/list/non-null-elements", consumes = MediaType.APPLICATION_JSON_VALUE) + public String listNonNull(@RequestBody KotlinModel.KotlinListNonNullRequest request) { + return "OK"; + } + + @PostMapping(path = "/list/nullable-elements", consumes = MediaType.APPLICATION_JSON_VALUE) + public String listNullableElements( + @RequestBody KotlinModel.KotlinListNullableElementsRequest request) { + return "OK"; + } + + @PostMapping(path = "/map/non-null-values", consumes = MediaType.APPLICATION_JSON_VALUE) + public String mapNonNull(@RequestBody KotlinModel.KotlinMapValueNonNullRequest request) { + return "OK"; + } + + @PostMapping(path = "/map/nullable-values", consumes = MediaType.APPLICATION_JSON_VALUE) + public String mapNullable(@RequestBody KotlinModel.KotlinMapValueNullableRequest request) { + return "OK"; + } +} diff --git a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java new file mode 100644 index 00000000..27e6d4c3 --- /dev/null +++ b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025-2026 The Problem4J Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.github.problem4j.spring.webflux.integration; + +import static io.github.problem4j.spring.web.ProblemSupport.KIND_EXTENSION; +import static io.github.problem4j.spring.web.ProblemSupport.PROPERTY_EXTENSION; +import static io.github.problem4j.spring.web.ProblemSupport.TYPE_MISMATCH_DETAIL; +import static org.assertj.core.api.Assertions.assertThat; + +import io.github.problem4j.core.Problem; +import io.github.problem4j.spring.webflux.app.WebFluxTestApp; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest( + classes = {WebFluxTestApp.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +class BindingKotlinWebFluxTest { + + @Autowired private WebTestClient webTestClient; + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": null }", + "/binding-kotlin/long | { \"value\": null }", + "/binding-kotlin/short | { \"value\": null }", + "/binding-kotlin/byte | { \"value\": null }", + "/binding-kotlin/float | { \"value\": null }", + "/binding-kotlin/double | { \"value\": null }", + "/binding-kotlin/boolean | { \"value\": null }" + }) + void givenNullValue_whenPost_thenReturnTypeMismatch(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> { + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + }); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": null } }" + }) + void givenNullInNestedObjectValue_whenPost_thenReturnTypeMismatch(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> { + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "nested.value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + }); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": null }", + "/binding-kotlin/nested/int | { }", + "/binding-kotlin/nested/short | { \"nested\": null }", + "/binding-kotlin/nested/short | { }", + "/binding-kotlin/nested/float | { \"nested\": null }", + "/binding-kotlin/nested/float | { }", + "/binding-kotlin/nested/boolean | { \"nested\": null }", + "/binding-kotlin/nested/boolean | { }" + }) + void givenNullNestedObject_whenPost_thenReturnTypeMismatch(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "nested") + .build())); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "{ \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | flag | boolean", + "{ \"timestamp\": \"notLong\", \"flag\": \"notBool\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | timestamp | integer", + "{ \"amount\": \"notFloat\", \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"shortNested\": { \"value\": \"notShort\" } } | amount | number", + "{ \"shortNested\": { \"value\": \"notShort\" }, \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\" } | shortNested.value | integer" + }) + void givenMalformedComplexKotlinObject_whenPost_thenReturnProblemWithFirstInvalidFieldAsProperty( + String json, String expectedProperty, String expectedKind) { + webTestClient + .post() + .uri("/binding-kotlin/complex") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, expectedProperty) + .extension(KIND_EXTENSION, expectedKind) + .build())); + } + + @Test + void givenNullableProperty_whenPostNull_thenReturnOk() { + webTestClient + .post() + .uri("/binding-kotlin/nullable") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{ \"value\": null }") + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void givenDefaultParam_whenMissingProperty_thenUseDefault() { + webTestClient + .post() + .uri("/binding-kotlin/default") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{}") + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void givenListElementNullability_whenElementNull_thenBehaviorMatchesNullability() { + webTestClient + .post() + .uri("/binding-kotlin/list/non-null-elements") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{ \"values\": [null] }") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "values") + .extension(KIND_EXTENSION, "integer") + .build())); + } + + @Test + void givenListElementNullable_whenElementNull_thenReturnOk() { + webTestClient + .post() + .uri("/binding-kotlin/list/nullable-elements") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{ \"values\": [null] }") + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void givenMapValueNullability_whenValueNull_thenBehaviorMatchesNullability() { + webTestClient + .post() + .uri("/binding-kotlin/map/non-null-values") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{ \"map\": { \"k\": null } }") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "map.k") + .extension(KIND_EXTENSION, "integer") + .build())); + } + + @Test + void givenMapValueNullable_whenValueNull_thenReturnOk() { + webTestClient + .post() + .uri("/binding-kotlin/map/nullable-values") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{ \"map\": { \"k\": null } }") + .exchange() + .expectStatus() + .isOk(); + } +} diff --git a/problem4j-spring-webflux/src/test/kotlin/io/github/problem4j/spring/webflux/app/model/KotlinModel.kt b/problem4j-spring-webflux/src/test/kotlin/io/github/problem4j/spring/webflux/app/model/KotlinModel.kt new file mode 100644 index 00000000..72ee8d92 --- /dev/null +++ b/problem4j-spring-webflux/src/test/kotlin/io/github/problem4j/spring/webflux/app/model/KotlinModel.kt @@ -0,0 +1,54 @@ +package io.github.problem4j.spring.webflux.app.model + +interface KotlinModel { + + data class KotlinIntRequest(val value: Int) + + data class KotlinLongRequest(val value: Long) + + data class KotlinShortRequest(val value: Short) + + data class KotlinByteRequest(val value: Byte) + + data class KotlinFloatRequest(val value: Float) + + data class KotlinDoubleRequest(val value: Double) + + data class KotlinBooleanRequest(val value: Boolean) + + data class KotlinNestedIntRequest(val nested: KotlinIntRequest) + + data class KotlinNestedLongRequest(val nested: KotlinLongRequest) + + data class KotlinNestedShortRequest(val nested: KotlinShortRequest) + + data class KotlinNestedByteRequest(val nested: KotlinByteRequest) + + data class KotlinNestedFloatRequest(val nested: KotlinFloatRequest) + + data class KotlinNestedDoubleRequest(val nested: KotlinDoubleRequest) + + data class KotlinNestedBooleanRequest(val nested: KotlinBooleanRequest) + + data class KotlinTreeRequest(val leaf: KotlinNestedShortRequest) + + data class KotlinComplexRequest( + val flag: Boolean, + val timestamp: Long, + val amount: Double, + val shortNested: KotlinShortRequest, + val tree: KotlinTreeRequest, + ) + + data class KotlinNullableRequest(val value: Int?) + + data class KotlinDefaultRequest(val x: Int = 5) + + data class KotlinListNonNullRequest(val values: List) + + data class KotlinListNullableElementsRequest(val values: List) + + data class KotlinMapValueNonNullRequest(val map: Map) + + data class KotlinMapValueNullableRequest(val map: Map) +} diff --git a/problem4j-spring-webmvc/build.gradle.kts b/problem4j-spring-webmvc/build.gradle.kts index 23189aec..efb61c97 100644 --- a/problem4j-spring-webmvc/build.gradle.kts +++ b/problem4j-spring-webmvc/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("internal.errorprone-convention") id("internal.jacoco-convention") id("internal.java-library-convention") + id("internal.kotlin-interop-convention") id("internal.publishing-convention") alias(libs.plugins.nmcp) } @@ -27,6 +28,8 @@ dependencies { testImplementation(libs.spring.boot.starter.webmvc) testImplementation(libs.spring.boot.validation) testImplementation(libs.jackson3.dataformat.xml) + testImplementation(libs.jackson3.module.kotlin) + testImplementation(libs.kotlin.reflect) // Included because TestRestTemplate requires it if used with actual web environment in tests. Not migrating to // WebTestClient either for easier merges with 1.x versions. diff --git a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/rest/BindingKotlinController.java b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/rest/BindingKotlinController.java new file mode 100644 index 00000000..ad005580 --- /dev/null +++ b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/rest/BindingKotlinController.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025-2026 The Problem4J Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.github.problem4j.spring.webmvc.app.rest; + +import io.github.problem4j.spring.webmvc.app.model.KotlinModel; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/binding-kotlin") +public class BindingKotlinController { + + @PostMapping(path = "/int", consumes = MediaType.APPLICATION_JSON_VALUE) + public String intValue(@RequestBody KotlinModel.KotlinIntRequest request) { + return "OK"; + } + + @PostMapping(path = "/long", consumes = MediaType.APPLICATION_JSON_VALUE) + public String longValue(@RequestBody KotlinModel.KotlinLongRequest request) { + return "OK"; + } + + @PostMapping(path = "/short", consumes = MediaType.APPLICATION_JSON_VALUE) + public String shortValue(@RequestBody KotlinModel.KotlinShortRequest request) { + return "OK"; + } + + @PostMapping(path = "/byte", consumes = MediaType.APPLICATION_JSON_VALUE) + public String byteValue(@RequestBody KotlinModel.KotlinByteRequest request) { + return "OK"; + } + + @PostMapping(path = "/float", consumes = MediaType.APPLICATION_JSON_VALUE) + public String floatValue(@RequestBody KotlinModel.KotlinFloatRequest request) { + return "OK"; + } + + @PostMapping(path = "/double", consumes = MediaType.APPLICATION_JSON_VALUE) + public String doubleValue(@RequestBody KotlinModel.KotlinDoubleRequest request) { + return "OK"; + } + + @PostMapping(path = "/boolean", consumes = MediaType.APPLICATION_JSON_VALUE) + public String booleanValue(@RequestBody KotlinModel.KotlinBooleanRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/int", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedInt(@RequestBody KotlinModel.KotlinNestedIntRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/long", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedLong(@RequestBody KotlinModel.KotlinNestedLongRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/short", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedShort(@RequestBody KotlinModel.KotlinNestedShortRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/byte", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedByte(@RequestBody KotlinModel.KotlinNestedByteRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/float", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedFloat(@RequestBody KotlinModel.KotlinNestedFloatRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/double", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedDouble(@RequestBody KotlinModel.KotlinNestedDoubleRequest request) { + return "OK"; + } + + @PostMapping(path = "/nested/boolean", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nestedBoolean(@RequestBody KotlinModel.KotlinNestedBooleanRequest request) { + return "OK"; + } + + @PostMapping(path = "/complex", consumes = MediaType.APPLICATION_JSON_VALUE) + public String complex(@RequestBody KotlinModel.KotlinComplexRequest request) { + return "OK"; + } + + @PostMapping(path = "/nullable", consumes = MediaType.APPLICATION_JSON_VALUE) + public String nullable(@RequestBody KotlinModel.KotlinNullableRequest request) { + return "OK"; + } + + @PostMapping(path = "/default", consumes = MediaType.APPLICATION_JSON_VALUE) + public String defaultValue(@RequestBody KotlinModel.KotlinDefaultRequest request) { + return "OK"; + } + + @PostMapping(path = "/list/non-null-elements", consumes = MediaType.APPLICATION_JSON_VALUE) + public String listNonNull(@RequestBody KotlinModel.KotlinListNonNullRequest request) { + return "OK"; + } + + @PostMapping(path = "/list/nullable-elements", consumes = MediaType.APPLICATION_JSON_VALUE) + public String listNullableElements( + @RequestBody KotlinModel.KotlinListNullableElementsRequest request) { + return "OK"; + } + + @PostMapping(path = "/map/non-null-values", consumes = MediaType.APPLICATION_JSON_VALUE) + public String mapNonNull(@RequestBody KotlinModel.KotlinMapValueNonNullRequest request) { + return "OK"; + } + + @PostMapping(path = "/map/nullable-values", consumes = MediaType.APPLICATION_JSON_VALUE) + public String mapNullable(@RequestBody KotlinModel.KotlinMapValueNullableRequest request) { + return "OK"; + } +} diff --git a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java new file mode 100644 index 00000000..be33e701 --- /dev/null +++ b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2025-2026 The Problem4J Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.github.problem4j.spring.webmvc.integration; + +import static io.github.problem4j.spring.web.ProblemSupport.KIND_EXTENSION; +import static io.github.problem4j.spring.web.ProblemSupport.PROPERTY_EXTENSION; +import static io.github.problem4j.spring.web.ProblemSupport.TYPE_MISMATCH_DETAIL; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import io.github.problem4j.core.Problem; +import io.github.problem4j.spring.webmvc.app.WebMvcTestApp; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import tools.jackson.databind.json.JsonMapper; + +@SpringBootTest( + classes = {WebMvcTestApp.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +class BindingKotlinWebMvcTest { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private JsonMapper jsonMapper; + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": null }", + "/binding-kotlin/long | { \"value\": null }", + "/binding-kotlin/short | { \"value\": null }", + "/binding-kotlin/byte | { \"value\": null }", + "/binding-kotlin/float | { \"value\": null }", + "/binding-kotlin/double | { \"value\": null }", + "/binding-kotlin/boolean | { \"value\": null }" + }) + void givenNullValue_whenPost_thenReturnTypeMismatch(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": null } }" + }) + void givenNullInNestedObjectValue_whenPost_thenReturnTypeMismatch(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "nested.value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": null }", + "/binding-kotlin/nested/int | { }", + "/binding-kotlin/nested/short | { \"nested\": null }", + "/binding-kotlin/nested/short | { }", + "/binding-kotlin/nested/float | { \"nested\": null }", + "/binding-kotlin/nested/float | { }", + "/binding-kotlin/nested/boolean | { \"nested\": null }", + "/binding-kotlin/nested/boolean | { }" + }) + void givenNullNestedObject_whenPost_thenReturnTypeMismatch(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "nested") + .build()); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "{ \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | flag | boolean", + "{ \"timestamp\": \"notLong\", \"flag\": \"notBool\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | timestamp | integer", + "{ \"amount\": \"notFloat\", \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"shortNested\": { \"value\": \"notShort\" } } | amount | number", + "{ \"shortNested\": { \"value\": \"notShort\" }, \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\" } | shortNested.value | integer" + }) + void givenMalformedComplexKotlinObject_whenPost_thenReturnProblemWithFirstInvalidFieldAsProperty( + String json, String expectedProperty, String expectedKind) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + "/binding-kotlin/complex", new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, expectedProperty) + .extension(KIND_EXTENSION, expectedKind) + .build()); + } + + @Test + void givenNullableProperty_whenPostNull_thenReturnOk() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + "/binding-kotlin/nullable", + new HttpEntity<>("{ \"value\": null }", headers), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void givenDefaultParam_whenMissingProperty_thenUseDefault() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + "/binding-kotlin/default", new HttpEntity<>("{}", headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void givenListElementNullability_whenElementNull_thenBehaviorMatchesNullability() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity nonNullResponse = + restTemplate.postForEntity( + "/binding-kotlin/list/non-null-elements", + new HttpEntity<>("{ \"values\": [null] }", headers), + String.class); + + assertThat(nonNullResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + Problem problem1 = jsonMapper.readValue(nonNullResponse.getBody(), Problem.class); + assertThat(problem1) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "values") + .extension(KIND_EXTENSION, "integer") + .build()); + } + + @Test + void givenListElementNullable_whenElementNull_thenReturnOk() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity nullableResponse = + restTemplate.postForEntity( + "/binding-kotlin/list/nullable-elements", + new HttpEntity<>("{ \"values\": [null] }", headers), + String.class); + + assertThat(nullableResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void givenMapValueNullability_whenValueNull_thenBehaviorMatchesNullability() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity nonNullResponse = + restTemplate.postForEntity( + "/binding-kotlin/map/non-null-values", + new HttpEntity<>("{ \"map\": { \"k\": null } }", headers), + String.class); + + assertThat(nonNullResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + Problem problem2 = jsonMapper.readValue(nonNullResponse.getBody(), Problem.class); + assertThat(problem2) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "map.k") + .extension(KIND_EXTENSION, "integer") + .build()); + } + + @Test + void givenMapValueNullable_whenValueNull_thenReturnOk() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity nullableResponse = + restTemplate.postForEntity( + "/binding-kotlin/map/nullable-values", + new HttpEntity<>("{ \"map\": { \"k\": null } }", headers), + String.class); + + assertThat(nullableResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/problem4j-spring-webmvc/src/test/kotlin/io/github/problem4j/spring/webmvc/app/model/KotlinModel.kt b/problem4j-spring-webmvc/src/test/kotlin/io/github/problem4j/spring/webmvc/app/model/KotlinModel.kt new file mode 100644 index 00000000..026c571f --- /dev/null +++ b/problem4j-spring-webmvc/src/test/kotlin/io/github/problem4j/spring/webmvc/app/model/KotlinModel.kt @@ -0,0 +1,54 @@ +package io.github.problem4j.spring.webmvc.app.model + +interface KotlinModel { + + data class KotlinIntRequest(val value: Int) + + data class KotlinLongRequest(val value: Long) + + data class KotlinShortRequest(val value: Short) + + data class KotlinByteRequest(val value: Byte) + + data class KotlinFloatRequest(val value: Float) + + data class KotlinDoubleRequest(val value: Double) + + data class KotlinBooleanRequest(val value: Boolean) + + data class KotlinNestedIntRequest(val nested: KotlinIntRequest) + + data class KotlinNestedLongRequest(val nested: KotlinLongRequest) + + data class KotlinNestedShortRequest(val nested: KotlinShortRequest) + + data class KotlinNestedByteRequest(val nested: KotlinByteRequest) + + data class KotlinNestedFloatRequest(val nested: KotlinFloatRequest) + + data class KotlinNestedDoubleRequest(val nested: KotlinDoubleRequest) + + data class KotlinNestedBooleanRequest(val nested: KotlinBooleanRequest) + + data class KotlinTreeRequest(val leaf: KotlinNestedShortRequest) + + data class KotlinComplexRequest( + val flag: Boolean, + val timestamp: Long, + val amount: Double, + val shortNested: KotlinShortRequest, + val tree: KotlinTreeRequest, + ) + + data class KotlinNullableRequest(val value: Int?) + + data class KotlinDefaultRequest(val x: Int = 5) + + data class KotlinListNonNullRequest(val values: List) + + data class KotlinListNullableElementsRequest(val values: List) + + data class KotlinMapValueNonNullRequest(val map: Map) + + data class KotlinMapValueNullableRequest(val map: Map) +} From b38623f709a18aac4d5111ec60f0b861453fafec Mon Sep 17 00:00:00 2001 From: Damian Malczewski Date: Sat, 14 Mar 2026 23:00:17 +0100 Subject: [PATCH 2/2] Add more tests for edge cases --- .../integration/BindingKotlinWebFluxTest.java | 309 ++++++++++++++++++ .../integration/BindingKotlinWebMvcTest.java | 292 +++++++++++++++++ 2 files changed, 601 insertions(+) diff --git a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java index 27e6d4c3..5362d158 100644 --- a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java +++ b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingKotlinWebFluxTest.java @@ -302,4 +302,313 @@ void givenMapValueNullable_whenValueNull_thenReturnOk() { .expectStatus() .isOk(); } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": 42 }", + "/binding-kotlin/long | { \"value\": 9223372036854775807 }", + "/binding-kotlin/short | { \"value\": 123 }", + "/binding-kotlin/byte | { \"value\": 12 }", + "/binding-kotlin/float | { \"value\": 3.14 }", + "/binding-kotlin/double | { \"value\": 2.71828 }", + "/binding-kotlin/boolean | { \"value\": true }" + }) + void givenValidPrimitive_whenPost_thenReturnOk(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("OK"); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": \"notInt\" }", + "/binding-kotlin/int | { \"value\": [\"notInt\"] }", + "/binding-kotlin/int | { \"value\": { \"notInt\": true } }", + "/binding-kotlin/int | { \"value\": null }", + "/binding-kotlin/int | { }", + "/binding-kotlin/long | { \"value\": \"notLong\" }", + "/binding-kotlin/long | { \"value\": [\"notLong\"] }", + "/binding-kotlin/long | { \"value\": { \"notLong\": true } }", + "/binding-kotlin/long | { \"value\": null }", + "/binding-kotlin/long | { }", + "/binding-kotlin/short | { \"value\": \"notShort\" }", + "/binding-kotlin/short | { \"value\": [\"notShort\"] }", + "/binding-kotlin/short | { \"value\": { \"notShort\":true } }", + "/binding-kotlin/short | { \"value\": null }", + "/binding-kotlin/short | { }", + "/binding-kotlin/byte | { \"value\": \"notByte\" }", + "/binding-kotlin/byte | { \"value\": [\"notByte\"] }", + "/binding-kotlin/byte | { \"value\": { \"notByte\": true } }", + "/binding-kotlin/byte | { \"value\": null }", + "/binding-kotlin/byte | { }", + "/binding-kotlin/float | { \"value\": \"notFloat\" }", + "/binding-kotlin/float | { \"value\": [\"notFloat\"] }", + "/binding-kotlin/float | { \"value\": { \"notFloat\": true } }", + "/binding-kotlin/float | { \"value\": null }", + "/binding-kotlin/float | { }", + "/binding-kotlin/double | { \"value\": \"notDouble\" }", + "/binding-kotlin/double | { \"value\": [\"notDouble\"] }", + "/binding-kotlin/double | { \"value\": { \"notDouble\": true } }", + "/binding-kotlin/double | { \"value\": null }", + "/binding-kotlin/double | { }", + "/binding-kotlin/boolean | { \"value\": \"notBool\" }", + "/binding-kotlin/boolean | { \"value\": [\"notBool\"] }", + "/binding-kotlin/boolean | { \"value\": { \"notBool\": true } }", + "/binding-kotlin/boolean | { \"value\": null }", + "/binding-kotlin/boolean | { }", + }) + void givenMalformedPrimitive_whenPost_thenReturnProblem(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> { + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + Problem expected = + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, expectedKind) + .build(); + + if (!problem.equals(expected)) { + assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value())); + } + }); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": { \"value\": 42 } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": 9223372036854775807 } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": 123 } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": 12 } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": 3.14 } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": 2.71828 } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": true } }" + }) + void givenValidNested_whenPost_thenReturnOk(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("OK"); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": { \"value\": \"notInt\" } }", + "/binding-kotlin/nested/int | { \"nested\": { \"value\": [\"notInt\"] } }", + "/binding-kotlin/nested/int | { \"nested\": { \"value\": { \"notInt\": true } } }", + "/binding-kotlin/nested/int | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/int | { \"nested\": { } } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": \"notLong\" } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": [\"notLong\"] } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": { \"notLong\": true } } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/long | { \"nested\": { } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": \"notShort\" } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": [\"notShort\"] } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": { \"notShort\":true } } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/short | { \"nested\": { } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": \"notByte\" } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": [\"notByte\"] } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": { \"notByte\": true } } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/byte | { \"nested\": { } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": \"notFloat\" } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": [\"notFloat\"] } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": { \"notFloat\": true } } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/float | { \"nested\": { } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": \"notDouble\" } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": [\"notDouble\"] } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": { \"notDouble\": true } } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": { \"notDouble\": null } } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": { } } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": \"notBool\" } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": [\"notBool\"] } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": { \"notBool\": true } } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": { \"notBool\": null } } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": { } } }", + }) + void givenMalformedNested_whenPost_thenReturnProblem(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> { + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "nested.value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + }); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": \"\" }", + "/binding-kotlin/long | { \"value\": \"\" }", + "/binding-kotlin/short | { \"value\": \"\" }", + "/binding-kotlin/byte | { \"value\": \"\" }", + "/binding-kotlin/float | { \"value\": \"\" }", + "/binding-kotlin/double | { \"value\": \"\" }", + "/binding-kotlin/boolean | { \"value\": \"\" }", + }) + void givenEmptyStringPrimitive_whenPost_thenReturnProblem(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> { + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + }); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": 2147483648 }", + "/binding-kotlin/long | { \"value\": 9223372036854775808 }", + "/binding-kotlin/short | { \"value\": 40000 }", + "/binding-kotlin/byte | { \"value\": 256 }", + }) + void givenOverflowPrimitive_whenPost_thenReturnProblem(String path, String json) { + webTestClient + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.BAD_REQUEST) + .expectHeader() + .contentType(Problem.CONTENT_TYPE) + .expectBody(Problem.class) + .value( + problem -> { + Problem expected = + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, "integer") + .build(); + + if (!problem.equals(expected)) { + assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value())); + } + }); + } + + @Test + void givenValidComplexRoot_whenPost_thenReturnOk() { + String json = + """ + { + "flag": true, + "timestamp": 1672531200000, + "amount": 12.34, + "shortNested": { "value": 3 }, + "tree": { "leaf": { "nested": { "value": 3 } } } + } + """; + + webTestClient + .post() + .uri("/binding-kotlin/complex") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(json) + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("OK"); + } } diff --git a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java index be33e701..28a1c0b6 100644 --- a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java +++ b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingKotlinWebMvcTest.java @@ -300,4 +300,296 @@ void givenMapValueNullable_whenValueNull_thenReturnOk() { assertThat(nullableResponse.getStatusCode()).isEqualTo(HttpStatus.OK); } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": 42 }", + "/binding-kotlin/long | { \"value\": 9223372036854775807 }", + "/binding-kotlin/short | { \"value\": 123 }", + "/binding-kotlin/byte | { \"value\": 12 }", + "/binding-kotlin/float | { \"value\": 3.14 }", + "/binding-kotlin/double | { \"value\": 2.71828 }", + "/binding-kotlin/boolean | { \"value\": true }" + }) + void givenValidPrimitive_whenPost_thenReturnOk(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("OK"); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": \"notInt\" }", + "/binding-kotlin/int | { \"value\": [\"notInt\"] }", + "/binding-kotlin/int | { \"value\": { \"notInt\": true } }", + "/binding-kotlin/int | { \"value\": null }", + "/binding-kotlin/int | { }", + "/binding-kotlin/long | { \"value\": \"notLong\" }", + "/binding-kotlin/long | { \"value\": [\"notLong\"] }", + "/binding-kotlin/long | { \"value\": { \"notLong\": true } }", + "/binding-kotlin/long | { \"value\": null }", + "/binding-kotlin/long | { }", + "/binding-kotlin/short | { \"value\": \"notShort\" }", + "/binding-kotlin/short | { \"value\": [\"notShort\"] }", + "/binding-kotlin/short | { \"value\": { \"notShort\":true } }", + "/binding-kotlin/short | { \"value\": null }", + "/binding-kotlin/short | { }", + "/binding-kotlin/byte | { \"value\": \"notByte\" }", + "/binding-kotlin/byte | { \"value\": [\"notByte\"] }", + "/binding-kotlin/byte | { \"value\": { \"notByte\": true } }", + "/binding-kotlin/byte | { \"value\": null }", + "/binding-kotlin/byte | { }", + "/binding-kotlin/float | { \"value\": \"notFloat\" }", + "/binding-kotlin/float | { \"value\": [\"notFloat\"] }", + "/binding-kotlin/float | { \"value\": { \"notFloat\": true } }", + "/binding-kotlin/float | { \"value\": null }", + "/binding-kotlin/float | { }", + "/binding-kotlin/double | { \"value\": \"notDouble\" }", + "/binding-kotlin/double | { \"value\": [\"notDouble\"] }", + "/binding-kotlin/double | { \"value\": { \"notDouble\": true } }", + "/binding-kotlin/double | { \"value\": null }", + "/binding-kotlin/double | { }", + "/binding-kotlin/boolean | { \"value\": \"notBool\" }", + "/binding-kotlin/boolean | { \"value\": [\"notBool\"] }", + "/binding-kotlin/boolean | { \"value\": { \"notBool\": true } }", + "/binding-kotlin/boolean | { \"value\": null }", + "/binding-kotlin/boolean | { }", + }) + void givenMalformedPrimitive_whenPost_thenReturnProblem(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + Problem expected = + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, expectedKind) + .build(); + + if (!problem.equals(expected)) { + assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value())); + } + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": { \"value\": 42 } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": 9223372036854775807 } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": 123 } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": 12 } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": 3.14 } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": 2.71828 } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": true } }" + }) + void givenValidNested_whenPost_thenReturnOk(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("OK"); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/nested/int | { \"nested\": { \"value\": \"notInt\" } }", + "/binding-kotlin/nested/int | { \"nested\": { \"value\": [\"notInt\"] } }", + "/binding-kotlin/nested/int | { \"nested\": { \"value\": { \"notInt\": true } } }", + "/binding-kotlin/nested/int | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/int | { \"nested\": { } } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": \"notLong\" } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": [\"notLong\"] } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": { \"notLong\": true } } }", + "/binding-kotlin/nested/long | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/long | { \"nested\": { } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": \"notShort\" } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": [\"notShort\"] } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": { \"notShort\":true } } }", + "/binding-kotlin/nested/short | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/short | { \"nested\": { } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": \"notByte\" } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": [\"notByte\"] } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": { \"notByte\": true } } }", + "/binding-kotlin/nested/byte | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/byte | { \"nested\": { } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": \"notFloat\" } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": [\"notFloat\"] } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": { \"notFloat\": true } } }", + "/binding-kotlin/nested/float | { \"nested\": { \"value\": null } }", + "/binding-kotlin/nested/float | { \"nested\": { } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": \"notDouble\" } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": [\"notDouble\"] } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": { \"notDouble\": true } } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": { \"notDouble\": null } } }", + "/binding-kotlin/nested/double | { \"nested\": { \"value\": { } } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": \"notBool\" } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": [\"notBool\"] } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": { \"notBool\": true } } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": { \"notBool\": null } } }", + "/binding-kotlin/nested/boolean | { \"nested\": { \"value\": { } } }", + }) + void givenMalformedNested_whenPost_thenReturnProblem(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "nested.value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": \"\" }", + "/binding-kotlin/long | { \"value\": \"\" }", + "/binding-kotlin/short | { \"value\": \"\" }", + "/binding-kotlin/byte | { \"value\": \"\" }", + "/binding-kotlin/float | { \"value\": \"\" }", + "/binding-kotlin/double | { \"value\": \"\" }", + "/binding-kotlin/boolean | { \"value\": \"\" }", + }) + void givenEmptyStringPrimitive_whenPost_thenReturnProblem(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + String expectedKind; + if (path.endsWith("/boolean")) { + expectedKind = "boolean"; + } else if (path.endsWith("/float") || path.endsWith("/double")) { + expectedKind = "number"; + } else { + expectedKind = "integer"; + } + + assertThat(problem) + .isEqualTo( + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, expectedKind) + .build()); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "/binding-kotlin/int | { \"value\": 2147483648 }", + "/binding-kotlin/long | { \"value\": 9223372036854775808 }", + "/binding-kotlin/short | { \"value\": 40000 }", + "/binding-kotlin/byte | { \"value\": 256 }", + }) + void givenOverflowPrimitive_whenPost_thenReturnProblem(String path, String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE); + + Problem problem = jsonMapper.readValue(response.getBody(), Problem.class); + + Problem expected = + Problem.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .detail(TYPE_MISMATCH_DETAIL) + .extension(PROPERTY_EXTENSION, "value") + .extension(KIND_EXTENSION, "integer") + .build(); + + if (!problem.equals(expected)) { + assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value())); + } + } + + @Test + void givenValidComplexRoot_whenPost_thenReturnOk() { + String json = + """ + { + "flag": true, + "timestamp": 1672531200000, + "amount": 12.34, + "shortNested": { "value": 3 }, + "tree": { "leaf": { "nested": { "value": 3 } } } + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + "/binding-kotlin/complex", new HttpEntity<>(json, headers), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("OK"); + } }