diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index 425bcf448f..5295fbe2a1 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -49,7 +49,7 @@ Modules are distributed as separate dependencies. Below is the catalog of offici * link:{uiVersion}/modules/gson[Gson]: Gson module for Jooby. * link:{uiVersion}/modules/jackson2[Jackson2]: Jackson2 module for Jooby. * link:{uiVersion}/modules/jackson3[Jackson3]: Jackson3 module for Jooby. - * link:{uiVersion}/modules/yasson[JSON-B]: JSON-B module for Jooby. + * link:{uiVersion}/modules/yasson[Yasson]: JSON-B module for Jooby. ==== OpenAPI * link:{uiVersion}/modules/openapi[OpenAPI]: OpenAPI supports. diff --git a/jooby/src/main/java/io/jooby/json/JsonCodec.java b/jooby/src/main/java/io/jooby/json/JsonCodec.java new file mode 100644 index 0000000000..16ddf773ee --- /dev/null +++ b/jooby/src/main/java/io/jooby/json/JsonCodec.java @@ -0,0 +1,31 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.json; + +/** + * A unified contract for complete JSON processing, combining both serialization and deserialization + * capabilities. + * + *

This interface acts as a convenient composite of {@link JsonEncoder} and {@link JsonDecoder}. + * Implementations of this interface (such as Jooby's Jackson, Gson, or Moshi integration modules) + * provide full-stack JSON support. This allows a Jooby application to seamlessly parse incoming + * JSON request bodies into Java objects, and render outgoing Java objects as JSON responses. + * + *

By providing a single interface that encompasses both directions of data binding, JSON + * libraries can be easily registered into the Jooby environment to handle all JSON-related content + * negotiation. + * + *

Important Note: Jooby core itself does not implement these + * interfaces. These contracts act as a bridge and are designed to be implemented exclusively by + * dedicated JSON modules (such as {@code jooby-jackson}, {@code jooby-gson}, or {@code + * jooby-avaje-json}), etc. + * + * @see JsonEncoder + * @see JsonDecoder + * @since 4.5.0 + * @author edgar + */ +public interface JsonCodec extends JsonEncoder, JsonDecoder {} diff --git a/jooby/src/main/java/io/jooby/json/JsonDecoder.java b/jooby/src/main/java/io/jooby/json/JsonDecoder.java new file mode 100644 index 0000000000..4c5253adfb --- /dev/null +++ b/jooby/src/main/java/io/jooby/json/JsonDecoder.java @@ -0,0 +1,74 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.json; + +import java.lang.reflect.Type; + +import io.jooby.Reified; + +/** + * Contract for decoding (deserializing) JSON strings into Java objects. + * + *

This functional interface provides the core deserialization strategy for Jooby. It is designed + * to be implemented by specific JSON library integrations (such as Jackson, Gson, Moshi, etc.) to + * adapt their internal parsing mechanics to Jooby's standard architecture. + * + *

Important Note: Jooby core itself does not implement these + * interfaces. These contracts act as a bridge and are designed to be implemented exclusively by + * dedicated JSON modules (such as {@code jooby-jackson}, {@code jooby-gson}, or {@code + * jooby-avaje-json}), etc. + * + * @since 4.5.0 + * @author edgar + */ +@FunctionalInterface +public interface JsonDecoder { + + /** + * Decodes a JSON string into the specified Java {@link java.lang.reflect.Type}. + * + *

This is the primary decoding method that all underlying JSON libraries must implement. It + * accepts a raw reflection {@code Type}, making it capable of handling both simple classes and + * complex parameterized/generic types (e.g., {@code List}). + * + * @param json The JSON payload as a string. + * @param type The target Java reflection type to deserialize into. + * @return The deserialized Java object instance. + * @param The expected generic type of the returned object. + */ + T decode(String json, Type type); + + /** + * Decodes a JSON string into the specified Java {@link Class}. + * + *

This is a convenience method for deserializing simple, non-generic types. It delegates + * directly to {@link #decode(String, Type)}. + * + * @param json The JSON payload as a string. + * @param type The target Java class to deserialize into. + * @return The deserialized Java object instance. + * @param The expected generic type of the returned object. + */ + default T decode(String json, Class type) { + return decode(json, (java.lang.reflect.Type) type); + } + + /** + * Decodes a JSON string into the specified Jooby {@link Reified} type. + * + *

This is a convenience method for deserializing complex, generic types while avoiding type + * erasure. It extracts the underlying reflection type from the {@code Reified} token and + * delegates to {@link #decode(String, Type)}. + * + * @param json The JSON payload as a string. + * @param type The Reified type token capturing the target type. + * @return The deserialized Java object instance. + * @param The expected generic type of the returned object. + */ + default T decode(String json, Reified type) { + return decode(json, type.getType()); + } +} diff --git a/jooby/src/main/java/io/jooby/json/JsonEncoder.java b/jooby/src/main/java/io/jooby/json/JsonEncoder.java new file mode 100644 index 0000000000..92e4a4a900 --- /dev/null +++ b/jooby/src/main/java/io/jooby/json/JsonEncoder.java @@ -0,0 +1,34 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.json; + +/** + * Contract for encoding (serializing) Java objects into JSON strings. + * + *

Important Note: Jooby core itself does not implement these + * interfaces. These contracts act as a bridge and are designed to be implemented exclusively by + * dedicated JSON modules (such as {@code jooby-jackson}, {@code jooby-gson}, or {@code + * jooby-avaje-json}), etc. + * + * @since 4.5.0 + * @author edgar + */ +@FunctionalInterface +public interface JsonEncoder { + + /** + * Encodes a Java object into its JSON string representation. + * + *

This method takes an arbitrary Java object and converts it into a valid JSON payload. + * Implementations are responsible for handling the specific serialization rules, configurations, + * and exception management of their underlying JSON library. + * + * @param value The Java object to serialize. This can be a simple data type, a collection, or a + * complex custom bean. + * @return The JSON string representation of the provided object. + */ + String encode(Object value); +} diff --git a/jooby/src/main/java/io/jooby/json/package-info.java b/jooby/src/main/java/io/jooby/json/package-info.java new file mode 100644 index 0000000000..170b354472 --- /dev/null +++ b/jooby/src/main/java/io/jooby/json/package-info.java @@ -0,0 +1,30 @@ +/** + * Provides the core JSON processing contracts and abstractions for the Jooby framework. + * + *

This package defines the foundational interfaces (such as {@link io.jooby.json.JsonEncoder}, + * {@link io.jooby.json.JsonDecoder}, and {@link io.jooby.json.JsonCodec}) that allow Jooby to + * integrate seamlessly with various external JSON libraries (like Jackson, Gson, or Moshi). By + * implementing these contracts, those libraries can participate in Jooby's content negotiation, + * enabling automatic serialization of HTTP responses and deserialization of HTTP request bodies. + * + *

Null-Safety Guarantee

+ * + *

This package is explicitly marked with {@link org.jspecify.annotations.NullMarked}. This + * establishes a strict nullability contract where all types (parameters, return types, and fields) + * within this package are considered non-null by default, unless explicitly + * annotated otherwise (e.g., using {@code @Nullable}). + * + *

Adopting JSpecify semantics ensures excellent interoperability with null-safe languages like + * Kotlin and provides robust guarantees for modern static code analysis tools. + * + *

Important Note: Jooby core itself does not implement these + * interfaces. These contracts act as a bridge and are designed to be implemented exclusively by + * dedicated JSON modules (such as {@code jooby-jackson}, {@code jooby-gson}, or {@code + * jooby-avaje-json}), etc. + * + * @see io.jooby.json.JsonCodec + * @author edgar + * @since 4.5.0 + */ +@org.jspecify.annotations.NullMarked +package io.jooby.json; diff --git a/jooby/src/main/java/module-info.java b/jooby/src/main/java/module-info.java index 077e1e792b..a1c1f12936 100644 --- a/jooby/src/main/java/module-info.java +++ b/jooby/src/main/java/module-info.java @@ -9,6 +9,7 @@ exports io.jooby; exports io.jooby.annotation; exports io.jooby.exception; + exports io.jooby.json; exports io.jooby.handler; exports io.jooby.validation; exports io.jooby.problem; diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonCodec.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonCodec.java new file mode 100644 index 0000000000..56399e56f5 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonCodec.java @@ -0,0 +1,32 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.avaje.jsonb; + +import java.lang.reflect.Type; + +import io.avaje.jsonb.JsonType; +import io.avaje.jsonb.Jsonb; +import io.jooby.json.JsonCodec; + +class AvajeJsonCodec implements JsonCodec { + private final Jsonb jsonb; + + public AvajeJsonCodec(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @SuppressWarnings("unchecked") + @Override + public T decode(String json, Type type) { + var jsonType = (JsonType) jsonb.type(type); + return jsonType.fromJson(json); + } + + @Override + public String encode(Object value) { + return jsonb.toJson(value); + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java index b28a2b1fbf..037ac75e11 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java @@ -14,6 +14,9 @@ import io.avaje.jsonb.Jsonb; import io.jooby.*; import io.jooby.internal.avaje.jsonb.*; +import io.jooby.json.JsonCodec; +import io.jooby.json.JsonDecoder; +import io.jooby.json.JsonEncoder; import io.jooby.output.Output; /** @@ -85,6 +88,11 @@ public void install(Jooby application) throws Exception { var services = application.getServices(); services.put(Jsonb.class, jsonb); + // JsonCodec + var jsonCodec = new AvajeJsonCodec(jsonb); + services.putIfAbsent(JsonCodec.class, jsonCodec); + services.putIfAbsent(JsonEncoder.class, jsonCodec); + services.putIfAbsent(JsonDecoder.class, jsonCodec); } @Override diff --git a/modules/jooby-avaje-jsonb/src/test/java/io/jooby/avaje/jsonb/AvajeJsonCodecTest.java b/modules/jooby-avaje-jsonb/src/test/java/io/jooby/avaje/jsonb/AvajeJsonCodecTest.java new file mode 100644 index 0000000000..20320007b5 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/test/java/io/jooby/avaje/jsonb/AvajeJsonCodecTest.java @@ -0,0 +1,72 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.avaje.jsonb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.avaje.jsonb.Jsonb; +import io.jooby.Reified; + +class AvajeJsonCodecTest { + + private AvajeJsonCodec codec; + + @BeforeEach + void setUp() { + Jsonb jsonb = Jsonb.builder().build(); + codec = new AvajeJsonCodec(jsonb); + } + + @Test + void shouldEncodeMapToJson() { + // Using a LinkedHashMap guarantees the order of the keys in the output JSON + Map map = new java.util.LinkedHashMap<>(); + map.put("Alice", 30); + map.put("Bob", 25); + + String json = codec.encode(map); + + assertEquals("{\"Alice\":30,\"Bob\":25}", json); + } + + @Test + void shouldDecodeJsonToGenericMapUsingReified() { + String json = "{\"Alice\":30,\"Bob\":25}"; + + // Using the anonymous subclass trick to capture Map without type erasure + Map map = codec.decode(json, Reified.map(String.class, Integer.class)); + + assertNotNull(map); + assertEquals(2, map.size()); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } + + @Test + void shouldDecodeJsonToGenericListMapUsingReified() { + String json = "[{\"Alice\":30,\"Bob\":25}]"; + + // Using the anonymous subclass trick to capture Map without type erasure + List> list = + codec.decode(json, Reified.list(Reified.map(String.class, Integer.class))); + + assertNotNull(list); + assertEquals(1, list.size()); + + var map = list.getFirst(); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } +} diff --git a/modules/jooby-gson/src/main/java/io/jooby/gson/GsonJsonCodec.java b/modules/jooby-gson/src/main/java/io/jooby/gson/GsonJsonCodec.java new file mode 100644 index 0000000000..a27c47ce20 --- /dev/null +++ b/modules/jooby-gson/src/main/java/io/jooby/gson/GsonJsonCodec.java @@ -0,0 +1,29 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.gson; + +import java.lang.reflect.Type; + +import com.google.gson.Gson; +import io.jooby.json.JsonCodec; + +public class GsonJsonCodec implements JsonCodec { + private final Gson gson; + + public GsonJsonCodec(Gson gson) { + this.gson = gson; + } + + @Override + public T decode(String json, Type type) { + return gson.fromJson(json, type); + } + + @Override + public String encode(Object value) { + return gson.toJson(value); + } +} diff --git a/modules/jooby-gson/src/main/java/io/jooby/gson/GsonModule.java b/modules/jooby-gson/src/main/java/io/jooby/gson/GsonModule.java index dd9f7275ba..9bf151c4df 100644 --- a/modules/jooby-gson/src/main/java/io/jooby/gson/GsonModule.java +++ b/modules/jooby-gson/src/main/java/io/jooby/gson/GsonModule.java @@ -20,6 +20,9 @@ import io.jooby.MediaType; import io.jooby.MessageDecoder; import io.jooby.MessageEncoder; +import io.jooby.json.JsonCodec; +import io.jooby.json.JsonDecoder; +import io.jooby.json.JsonEncoder; import io.jooby.output.Output; /** @@ -90,6 +93,11 @@ public void install(Jooby application) { var services = application.getServices(); services.put(Gson.class, gson); + // JsonCodec + var jsonCodec = new GsonJsonCodec(gson); + services.putIfAbsent(JsonCodec.class, jsonCodec); + services.putIfAbsent(JsonEncoder.class, jsonCodec); + services.putIfAbsent(JsonDecoder.class, jsonCodec); } @Override diff --git a/modules/jooby-gson/src/main/java/io/jooby/gson/package-info.java b/modules/jooby-gson/src/main/java/io/jooby/gson/package-info.java index 8ef42d5422..cdac672191 100644 --- a/modules/jooby-gson/src/main/java/io/jooby/gson/package-info.java +++ b/modules/jooby-gson/src/main/java/io/jooby/gson/package-info.java @@ -1,2 +1,45 @@ +/** + * JSON module using Gson: https://github.com/google/gson. + * + *

Usage: + * + *

{@code
+ * {
+ *
+ *   install(new GsonModule());
+ *
+ *   get("/", ctx -> {
+ *     MyObject myObject = ...;
+ *     // send json
+ *     return myObject;
+ *   });
+ *
+ *   post("/", ctx -> {
+ *     // read json
+ *     MyObject myObject = ctx.body(MyObject.class);
+ *     // send json
+ *     return myObject;
+ *   });
+ * }
+ * }
+ * + * For body decoding the client must specify the Content-Type header set to + * application/json. + * + *

You can retrieve the {@link com.google.gson.Gson} object via require call: + * + *

{@code
+ * {
+ *
+ *   Gson gson = require(Gson.class);
+ *
+ * }
+ * }
+ * + * Complete documentation is available at: https://jooby.io/modules/gson. + * + * @author edgar + * @since 2.7.2 + */ @org.jspecify.annotations.NullMarked package io.jooby.gson; diff --git a/modules/jooby-gson/src/main/java/module-info.java b/modules/jooby-gson/src/main/java/module-info.java index 8c594bc684..db54874c67 100644 --- a/modules/jooby-gson/src/main/java/module-info.java +++ b/modules/jooby-gson/src/main/java/module-info.java @@ -3,7 +3,52 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -/** Gson module. */ + +import com.google.gson.Gson; + +/** + * JSON module using Gson: https://github.com/google/gson. + * + *

Usage: + * + *

{@code
+ * {
+ *
+ *   install(new GsonModule());
+ *
+ *   get("/", ctx -> {
+ *     MyObject myObject = ...;
+ *     // send json
+ *     return myObject;
+ *   });
+ *
+ *   post("/", ctx -> {
+ *     // read json
+ *     MyObject myObject = ctx.body(MyObject.class);
+ *     // send json
+ *     return myObject;
+ *   });
+ * }
+ * }
+ * + * For body decoding the client must specify the Content-Type header set to + * application/json. + * + *

You can retrieve the {@link Gson} object via require call: + * + *

{@code
+ * {
+ *
+ *   Gson gson = require(Gson.class);
+ *
+ * }
+ * }
+ * + * Complete documentation is available at: https://jooby.io/modules/gson. + * + * @author edgar + * @since 2.7.2 + */ module io.jooby.gson { exports io.jooby.gson; diff --git a/modules/jooby-gson/src/test/java/io/jooby/gson/GsonJsonCodecTest.java b/modules/jooby-gson/src/test/java/io/jooby/gson/GsonJsonCodecTest.java new file mode 100644 index 0000000000..700322a38f --- /dev/null +++ b/modules/jooby-gson/src/test/java/io/jooby/gson/GsonJsonCodecTest.java @@ -0,0 +1,71 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.gson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import io.jooby.Reified; + +class GsonJsonCodecTest { + + private GsonJsonCodec codec; + + @BeforeEach + void setUp() { + codec = new GsonJsonCodec(new Gson()); + } + + @Test + void shouldEncodeMapToJson() { + // Using a LinkedHashMap guarantees the order of the keys in the output JSON + Map map = new java.util.LinkedHashMap<>(); + map.put("Alice", 30); + map.put("Bob", 25); + + String json = codec.encode(map); + + assertEquals("{\"Alice\":30,\"Bob\":25}", json); + } + + @Test + void shouldDecodeJsonToGenericMapUsingReified() { + String json = "{\"Alice\":30,\"Bob\":25}"; + + // Using the anonymous subclass trick to capture Map without type erasure + Map map = codec.decode(json, Reified.map(String.class, Integer.class)); + + assertNotNull(map); + assertEquals(2, map.size()); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } + + @Test + void shouldDecodeJsonToGenericListMapUsingReified() { + String json = "[{\"Alice\":30,\"Bob\":25}]"; + + // Using the anonymous subclass trick to capture Map without type erasure + List> list = + codec.decode(json, Reified.list(Reified.map(String.class, Integer.class))); + + assertNotNull(list); + assertEquals(1, list.size()); + + var map = list.getFirst(); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonCodec.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonCodec.java new file mode 100644 index 0000000000..685a08abdb --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonCodec.java @@ -0,0 +1,50 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson; + +import java.lang.reflect.Type; + +import org.jspecify.annotations.NonNull; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jooby.SneakyThrows; +import io.jooby.json.JsonCodec; + +public class JacksonJsonCodec implements JsonCodec { + private final ObjectMapper mapper; + + public JacksonJsonCodec(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public T decode(@NonNull String json, @NonNull Type type) { + try { + return mapper.readValue(json, mapper.getTypeFactory().constructType(type)); + } catch (JsonProcessingException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public T decode(@NonNull String json, @NonNull Class type) { + try { + return mapper.readValue(json, type); + } catch (JsonProcessingException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public @NonNull String encode(@NonNull Object value) { + try { + return mapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw SneakyThrows.propagate(e); + } + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/Jackson2Module.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/Jackson2Module.java index 1eca40efdb..cda554a151 100644 --- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/Jackson2Module.java +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/Jackson2Module.java @@ -25,6 +25,9 @@ import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import io.jooby.*; import io.jooby.internal.jackson.*; +import io.jooby.json.JsonCodec; +import io.jooby.json.JsonDecoder; +import io.jooby.json.JsonEncoder; import io.jooby.output.Output; /** @@ -140,6 +143,11 @@ public void install(Jooby application) { Class mapperType = mapper.getClass(); services.put(mapperType, mapper); services.put(ObjectMapper.class, mapper); + // Json Codec + var jsonCodec = new JacksonJsonCodec(mapper); + services.putIfAbsent(JsonCodec.class, jsonCodec); + services.putIfAbsent(JsonEncoder.class, jsonCodec); + services.putIfAbsent(JsonDecoder.class, jsonCodec); // Parsing exception as 400 application.errorCode(JsonParseException.class, StatusCode.BAD_REQUEST); diff --git a/modules/jooby-jackson/src/test/java/io/jooby/internal/jackson/JacksonJsonCodecTest.java b/modules/jooby-jackson/src/test/java/io/jooby/internal/jackson/JacksonJsonCodecTest.java new file mode 100644 index 0000000000..e554276309 --- /dev/null +++ b/modules/jooby-jackson/src/test/java/io/jooby/internal/jackson/JacksonJsonCodecTest.java @@ -0,0 +1,71 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jooby.Reified; + +class JacksonJsonCodecTest { + + private JacksonJsonCodec codec; + + @BeforeEach + void setUp() { + codec = new JacksonJsonCodec(new ObjectMapper()); + } + + @Test + void shouldEncodeMapToJson() { + // Using a LinkedHashMap guarantees the order of the keys in the output JSON + Map map = new java.util.LinkedHashMap<>(); + map.put("Alice", 30); + map.put("Bob", 25); + + String json = codec.encode(map); + + assertEquals("{\"Alice\":30,\"Bob\":25}", json); + } + + @Test + void shouldDecodeJsonToGenericMapUsingReified() { + String json = "{\"Alice\":30,\"Bob\":25}"; + + // Using the anonymous subclass trick to capture Map without type erasure + Map map = codec.decode(json, Reified.map(String.class, Integer.class)); + + assertNotNull(map); + assertEquals(2, map.size()); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } + + @Test + void shouldDecodeJsonToGenericListMapUsingReified() { + String json = "[{\"Alice\":30,\"Bob\":25}]"; + + // Using the anonymous subclass trick to capture Map without type erasure + List> list = + codec.decode(json, Reified.list(Reified.map(String.class, Integer.class))); + + assertNotNull(list); + assertEquals(1, list.size()); + + var map = list.getFirst(); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonCodec.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonCodec.java new file mode 100644 index 0000000000..084455a64a --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonCodec.java @@ -0,0 +1,36 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson3; + +import java.lang.reflect.Type; + +import org.jspecify.annotations.NonNull; + +import io.jooby.json.JsonCodec; +import tools.jackson.databind.ObjectMapper; + +public class JacksonJsonCodec implements JsonCodec { + private final ObjectMapper mapper; + + public JacksonJsonCodec(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public T decode(@NonNull String json, @NonNull Type type) { + return mapper.readValue(json, mapper.getTypeFactory().constructType(type)); + } + + @Override + public T decode(@NonNull String json, @NonNull Class type) { + return mapper.readValue(json, type); + } + + @Override + public @NonNull String encode(@NonNull Object value) { + return mapper.writeValueAsString(value); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java index e8eaa6fe43..5f4cba0dd7 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java @@ -14,6 +14,9 @@ import com.fasterxml.jackson.annotation.JsonFilter; import io.jooby.*; import io.jooby.internal.jackson3.*; +import io.jooby.json.JsonCodec; +import io.jooby.json.JsonDecoder; +import io.jooby.json.JsonEncoder; import io.jooby.output.Output; import tools.jackson.core.exc.StreamReadException; import tools.jackson.databind.*; @@ -132,6 +135,11 @@ public void install(Jooby application) { var services = application.getServices(); bindMapper(services, mapper); + // Json Codec + var jsonCodec = new JacksonJsonCodec(mapper); + services.putIfAbsent(JsonCodec.class, jsonCodec); + services.putIfAbsent(JsonEncoder.class, jsonCodec); + services.putIfAbsent(JsonDecoder.class, jsonCodec); // Parsing exception as 400 application.errorCode(StreamReadException.class, StatusCode.BAD_REQUEST); diff --git a/modules/jooby-jackson3/src/test/java/io/jooby/internal/jackson3/JacksonJsonCodecTest.java b/modules/jooby-jackson3/src/test/java/io/jooby/internal/jackson3/JacksonJsonCodecTest.java new file mode 100644 index 0000000000..02eab6a4f2 --- /dev/null +++ b/modules/jooby-jackson3/src/test/java/io/jooby/internal/jackson3/JacksonJsonCodecTest.java @@ -0,0 +1,71 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.jooby.Reified; +import tools.jackson.databind.json.JsonMapper; + +class JacksonJsonCodecTest { + + private JacksonJsonCodec codec; + + @BeforeEach + void setUp() { + codec = new JacksonJsonCodec(JsonMapper.builder().build()); + } + + @Test + void shouldEncodeMapToJson() { + // Using a LinkedHashMap guarantees the order of the keys in the output JSON + Map map = new java.util.LinkedHashMap<>(); + map.put("Alice", 30); + map.put("Bob", 25); + + String json = codec.encode(map); + + assertEquals("{\"Alice\":30,\"Bob\":25}", json); + } + + @Test + void shouldDecodeJsonToGenericMapUsingReified() { + String json = "{\"Alice\":30,\"Bob\":25}"; + + // Using the anonymous subclass trick to capture Map without type erasure + Map map = codec.decode(json, Reified.map(String.class, Integer.class)); + + assertNotNull(map); + assertEquals(2, map.size()); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } + + @Test + void shouldDecodeJsonToGenericListMapUsingReified() { + String json = "[{\"Alice\":30,\"Bob\":25}]"; + + // Using the anonymous subclass trick to capture Map without type erasure + List> list = + codec.decode(json, Reified.list(Reified.map(String.class, Integer.class))); + + assertNotNull(list); + assertEquals(1, list.size()); + + var map = list.getFirst(); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } +} diff --git a/modules/jooby-yasson/src/main/java/io/jooby/yasson/YassonJsonCodec.java b/modules/jooby-yasson/src/main/java/io/jooby/yasson/YassonJsonCodec.java new file mode 100644 index 0000000000..7c39d50c6d --- /dev/null +++ b/modules/jooby-yasson/src/main/java/io/jooby/yasson/YassonJsonCodec.java @@ -0,0 +1,29 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.yasson; + +import java.lang.reflect.Type; + +import io.jooby.json.JsonCodec; +import jakarta.json.bind.Jsonb; + +class YassonJsonCodec implements JsonCodec { + private final Jsonb jsonb; + + public YassonJsonCodec(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @Override + public T decode(String json, Type type) { + return jsonb.fromJson(json, type); + } + + @Override + public String encode(Object value) { + return jsonb.toJson(value); + } +} diff --git a/modules/jooby-yasson/src/main/java/io/jooby/yasson/YassonModule.java b/modules/jooby-yasson/src/main/java/io/jooby/yasson/YassonModule.java index c97968ae27..fd77274dea 100644 --- a/modules/jooby-yasson/src/main/java/io/jooby/yasson/YassonModule.java +++ b/modules/jooby-yasson/src/main/java/io/jooby/yasson/YassonModule.java @@ -20,6 +20,9 @@ import io.jooby.MessageDecoder; import io.jooby.MessageEncoder; import io.jooby.ServiceRegistry; +import io.jooby.json.JsonCodec; +import io.jooby.json.JsonDecoder; +import io.jooby.json.JsonEncoder; import io.jooby.output.Output; import jakarta.json.bind.Jsonb; import jakarta.json.bind.JsonbBuilder; @@ -89,6 +92,11 @@ public void install(final Jooby application) throws Exception { ServiceRegistry services = application.getServices(); services.put(Jsonb.class, jsonb); + // JsonCodec + var jsonCodec = new YassonJsonCodec(jsonb); + services.putIfAbsent(JsonCodec.class, jsonCodec); + services.putIfAbsent(JsonEncoder.class, jsonCodec); + services.putIfAbsent(JsonDecoder.class, jsonCodec); } @Override diff --git a/modules/jooby-yasson/src/main/java/io/jooby/yasson/package-info.java b/modules/jooby-yasson/src/main/java/io/jooby/yasson/package-info.java index d5cfc250ad..4cf493ee92 100644 --- a/modules/jooby-yasson/src/main/java/io/jooby/yasson/package-info.java +++ b/modules/jooby-yasson/src/main/java/io/jooby/yasson/package-info.java @@ -1,2 +1,42 @@ +/** + * JSON module using JSON-B: https://github.com/eclipse-ee4j/jsonb-api. + * + *

Usage: + * + *

{@code
+ * {
+ *
+ *   install(new YassonModule());
+ *
+ *   get("/", ctx -> {
+ *     MyObject myObject = ...;
+ *     // send json
+ *     return myObject;
+ *   });
+ *
+ *   post("/", ctx -> {
+ *     // read json
+ *     MyObject myObject = ctx.body(MyObject.class);
+ *     // send json
+ *     return myObject;
+ *   });
+ * }
+ * }
+ * + * For body decoding the client must specify the Content-Type header set to + * application/json. + * + *

You can retrieve the {@link jakarta.json.bind.Jsonb} object via require call: + * + *

{@code
+ * {
+ *
+ *   Jsonb jsonb = require(Jsonb.class);
+ *
+ * }
+ * }
+ * + * Complete documentation is available at: https://jooby.io/modules/jsonb. + */ @org.jspecify.annotations.NullMarked package io.jooby.yasson; diff --git a/modules/jooby-yasson/src/test/java/io/jooby/yasson/YassonJsonCodecTest.java b/modules/jooby-yasson/src/test/java/io/jooby/yasson/YassonJsonCodecTest.java new file mode 100644 index 0000000000..e87f6dfd55 --- /dev/null +++ b/modules/jooby-yasson/src/test/java/io/jooby/yasson/YassonJsonCodecTest.java @@ -0,0 +1,71 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.yasson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.jooby.Reified; +import jakarta.json.bind.JsonbBuilder; + +class YassonJsonCodecTest { + + private YassonJsonCodec codec; + + @BeforeEach + void setUp() { + codec = new YassonJsonCodec(JsonbBuilder.create()); + } + + @Test + void shouldEncodeMapToJson() { + // Using a LinkedHashMap guarantees the order of the keys in the output JSON + Map map = new java.util.LinkedHashMap<>(); + map.put("Alice", 30); + map.put("Bob", 25); + + String json = codec.encode(map); + + assertEquals("{\"Alice\":30,\"Bob\":25}", json); + } + + @Test + void shouldDecodeJsonToGenericMapUsingReified() { + String json = "{\"Alice\":30,\"Bob\":25}"; + + // Using the anonymous subclass trick to capture Map without type erasure + Map map = codec.decode(json, Reified.map(String.class, Integer.class)); + + assertNotNull(map); + assertEquals(2, map.size()); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } + + @Test + void shouldDecodeJsonToGenericListMapUsingReified() { + String json = "[{\"Alice\":30,\"Bob\":25}]"; + + // Using the anonymous subclass trick to capture Map without type erasure + List> list = + codec.decode(json, Reified.list(Reified.map(String.class, Integer.class))); + + assertNotNull(list); + assertEquals(1, list.size()); + + var map = list.getFirst(); + + assertEquals(30, map.get("Alice")); + assertEquals(25, map.get("Bob")); + } +}