diff --git a/discovery-core/build.gradle b/discovery-core/build.gradle index 8745608bb61..c024a020436 100644 --- a/discovery-core/build.gradle +++ b/discovery-core/build.gradle @@ -5,9 +5,19 @@ plugins { dependencies { annotationProcessor project(":inject-java") annotationProcessor project(":graal") + + annotationProcessor(platform(libs.test.boms.micronaut.serde)) + annotationProcessor(libs.micronaut.serde.processor) { + exclude group: "io.micronaut", module: "json-core" + } + api project(':context') implementation libs.managed.reactor + compileOnly project(":jackson-databind") - testImplementation project(":jackson-databind") -// api project(":http") + compileOnly(platform(libs.test.boms.micronaut.serde)) + compileOnly(libs.micronaut.serde.api) { + exclude group: "io.micronaut", module: "json-core" + } + } diff --git a/discovery-core/src/main/java/io/micronaut/health/HealthStatus.java b/discovery-core/src/main/java/io/micronaut/health/HealthStatus.java index c1ecdb75132..61ea5c48806 100644 --- a/discovery-core/src/main/java/io/micronaut/health/HealthStatus.java +++ b/discovery-core/src/main/java/io/micronaut/health/HealthStatus.java @@ -16,9 +16,9 @@ package io.micronaut.health; import com.fasterxml.jackson.annotation.JsonValue; -import io.micronaut.core.annotation.Introspected; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.ReflectiveAccess; +import io.micronaut.serde.annotation.Serdeable; import java.util.Optional; @@ -29,7 +29,7 @@ * @author Graeme Rocher * @since 1.0 */ -@Introspected +@Serdeable @ReflectiveAccess public class HealthStatus implements Comparable { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47aa7270cfc..36f11dd83b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,6 +77,7 @@ managed-snakeyaml = "2.2" managed-java-parser-core = "3.25.8" managed-ksp = "1.9.22-1.0.17" micronaut-docs = "2.0.0" +micronaut-serde = "2.8.1" [libraries] # Libraries prefixed with bom- are BOM files @@ -86,6 +87,7 @@ test-boms-micronaut-validation = { module = "io.micronaut.validation:micronaut-v test-boms-micronaut-rxjava2 = { module = "io.micronaut.rxjava2:micronaut-rxjava2-bom", version.ref = "micronaut-rxjava2" } test-boms-micronaut-rxjava3 = { module = "io.micronaut.rxjava3:micronaut-rxjava3-bom", version.ref = "micronaut-rxjava3" } test-boms-micronaut-reactor = { module = "io.micronaut.reactor:micronaut-reactor-bom", version.ref = "micronaut-reactor" } +test-boms-micronaut-serde = { module = "io.micronaut.serde:micronaut-serde-bom", version.ref = "micronaut-serde" } boms-groovy = { module = "org.apache.groovy:groovy-bom", version.ref = "managed-groovy" } boms-kotlin = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "managed-kotlin" } @@ -262,6 +264,10 @@ micronaut-tracing-brave = { module = "io.micronaut.tracing:micronaut-tracing-bra micronaut-validation = { module = "io.micronaut.validation:micronaut-validation" } micronaut-validation-processor = { module = "io.micronaut.validation:micronaut-validation-processor" } +micronaut-serde-api = { module = "io.micronaut.serde:micronaut-serde-api" } +micronaut-serde-processor = { module = "io.micronaut.serde:micronaut-serde-processor" } +micronaut-serde-jackson = { module = "io.micronaut.serde:micronaut-serde-jackson" } + testcontainers-spock = { module = "org.testcontainers:spock", version.ref = "testcontainers" } vertx = { module = "io.vertx:vertx-core", version.ref = "vertx" } diff --git a/http/build.gradle b/http/build.gradle index 1f6dc0a12d1..cd5a4c3b508 100644 --- a/http/build.gradle +++ b/http/build.gradle @@ -6,6 +6,12 @@ plugins { dependencies { annotationProcessor project(":inject-java") annotationProcessor project(":graal") + + annotationProcessor(platform(libs.test.boms.micronaut.serde)) + annotationProcessor(libs.micronaut.serde.processor) { + exclude group: "io.micronaut", module: "json-core" + } + api project(":context") api project(":core-reactive") implementation project(":context-propagation") @@ -13,6 +19,11 @@ dependencies { compileOnly libs.managed.kotlinx.coroutines.core compileOnly libs.managed.kotlinx.coroutines.reactor + compileOnly(platform(libs.test.boms.micronaut.serde)) + compileOnly(libs.micronaut.serde.api) { + exclude group: "io.micronaut", module: "json-core" + } + compileOnly libs.managed.jackson.annotations testCompileOnly project(":inject-groovy") diff --git a/http/src/main/java/io/micronaut/http/hateoas/AbstractResource.java b/http/src/main/java/io/micronaut/http/hateoas/AbstractResource.java index d4e5a4e5dcb..2745351666c 100644 --- a/http/src/main/java/io/micronaut/http/hateoas/AbstractResource.java +++ b/http/src/main/java/io/micronaut/http/hateoas/AbstractResource.java @@ -18,16 +18,18 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; import io.micronaut.core.annotation.ReflectiveAccess; import io.micronaut.core.convert.value.ConvertibleValues; import io.micronaut.core.util.StringUtils; import io.micronaut.core.value.OptionalMultiValues; import io.micronaut.http.MediaType; import io.micronaut.http.annotation.Produces; +import io.micronaut.serde.annotation.Serdeable; -import io.micronaut.core.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -41,8 +43,9 @@ * @since 1.1 */ @Produces(MediaType.APPLICATION_HAL_JSON) +@Serdeable @Introspected -public abstract class AbstractResource implements Resource { +public abstract class AbstractResource> implements Resource { private final Map> linkMap = new LinkedHashMap<>(1); private final Map> embeddedMap = new LinkedHashMap<>(1); @@ -150,6 +153,13 @@ public final void setLinks(Map links) { if (value instanceof Map) { Map linkMap = (Map) value; link(name, linkMap); + } else if (value instanceof Collection collection) { + for (Object o : collection) { + if (o instanceof Map) { + Map linkMap = (Map) o; + link(name, linkMap); + } + } } } } diff --git a/http/src/main/java/io/micronaut/http/hateoas/GenericResource.java b/http/src/main/java/io/micronaut/http/hateoas/GenericResource.java index 9a7f1dc38cf..cd41f9058e5 100644 --- a/http/src/main/java/io/micronaut/http/hateoas/GenericResource.java +++ b/http/src/main/java/io/micronaut/http/hateoas/GenericResource.java @@ -18,8 +18,8 @@ import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import io.micronaut.core.annotation.Internal; -import io.micronaut.core.annotation.Introspected; import io.micronaut.core.util.ObjectUtils; +import io.micronaut.serde.annotation.Serdeable; import java.util.LinkedHashMap; import java.util.Map; @@ -31,7 +31,7 @@ * @since 3.4.0 * @author yawkat */ -@Introspected +@Serdeable public final class GenericResource extends AbstractResource { private final Map additionalProperties = new LinkedHashMap<>(); diff --git a/http/src/main/java/io/micronaut/http/hateoas/JsonError.java b/http/src/main/java/io/micronaut/http/hateoas/JsonError.java index 5d3b88b5002..731cad4fced 100644 --- a/http/src/main/java/io/micronaut/http/hateoas/JsonError.java +++ b/http/src/main/java/io/micronaut/http/hateoas/JsonError.java @@ -22,6 +22,8 @@ import io.micronaut.http.annotation.Produces; import io.micronaut.core.annotation.Nullable; +import io.micronaut.serde.annotation.Serdeable; + import java.util.Optional; /** @@ -30,6 +32,7 @@ * @author Graeme Rocher * @since 1.1 */ +@Serdeable @Produces(MediaType.APPLICATION_JSON) public class JsonError extends AbstractResource { diff --git a/http/src/main/java/io/micronaut/http/hateoas/Resource.java b/http/src/main/java/io/micronaut/http/hateoas/Resource.java index 06de9f875a0..ee95d82d690 100644 --- a/http/src/main/java/io/micronaut/http/hateoas/Resource.java +++ b/http/src/main/java/io/micronaut/http/hateoas/Resource.java @@ -20,6 +20,7 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.Introspected; import io.micronaut.core.value.OptionalMultiValues; +import io.micronaut.serde.annotation.Serdeable; /** * Represents a REST resource in a hateoas architecture. @@ -27,6 +28,7 @@ * @author Graeme Rocher * @since 1.1 */ +@Serdeable @Introspected public interface Resource { diff --git a/http/src/main/java/io/micronaut/http/hateoas/VndError.java b/http/src/main/java/io/micronaut/http/hateoas/VndError.java index 4a2994297e0..01d317f8baa 100644 --- a/http/src/main/java/io/micronaut/http/hateoas/VndError.java +++ b/http/src/main/java/io/micronaut/http/hateoas/VndError.java @@ -19,6 +19,7 @@ import io.micronaut.core.annotation.Nullable; import io.micronaut.http.MediaType; import io.micronaut.http.annotation.Produces; +import io.micronaut.serde.annotation.Serdeable; import java.util.List; @@ -29,6 +30,7 @@ * @since 1.1 */ @Produces(MediaType.APPLICATION_VND_ERROR) +@Serdeable public class VndError extends JsonError { /** diff --git a/management/build.gradle b/management/build.gradle index 5156ecc38bd..631a43d0844 100644 --- a/management/build.gradle +++ b/management/build.gradle @@ -6,6 +6,11 @@ dependencies { annotationProcessor project(":inject-java") annotationProcessor project(":graal") + annotationProcessor(platform(libs.test.boms.micronaut.serde)) + annotationProcessor(libs.micronaut.serde.processor) { + exclude group: "io.micronaut", module: "json-core" + } + api project(":router") api project(":discovery-core") compileOnly project(":jackson-databind") @@ -39,4 +44,9 @@ dependencies { compileOnly libs.logback.classic compileOnly libs.log4j + compileOnly(platform(libs.test.boms.micronaut.serde)) + compileOnly(libs.micronaut.serde.api) { + exclude group: "io.micronaut", module: "json-core" + } + } diff --git a/management/src/main/java/io/micronaut/management/health/indicator/HealthResult.java b/management/src/main/java/io/micronaut/management/health/indicator/HealthResult.java index 43c6956a197..d70aacb47f8 100644 --- a/management/src/main/java/io/micronaut/management/health/indicator/HealthResult.java +++ b/management/src/main/java/io/micronaut/management/health/indicator/HealthResult.java @@ -16,9 +16,9 @@ package io.micronaut.management.health.indicator; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import io.micronaut.core.annotation.Introspected; import io.micronaut.core.annotation.ReflectiveAccess; import io.micronaut.health.HealthStatus; +import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.constraints.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,9 +32,9 @@ * @author James Kleeh * @since 1.0 */ -@Introspected -@ReflectiveAccess +@Serdeable @JsonDeserialize(as = DefaultHealthResult.class) +@ReflectiveAccess public interface HealthResult { /** diff --git a/settings.gradle b/settings.gradle index 7b37814f4a7..6e340f549ff 100644 --- a/settings.gradle +++ b/settings.gradle @@ -76,6 +76,7 @@ include "test-suite-logback" include "test-suite-logback-external-configuration" include "test-suite-logback-graalvm" include "test-suite-netty-ssl-graalvm" +include "test-suite-serde" include "test-utils" // benchmarks diff --git a/test-suite-serde/build.gradle b/test-suite-serde/build.gradle new file mode 100644 index 00000000000..03c28883628 --- /dev/null +++ b/test-suite-serde/build.gradle @@ -0,0 +1,18 @@ +plugins { + id "io.micronaut.build.internal.convention-test-library" +} + +micronautBuild { + core { + usesMicronautTestSpock() + } +} + +dependencies { + testAnnotationProcessor(projects.injectJava) + testImplementation(projects.http) + testImplementation(projects.management) + testImplementation(platform(libs.test.boms.micronaut.serde)) + testImplementation libs.micronaut.serde.jackson + testImplementation(projects.micronaut.jacksonDatabind) +} diff --git a/test-suite-serde/src/test/groovy/io/micronaut/health/HealthSpec.groovy b/test-suite-serde/src/test/groovy/io/micronaut/health/HealthSpec.groovy new file mode 100644 index 00000000000..e58c9aaf65a --- /dev/null +++ b/test-suite-serde/src/test/groovy/io/micronaut/health/HealthSpec.groovy @@ -0,0 +1,36 @@ +package io.micronaut.health + +import io.micronaut.core.type.Argument +import io.micronaut.jackson.databind.JacksonDatabindMapper +import io.micronaut.json.JsonMapper +import io.micronaut.management.health.indicator.HealthResult +import io.micronaut.serde.ObjectMapper +import spock.lang.Specification + +class HealthSpec extends Specification { + + void "test HealthResult"(JsonMapper objectMapper) { + given: + + HealthResult hr = HealthResult.builder("db", HealthStatus.DOWN) + .details(Collections.singletonMap("foo", "bar")) + .build() + + when: + def result = objectMapper.writeValueAsString(hr) + + then: + result == '{"name":"db","status":"DOWN","details":{"foo":"bar"}}' + + when: + hr = objectMapper.readValue(result, Argument.of(HealthResult)) + + then: + hr.name == 'db' + hr.status == HealthStatus.DOWN + + where: + objectMapper << [ObjectMapper.getDefault(), new JacksonDatabindMapper(new com.fasterxml.jackson.databind.ObjectMapper())] + } + +} diff --git a/test-suite-serde/src/test/groovy/io/micronaut/http/hateoas/JsonErrorSpec.groovy b/test-suite-serde/src/test/groovy/io/micronaut/http/hateoas/JsonErrorSpec.groovy new file mode 100644 index 00000000000..9ae427b6c9d --- /dev/null +++ b/test-suite-serde/src/test/groovy/io/micronaut/http/hateoas/JsonErrorSpec.groovy @@ -0,0 +1,63 @@ +package io.micronaut.http.hateoas + +import io.micronaut.jackson.databind.JacksonDatabindMapper +import io.micronaut.json.JsonMapper +import io.micronaut.serde.ObjectMapper +import spock.lang.PendingFeature +import spock.lang.Specification + +class JsonErrorSpec extends Specification { + + def jsonError = '{"_links":{"self":[{"href":"/resolve","templated":false}]},"_embedded":{"errors":[{"message":"Internal Server Error: Something bad happened"}]},"message":"Internal Server Error"}' + + @PendingFeature + void "JsonError should be deserializable from a string - serde"() { + setup: + ObjectMapper objectMapper = ObjectMapper.getDefault() + when: + JsonError jsonError = objectMapper.readValue(this.jsonError, JsonError) + + then: + jsonError.message == 'Internal Server Error' + jsonError.embedded.getFirst('errors').isPresent() + jsonError.links.getFirst("self").get().href == "/resolve" + !jsonError.links.getFirst("self").get().templated + } + + @PendingFeature + void "can deserialize a Json error as a generic resource - serde"() { + setup: + ObjectMapper objectMapper = ObjectMapper.getDefault() + when: + GenericResource resource = objectMapper.readValue(jsonError, Resource) + then: + resource.embedded.getFirst('errors').isPresent() + resource.links.getFirst("self").get().href == "/resolve" + !resource.links.getFirst("self").get().templated + } + + void "JsonError should be deserializable from a string - jackson databind"() { + setup: + JsonMapper objectMapper = new JacksonDatabindMapper(new com.fasterxml.jackson.databind.ObjectMapper()) + + when: + JsonError jsonError = objectMapper.readValue(this.jsonError, JsonError) + + then: + jsonError.message == 'Internal Server Error' + jsonError.embedded.getFirst('errors').isPresent() + jsonError.links.getFirst("self").get().href == "/resolve" + !jsonError.links.getFirst("self").get().templated + } + + void "can deserialize a Json error as a generic resource - jackson databind"() { + setup: + JsonMapper objectMapper = new JacksonDatabindMapper(new com.fasterxml.jackson.databind.ObjectMapper()) + when: + GenericResource resource = objectMapper.readValue(jsonError, Resource) + then: + resource.embedded.getFirst('errors').isPresent() + resource.links.getFirst("self").get().href == "/resolve" + !resource.links.getFirst("self").get().templated + } +}