diff --git a/extensions/jackson3/pom.xml b/extensions/jackson3/pom.xml new file mode 100644 index 000000000..b73485d63 --- /dev/null +++ b/extensions/jackson3/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + io.jsonwebtoken + jjwt-root + 0.14.0-SNAPSHOT + ../../pom.xml + + + jjwt-jackson3 + JJWT :: Extensions :: Jackson3 + jar + + + ${basedir}/../.. + + + + + + io.jsonwebtoken + jjwt-api + + + tools.jackson.core + jackson-databind + + + + + + + + + diff --git a/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3Deserializer.java b/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3Deserializer.java new file mode 100644 index 000000000..b3b4f96ef --- /dev/null +++ b/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3Deserializer.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2014 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.jackson.io; + +import io.jsonwebtoken.io.AbstractDeserializer; +import io.jsonwebtoken.lang.Assert; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.deser.jdk.UntypedObjectDeserializer; +import tools.jackson.databind.module.SimpleModule; + +import java.io.Reader; +import java.util.Collections; +import java.util.Map; + +/** + * Deserializer using a Jackson {@link ObjectMapper}. + * + * @since 0.14.0 + */ +public class Jackson3Deserializer extends AbstractDeserializer { + + private final Class returnType; + + private final ObjectMapper objectMapper; + + /** + * Constructor using JJWT's default {@link ObjectMapper} singleton for deserialization. + */ + public Jackson3Deserializer() { + this(Jackson3Serializer.DEFAULT_OBJECT_MAPPER); + } + + /** + * Creates a new JacksonDeserializer where the values of the claims can be parsed into given types. A common usage + * example is to parse custom User object out of a claim, for example the claims: + *
{@code
+     * {
+     *     "issuer": "https://issuer.example.com",
+     *     "user": {
+     *         "firstName": "Jill",
+     *         "lastName": "Coder"
+     *     }
+     * }}
+ * Passing a map of {@code ["user": User.class]} to this constructor would result in the {@code user} claim being + * transformed to an instance of your custom {@code User} class, instead of the default of {@code Map}. + *

+ * Because custom type parsing requires modifying the state of a Jackson {@code ObjectMapper}, this + * constructor creates a new internal {@code ObjectMapper} instance and customizes it to support the + * specified {@code claimTypeMap}. This ensures that the JJWT parsing behavior does not unexpectedly + * modify the state of another application-specific {@code ObjectMapper}. + *

+ * If you would like to use your own {@code ObjectMapper} instance that also supports custom types for + * JWT {@code Claims}, you will need to first customize your {@code ObjectMapper} instance by registering + * your custom types and then use the {@link #Jackson3Deserializer(ObjectMapper)} constructor instead. + * + * @param claimTypeMap The claim name-to-class map used to deserialize claims into the given type + */ + public Jackson3Deserializer(Map> claimTypeMap) { + // DO NOT specify JacksonSerializer.DEFAULT_OBJECT_MAPPER here as that would modify the shared instance + this(Jackson3Serializer.newObjectMapper(), claimTypeMap); + } + + /** + * Deserializer using a Jackson {@link ObjectMapper}. + * + * @since 0.14.0 + * @param objectMapper + */ + @SuppressWarnings("unchecked") + public Jackson3Deserializer(ObjectMapper objectMapper) { + this(objectMapper, (Class) Object.class); + } + + /** + * Creates a new JacksonDeserializer where the values of the claims can be parsed into given types by registering + * a type-converting {@link tools.jackson.databind.JacksonModule Module} on the specified {@link ObjectMapper}. + * A common usage example is to parse custom User object out of a claim, for example the claims: + *

{@code
+     * {
+     *     "issuer": "https://issuer.example.com",
+     *     "user": {
+     *         "firstName": "Jill",
+     *         "lastName": "Coder"
+     *     }
+     * }}
+ * Passing a map of {@code ["user": User.class]} to this constructor would result in the {@code user} claim being + * transformed to an instance of your custom {@code User} class, instead of the default of {@code Map}. + *

+ * Because custom type parsing requires modifying the state of a Jackson {@code ObjectMapper}, this + * constructor modifies the specified {@code objectMapper} argument and customizes it to support the + * specified {@code claimTypeMap}. + *

+ * If you do not want your {@code ObjectMapper} instance modified, but also want to support custom types for + * JWT {@code Claims}, you will need to first customize your {@code ObjectMapper} instance by registering + * your custom types separately and then use the {@link #Jackson3Deserializer(ObjectMapper)} constructor instead + * (which does not modify the {@code objectMapper} argument). + * + * @param objectMapper the objectMapper to modify by registering a custom type-converting + * {@link tools.jackson.databind.JacksonModule Module} + * @param claimTypeMap The claim name-to-class map used to deserialize claims into the given type + */ + public Jackson3Deserializer(ObjectMapper objectMapper, Map> claimTypeMap) { + this(objectMapper, (Class) Object.class, claimTypeMap); + } + + private Jackson3Deserializer(ObjectMapper objectMapper, Class returnType) { + Assert.notNull(objectMapper, "ObjectMapper cannot be null."); + Assert.notNull(returnType, "Return type cannot be null."); + this.objectMapper = objectMapper; + this.returnType = returnType; + } + + /** + * + * @param objectMapper + * @param returnType + * @param claimTypeMap + */ + private Jackson3Deserializer(ObjectMapper objectMapper, Class returnType, Map> claimTypeMap) { + Assert.notNull(objectMapper, "ObjectMapper cannot be null."); + Assert.notNull(returnType, "Return type cannot be null."); + Assert.notNull(claimTypeMap, "Claim type map cannot be null."); + // register a new Deserializer on the ObjectMapper instance: + SimpleModule module = new SimpleModule(); + module.addDeserializer(Object.class, new MappedTypeDeserializer(Collections.unmodifiableMap(claimTypeMap))); + this.objectMapper = objectMapper.rebuild().addModule(module).build(); + this.returnType = returnType; + } + + @Override + protected T doDeserialize(Reader reader) throws Exception { + return objectMapper.readValue(reader, returnType); + } + + /** + * A Jackson {@link tools.jackson.databind.deser.std.StdDeserializer JsonDeserializer}, that will convert claim + * values to types based on {@code claimTypeMap}. + */ + private static class MappedTypeDeserializer extends UntypedObjectDeserializer { + + private final Map> claimTypeMap; + + private MappedTypeDeserializer(Map> claimTypeMap) { + super((JavaType) null, null); + this.claimTypeMap = claimTypeMap; + } + + @Override + public Object deserialize(JsonParser parser, DeserializationContext context) throws JacksonException { + // check if the current claim key is mapped, if so traverse it's value + String name = parser.currentName(); + if (claimTypeMap != null && name != null && claimTypeMap.containsKey(name)) { + Class type = claimTypeMap.get(name); + //noinspection resource + JsonNode node = parser.readValueAsTree(); + return context.readTreeAsValue(node, type); + } + // otherwise default to super + return super.deserialize(parser, context); + } + } +} diff --git a/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3Serializer.java b/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3Serializer.java new file mode 100644 index 000000000..7ffb4a07c --- /dev/null +++ b/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3Serializer.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.jackson.io; + +import io.jsonwebtoken.io.AbstractSerializer; +import io.jsonwebtoken.lang.Assert; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.module.SimpleModule; + +import java.io.OutputStream; + +import static tools.jackson.core.StreamWriteFeature.AUTO_CLOSE_TARGET; +import static tools.jackson.databind.json.JsonMapper.builder; + +/** + * Serializer using a Jackson {@link ObjectMapper}. + * + * @since 0.14.0 + */ +public class Jackson3Serializer extends AbstractSerializer { + + static final String MODULE_ID = "jjwt-jackson3"; + static final JacksonModule MODULE; + + static { + SimpleModule module = new SimpleModule(MODULE_ID); + module.addSerializer(Jackson3SupplierSerializer.INSTANCE); + MODULE = module; + } + + static final ObjectMapper DEFAULT_OBJECT_MAPPER = newObjectMapper(); + + /** + * Creates and returns a new ObjectMapper with the {@code jjwt-jackson3} module registered and + * {@code JsonParser.Feature.STRICT_DUPLICATE_DETECTION} enabled (set to true) and + * {@code DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES} disabled (set to false). + * + * @return a new ObjectMapper with the {@code jjwt-jackson3} module registered and + * {@code JsonParser.Feature.STRICT_DUPLICATE_DETECTION} enabled (set to true) and + * {@code DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES} disabled (set to false). + * + * @since 0.12.4 + */ + // package protected on purpose, do not expose to the public API + static ObjectMapper newObjectMapper() { + return builder().addModule(MODULE) + .configure(StreamReadFeature.STRICT_DUPLICATE_DETECTION, true) // https://github.com/jwtk/jjwt/issues/877 + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) // https://github.com/jwtk/jjwt/issues/893 + .build(); + } + + protected final ObjectMapper objectMapper; + + /** + * Constructor using JJWT's default {@link ObjectMapper} singleton for serialization. + */ + public Jackson3Serializer() { + this(DEFAULT_OBJECT_MAPPER); + } + + /** + * Creates a new Jackson Serializer that uses the specified {@link ObjectMapper} for serialization. + * + * @param objectMapper the ObjectMapper to use for serialization. + */ + public Jackson3Serializer(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper cannot be null."); + this.objectMapper = objectMapper.rebuild().addModule(MODULE).build(); + } + + @Override + protected void doSerialize(T t, OutputStream out) throws Exception { + Assert.notNull(out, "OutputStream cannot be null."); + + ObjectWriter writer = this.objectMapper.writer().without(AUTO_CLOSE_TARGET); + writer.writeValue(out, t); + } +} diff --git a/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3SupplierSerializer.java b/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3SupplierSerializer.java new file mode 100644 index 000000000..1e8a1f49f --- /dev/null +++ b/extensions/jackson3/src/main/java/io/jsonwebtoken/jackson/io/Jackson3SupplierSerializer.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2022 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.jackson.io; + + +import io.jsonwebtoken.lang.Supplier; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.ser.std.StdSerializer; + +final class Jackson3SupplierSerializer extends StdSerializer> { + + static final Jackson3SupplierSerializer INSTANCE = new Jackson3SupplierSerializer(); + + public Jackson3SupplierSerializer() { + super(Supplier.class, false); + } + + /** + * @param supplier + * @param generator + * @param provider + * @throws JacksonException + */ + @Override + public void serialize(Supplier supplier, JsonGenerator generator, SerializationContext provider) throws JacksonException { + Object value = supplier.get(); + + if (value == null) { + provider.defaultSerializeNullValue(generator); + return; + } + + Class clazz = value.getClass(); + ValueSerializer ser = provider.findTypedValueSerializer(clazz, true); + ser.serialize(value, generator, provider); + } +} diff --git a/extensions/jackson3/src/main/resources/META-INF/services/io.jsonwebtoken.io.Deserializer b/extensions/jackson3/src/main/resources/META-INF/services/io.jsonwebtoken.io.Deserializer new file mode 100644 index 000000000..053a60d4c --- /dev/null +++ b/extensions/jackson3/src/main/resources/META-INF/services/io.jsonwebtoken.io.Deserializer @@ -0,0 +1 @@ +io.jsonwebtoken.jackson.io.Jackson3Deserializer \ No newline at end of file diff --git a/extensions/jackson3/src/main/resources/META-INF/services/io.jsonwebtoken.io.Serializer b/extensions/jackson3/src/main/resources/META-INF/services/io.jsonwebtoken.io.Serializer new file mode 100644 index 000000000..87bdf64ff --- /dev/null +++ b/extensions/jackson3/src/main/resources/META-INF/services/io.jsonwebtoken.io.Serializer @@ -0,0 +1 @@ +io.jsonwebtoken.jackson.io.Jackson3Serializer \ No newline at end of file diff --git a/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3DeserializerTest.groovy b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3DeserializerTest.groovy new file mode 100644 index 000000000..811ac0764 --- /dev/null +++ b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3DeserializerTest.groovy @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2014 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//file:noinspection GrDeprecatedAPIUsage +package io.jsonwebtoken.jackson.io + +import io.jsonwebtoken.io.DeserializationException +import io.jsonwebtoken.io.Deserializer +import io.jsonwebtoken.io.Encoders +import io.jsonwebtoken.jackson.io.stubs.CustomBean +import io.jsonwebtoken.lang.Maps +import org.junit.Before +import org.junit.Test +import tools.jackson.core.JacksonException +import tools.jackson.databind.ObjectMapper + +import static org.junit.Assert.* + +class Jackson3DeserializerTest { + + private Jackson3Deserializer deserializer + + @Before + void setUp() { + deserializer = new Jackson3Deserializer() + } + + @Test + void loadService() { + def deserializer = ServiceLoader.load(Deserializer).iterator().next() + assertTrue deserializer instanceof Jackson3Deserializer + } + + @Test + void testDefaultConstructor() { + assertSame Jackson3Serializer.DEFAULT_OBJECT_MAPPER, deserializer.objectMapper + } + + @Test + void testObjectMapperConstructor() { + def customOM = new ObjectMapper() + deserializer = new Jackson3Deserializer<>(customOM) + assertSame customOM, deserializer.objectMapper + } + + @Test(expected = IllegalArgumentException) + void testObjectMapperConstructorWithNullArgument() { + new Jackson3Deserializer<>((ObjectMapper) null) + } + + @Test + void testDeserialize() { + def reader = new StringReader('{"hello":"世界"}') + def expected = [hello: '世界'] + def result = deserializer.deserialize(reader) + assertEquals expected, result + } + + @Test + void testDeserializeWithCustomObject() { + + long currentTime = System.currentTimeMillis() + + String json = """ + { + "oneKey":"oneValue", + "custom": { + "stringValue": "s-value", + "intValue": "11", + "dateValue": ${currentTime}, + "shortValue": 22, + "longValue": 33, + "byteValue": 15, + "byteArrayValue": "${base64('bytes')}", + "nestedValue": { + "stringValue": "nested-value", + "intValue": "111", + "dateValue": ${currentTime + 1}, + "shortValue": 222, + "longValue": 333, + "byteValue": 10, + "byteArrayValue": "${base64('bytes2')}" + } + } + } + """ + + CustomBean expectedCustomBean = new CustomBean() + .setByteArrayValue("bytes".getBytes("UTF-8")) + .setByteValue(0xF as byte) + .setDateValue(new Date(currentTime)) + .setIntValue(11) + .setShortValue(22 as short) + .setLongValue(33L) + .setStringValue("s-value") + .setNestedValue(new CustomBean() + .setByteArrayValue("bytes2".getBytes("UTF-8")) + .setByteValue(0xA as byte) + .setDateValue(new Date(currentTime + 1)) + .setIntValue(111) + .setShortValue(222 as short) + .setLongValue(333L) + .setStringValue("nested-value") + ) + + def expected = [oneKey: "oneValue", custom: expectedCustomBean] + def result = new Jackson3Deserializer(Maps.of("custom", CustomBean).build()) + .deserialize(new StringReader(json)) + assertEquals expected, result + } + + /** + * Asserts https://github.com/jwtk/jjwt/issues/877 + * @since 0.12.4 + */ + @Test + void testStrictDuplicateDetection() { + // 'bKey' is repeated twice: + String json = """ + { + "aKey":"oneValue", + "bKey": 15, + "bKey": "hello" + } + """ + try { + new Jackson3Deserializer<>().deserialize(new StringReader(json)) + fail() + } catch (DeserializationException expected) { + String causeMsg = "Duplicate Object property \"bKey\"\n at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN]" + String msg = "Unable to deserialize: $causeMsg" + assertEquals msg, expected.getMessage() + assertTrue expected.getCause() instanceof JacksonException + assertEquals causeMsg, expected.getCause().getMessage() + } + } + + /** + * Asserts https://github.com/jwtk/jjwt/issues/893 + */ + @Test + void testIgnoreUnknownPropertiesWhenDeserializeWithCustomObject() { + + long currentTime = System.currentTimeMillis() + + String json = """ + { + "oneKey":"oneValue", + "custom": { + "stringValue": "s-value", + "intValue": "11", + "dateValue": ${currentTime}, + "shortValue": 22, + "longValue": 33, + "byteValue": 15, + "byteArrayValue": "${base64('bytes')}", + "unknown": "unknown", + "nestedValue": { + "stringValue": "nested-value", + "intValue": "111", + "dateValue": ${currentTime + 1}, + "shortValue": 222, + "longValue": 333, + "byteValue": 10, + "byteArrayValue": "${base64('bytes2')}", + "unknown": "unknown" + } + } + } + """ + + CustomBean expectedCustomBean = new CustomBean() + .setByteArrayValue("bytes".getBytes("UTF-8")) + .setByteValue(0xF as byte) + .setDateValue(new Date(currentTime)) + .setIntValue(11) + .setShortValue(22 as short) + .setLongValue(33L) + .setStringValue("s-value") + .setNestedValue(new CustomBean() + .setByteArrayValue("bytes2".getBytes("UTF-8")) + .setByteValue(0xA as byte) + .setDateValue(new Date(currentTime + 1)) + .setIntValue(111) + .setShortValue(222 as short) + .setLongValue(333L) + .setStringValue("nested-value") + ) + + def expected = [oneKey: "oneValue", custom: expectedCustomBean] + def result = new Jackson3Deserializer(Maps.of("custom", CustomBean).build()) + .deserialize(new StringReader(json)) + assertEquals expected, result + } + + /** + * For: https://github.com/jwtk/jjwt/issues/564 + */ + @Test + void testMappedTypeDeserializerWithMapNullCheck() { + + // mimic map implementations that do NOT allow for null keys, or containsKey(null) + Map typeMap = new HashMap() { + @Override + boolean containsKey(Object key) { + if (key == null) { + throw new NullPointerException("key is null, expected for this test") + } + return super.containsKey(key) + } + } + + // TODO: the following does NOT work with Java 1.7 + // when we stop supporting that version we can use a partial mock instead + // the `typeMap.put("custom", CustomBean)` line below results in an NPE, (only on 1.7) + +// Map typeMap = partialMockBuilder(HashMap) +// .addMockedMethod("containsKey") +// .createNiceMock() +// +// expect(typeMap.containsKey(null)).andThrow(new NullPointerException("key is null, expected for this test")) +// replay(typeMap) + + typeMap.put("custom", CustomBean) + + def deserializer = new Jackson3Deserializer(typeMap) + def reader = new StringReader('{"alg":"HS256"}') + def result = deserializer.deserialize(reader) + assertEquals(["alg": "HS256"], result) + } + + @Test(expected = IllegalArgumentException) + void testNullClaimTypeMap() { + new Jackson3Deserializer((Map) null) + } + + @Test + void testDeserializeFailsWithException() { + + def ex = new IOException('foo') + + deserializer = new Jackson3Deserializer() { + @Override + protected Object doDeserialize(Reader reader) throws Exception { + throw ex + } + } + try { + deserializer.deserialize(new StringReader('{"hello":"世界"}')) + fail() + } catch (DeserializationException se) { + String msg = 'Unable to deserialize: foo' + assertEquals msg, se.getMessage() + assertSame ex, se.getCause() + } + } + + private static String base64(String input) { + return Encoders.BASE64.encode(input.getBytes('UTF-8')) + } +} diff --git a/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3SerializerTest.groovy b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3SerializerTest.groovy new file mode 100644 index 000000000..54391bfec --- /dev/null +++ b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3SerializerTest.groovy @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2014 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.jackson.io + +import io.jsonwebtoken.io.Serializer +import io.jsonwebtoken.lang.Strings +import org.junit.Before +import org.junit.Test +import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.cfg.MapperBuilder + +import static org.easymock.EasyMock.* +import static org.junit.Assert.* + +class Jackson3SerializerTest { + + private Jackson3Serializer ser + + @Before + void setUp() { + ser = new Jackson3Serializer() + } + + byte[] serialize(def value) { + def os = new ByteArrayOutputStream() + ser.serialize(value, os) + return os.toByteArray() + } + + @Test + void loadService() { + def serializer = ServiceLoader.load(Serializer).iterator().next() + assertTrue serializer instanceof Jackson3Serializer + } + + @Test + void testDefaultConstructor() { + assertSame Jackson3Serializer.DEFAULT_OBJECT_MAPPER.serializationConfig().getSubtypeResolver().getClass(), ser.objectMapper.serializationConfig().getSubtypeResolver().getClass() + } + + @Test + void testObjectMapperConstructor() { + ObjectMapper customOM = new ObjectMapper() + ser = new Jackson3Serializer(customOM) + assertNotSame customOM, ser.objectMapper; + + assertEquals customOM.serializationConfig().getSubtypeResolver().getClass(), + ser.objectMapper.serializationConfig().getSubtypeResolver().getClass(); + } + + @Test(expected = IllegalArgumentException) + void testObjectMapperConstructorWithNullArgument() { + new Jackson3Serializer<>(null) + } + + @Test + void testObjectMapperConstructorAutoRegistersModule() { + ObjectMapper om = createMock(ObjectMapper) + MapperBuilder builder = createMock(MapperBuilder) + + expect(om.rebuild()).andReturn(builder) + expect(builder.addModule(same(Jackson3Serializer.MODULE))).andReturn(builder) + expect(builder.build()).andReturn(om) + + replay om, builder + + new Jackson3Serializer<>(om) + + verify om, builder + } + + @Test + void testSerialize() { + byte[] expected = '{"hello":"世界"}'.getBytes(Strings.UTF_8) + byte[] result = ser.serialize([hello: '世界']) + assertTrue Arrays.equals(expected, result) + } + + @Test + void testByte() { + byte[] expected = Strings.utf8("120") //ascii("x") = 120 + byte[] bytes = Strings.utf8("x") + assertArrayEquals expected, serialize(bytes[0]) // single byte + } + + @Test + void testByteArray() { //expect Base64 string by default: + byte[] bytes = Strings.utf8("hi") + String expected = '"aGk="' as String //base64(hi) --> aGk= + assertEquals expected, Strings.utf8(serialize(bytes)) + } + + @Test + void testEmptyByteArray() { //expect Base64 string by default: + byte[] bytes = new byte[0] + byte[] result = serialize(bytes) + assertEquals '""', Strings.utf8(result) + } + + @Test + void testChar() { //expect Base64 string by default: + byte[] result = serialize('h' as char) + assertEquals "\"h\"", Strings.utf8(result) + } + + @Test + void testCharArray() { //expect Base64 string by default: + byte[] result = serialize('hi'.toCharArray()) + assertEquals "\"hi\"", Strings.utf8(result) + } + + @Test + void testWriteObject() { + byte[] expected = Strings.utf8('{"hello":"世界"}' as String) + byte[] result = serialize([hello: '世界']) + assertArrayEquals expected, result + } +} diff --git a/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3SupplierSerializerTest.groovy b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3SupplierSerializerTest.groovy new file mode 100644 index 000000000..78daf55b5 --- /dev/null +++ b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/Jackson3SupplierSerializerTest.groovy @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2022 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.jackson.io + +import io.jsonwebtoken.lang.Strings +import io.jsonwebtoken.lang.Supplier +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class Jackson3SupplierSerializerTest { + + @Test + void testSupplierNullValue() { + def serializer = new Jackson3Serializer() + def supplier = new Supplier() { + @Override + Object get() { + return null + } + } + ByteArrayOutputStream out = new ByteArrayOutputStream() + serializer.serialize(supplier, out) + assertEquals 'null', Strings.utf8(out.toByteArray()) + } + + @Test + void testSupplierStringValue() { + def serializer = new Jackson3Serializer() + def supplier = new Supplier() { + @Override + Object get() { + return 'hello' + } + } + ByteArrayOutputStream out = new ByteArrayOutputStream() + serializer.serialize(supplier, out) + assertEquals '"hello"', Strings.utf8(out.toByteArray()) + } +} diff --git a/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/TestSupplier.groovy b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/TestSupplier.groovy new file mode 100644 index 000000000..4faf2c8ac --- /dev/null +++ b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/TestSupplier.groovy @@ -0,0 +1,33 @@ +/* + * Copyright © 2023 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.jackson.io + +import io.jsonwebtoken.lang.Supplier + +class TestSupplier implements Supplier { + + private static final TestSupplier INSTANCE = new TestSupplier<>("test") + private final T value; + + private TestSupplier(T value) { + this.value = value; + } + + @Override + T get() { + return value; + } +} diff --git a/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/stubs/CustomBean.groovy b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/stubs/CustomBean.groovy new file mode 100644 index 000000000..ab9c4bad1 --- /dev/null +++ b/extensions/jackson3/src/test/groovy/io/jsonwebtoken/jackson/io/stubs/CustomBean.groovy @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2025 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.jackson.io.stubs + +class CustomBean { + + private String stringValue + private int intValue + private Date dateValue + private short shortValue + private long longValue + private byte byteValue + private byte[] byteArrayValue + private CustomBean nestedValue + + String getStringValue() { + return stringValue + } + + CustomBean setStringValue(String stringValue) { + this.stringValue = stringValue + return this + } + + int getIntValue() { + return intValue + } + + CustomBean setIntValue(int intValue) { + this.intValue = intValue + return this + } + + Date getDateValue() { + return dateValue + } + + CustomBean setDateValue(Date dateValue) { + this.dateValue = dateValue + return this + } + + short getShortValue() { + return shortValue + } + + CustomBean setShortValue(short shortValue) { + this.shortValue = shortValue + return this + } + + long getLongValue() { + return longValue + } + + CustomBean setLongValue(long longValue) { + this.longValue = longValue + return this + } + + byte getByteValue() { + return byteValue + } + + CustomBean setByteValue(byte byteValue) { + this.byteValue = byteValue + return this + } + + byte[] getByteArrayValue() { + return byteArrayValue + } + + CustomBean setByteArrayValue(byte[] byteArrayValue) { + this.byteArrayValue = byteArrayValue + return this + } + + CustomBean getNestedValue() { + return nestedValue + } + + CustomBean setNestedValue(CustomBean nestedValue) { + this.nestedValue = nestedValue + return this + } + + boolean equals(o) { + if (this.is(o)) return true + if (getClass() != o.class) return false + + CustomBean that = (CustomBean) o + + if (byteValue != that.byteValue) return false + if (intValue != that.intValue) return false + if (longValue != that.longValue) return false + if (shortValue != that.shortValue) return false + if (!Arrays.equals(byteArrayValue, that.byteArrayValue)) return false + if (dateValue != that.dateValue) return false + if (nestedValue != that.nestedValue) return false + if (stringValue != that.stringValue) return false + + return true + } + + int hashCode() { + int result + result = stringValue.hashCode() + result = 31 * result + intValue + result = 31 * result + dateValue.hashCode() + result = 31 * result + (int) shortValue + result = 31 * result + (int) (longValue ^ (longValue >>> 32)) + result = 31 * result + (int) byteValue + result = 31 * result + Arrays.hashCode(byteArrayValue) + result = 31 * result + nestedValue.hashCode() + return result + } + + + @Override + String toString() { + return "CustomBean{" + + "stringValue='" + stringValue + '\'' + + ", intValue=" + intValue + + ", dateValue=" + dateValue?.time + + ", shortValue=" + shortValue + + ", longValue=" + longValue + + ", byteValue=" + byteValue + +// ", byteArrayValue=" + Arrays.toString(byteArrayValue) + + ", nestedValue=" + nestedValue + + '}' + } +} diff --git a/extensions/pom.xml b/extensions/pom.xml index 24c3bb75d..515606ed2 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -37,5 +37,6 @@ jackson orgjson gson + jackson3 \ No newline at end of file diff --git a/pom.xml b/pom.xml index b0c887bc4..4eaf0b024 100644 --- a/pom.xml +++ b/pom.xml @@ -110,6 +110,7 @@ ${user.name}-${maven.build.timestamp} 2.12.7.1 + 3.0.0 20231013 2.11.0 @@ -176,6 +177,11 @@ jjwt-gson ${project.version} + + tools.jackson.core + jackson-databind + ${jackson3.version} + com.fasterxml.jackson.core jackson-databind @@ -467,7 +473,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.2.1 + 3.4.1 deprecated true @@ -744,6 +750,7 @@ -html5 ${test.addOpens} + 5.6.0