Skip to content

Commit

Permalink
Add option to customise ObjectMapper (#704, #740 / #741)
Browse files Browse the repository at this point in the history
This PR solves the need to be able to customise the ObjectMapper
in JSON based tests, e.g.: load in services. It also adds the option
ALLOW_TRAILING_COMMA.

Closes: #704, #740
PR: #741
  • Loading branch information
Michael1993 committed Sep 7, 2023
1 parent 18ed594 commit c7bb969
Show file tree
Hide file tree
Showing 17 changed files with 544 additions and 72 deletions.
63 changes: 57 additions & 6 deletions docs/json-argument-source.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -169,32 +169,83 @@ If your project does not already depend on a supported JSON parser, you can add
Gradle offers two ways to pull in a parser.
The recommended one is to use https://docs.gradle.org/current/userguide/feature_variants.html[feature variants]:

```kotlin
[source,kotlin]
----
testRuntimeOnly("org.junit-pioneer:junit-pioneer") {
capabilities {
requireCapability("org.junit-pioneer:junit-pioneer-jackson")
}
}
```
----

Alternatively, the dependency can be added directly:

```kotlin
[source,kotlin]
----
testImplementation("com.fasterxml.jackson.core:jackson-databind:$CURRENT_VERSION")
```
----

=== Maven

In Maven, add the parser as a test dependency:

```xml
[source,xml]
----
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>$CURRENT_VERSION</version>
<scope>test</scope>
</dependency>
```
----

=== Custom `ObjectMapper` for Jackson

By default, Jackson does not support complex fields (e.g.: `LocalDate`).
It uses an extension/module system for additional capabilities.
You have to register these modules on the `ObjectMapper` instance you want to use.

Pioneer provides the service interface `ObjectMapperProvider` for you to be able to provide your own `ObjectMapper` instance (_and register modules on it_).
This interface has three methods:

- `get()` for supplying an `ObjectMapper`.
- `getLenient()` this is a `default` method that copies the value from `get` and enables some convenience features.
The `ObjectMapper` provided by this method is the one Pioneer uses to parse your JSON.
If you use your own custom `ObjectMapper` implementation, you might have to override this method.
- `id()` to identify the `ObjectMapperProvider`.
This has to be a unique `String`.
The `ObjectMapperProvider` used by Pioneer has the id *"default"*.

Pioneer uses `ServiceLoader` to load in your implementation of `ObjectMapperProvider`.
You can tell Pioneer to use your implementation in one of two ways.

- By using `@UseObjectMapper` on your test.
This will make that single test use the `ObjectMapper` provided by the `ObjectMapperProvider` with the id specified.
You can also add `@UseObjectMapper` to your own annotation as meta-annotation.

.Annotating your test
[source,java,indent=0]
----
include::{json-demo}[tag=use_object_mapper_example]
----

.Creating your own annotation
[source,java,indent=0]
----
include::{json-demo}[tag=custom_annotation]
----

- By supplying the id of an `ObjectMapperProvider` implementation as a https://junit.org/junit5/docs/current/user-guide/#running-tests-config-params[configuration parameter].
The configuration parameter is `org.junitpioneer.jupiter.json.objectmapper`.
All tests will use the `ObjectMapper` provided by the `ObjectMapperProvider` with the id specified.

.Configuration parameter example
[source]
----
org.junitpioneer.jupiter.json.objectmapper=custom
----

If both the configuration parameter and the `@UseObjectMapper` annotation is present, the annotation value will be used.

=== Java Modules

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

package org.junitpioneer.jupiter.json;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.List;

import org.junit.jupiter.api.Nested;
Expand Down Expand Up @@ -127,4 +131,26 @@ void deconstructFromArray(

}

static class Misc {

// tag::use_object_mapper_example[]
@ParameterizedTest
@UseObjectMapper("custom")
@JsonClasspathSource("jedis.json")
void singleJediProperty(@Property("name") String jediName) {
// YOUR TEST CODE HERE
}

// end::use_object_mapper_example[]
}

// tag::custom_annotation[]
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ParameterizedTest
@UseObjectMapper("custom")
public @interface JsonTest {
}
// end::custom_annotation[]

}
4 changes: 4 additions & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@
provides org.junit.platform.launcher.TestExecutionListener
with org.junitpioneer.jupiter.issue.IssueExtensionExecutionListener;
uses org.junitpioneer.jupiter.IssueProcessor;

provides org.junitpioneer.jupiter.json.ObjectMapperProvider
with org.junitpioneer.jupiter.json.DefaultObjectMapperProvider;
uses org.junitpioneer.jupiter.json.ObjectMapperProvider;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

package org.junitpioneer.jupiter.json;

import static java.lang.String.format;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
Expand All @@ -20,6 +22,8 @@
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.support.AnnotationConsumer;
import org.junit.platform.commons.support.AnnotationSupport;
import org.junitpioneer.internal.PioneerPreconditions;
import org.junitpioneer.jupiter.cartesian.CartesianParameterArgumentsProvider;

/**
Expand All @@ -28,6 +32,8 @@
abstract class AbstractJsonArgumentsProvider<A extends Annotation>
implements ArgumentsProvider, AnnotationConsumer<A>, CartesianParameterArgumentsProvider<Object> {

public static final String CONFIG_PARAM = "org.junitpioneer.jupiter.json.objectmapper";

@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
Method method = context.getRequiredTestMethod();
Expand All @@ -40,7 +46,15 @@ public Stream<Object> provideArguments(ExtensionContext context, Parameter param
}

private Stream<Node> provideNodes(ExtensionContext context) {
return provideNodes(context, JsonConverterProvider.getJsonConverter());
String config = context.getConfigurationParameter(CONFIG_PARAM).orElse("default");
PioneerPreconditions
.notBlank(config, format("The configuration parameter %s must not have a blank value", CONFIG_PARAM));
String objectMapperId = AnnotationSupport
.findAnnotation(context.getRequiredTestMethod(), UseObjectMapper.class)
.map(UseObjectMapper::value)
.orElse(config);
PioneerPreconditions.notBlank(objectMapperId, format("%s must not have a blank value", UseObjectMapper.class));
return provideNodes(context, JsonConverterProvider.getJsonConverter(objectMapperId));
}

protected abstract Stream<Node> provideNodes(ExtensionContext context, JsonConverter jsonConverter);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2016-2022 the original author or authors.
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License v2.0 which
* accompanies this distribution and is available at
*
* http://www.eclipse.org/legal/epl-v20.html
*/

package org.junitpioneer.jupiter.json;

import com.fasterxml.jackson.databind.ObjectMapper;

public class DefaultObjectMapperProvider implements ObjectMapperProvider {

public DefaultObjectMapperProvider() {
// recreate default constructor to prevent compiler warning
}

@Override
public ObjectMapper get() {
return new ObjectMapper();
}

@Override
public String id() {
return "default";
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,43 @@

package org.junitpioneer.jupiter.json;

import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toMap;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.Map;
import java.util.ServiceLoader;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.junitpioneer.internal.PioneerPreconditions;

/**
* A {@link JsonConverter} using Jackson 2 {@link ObjectMapper} to perform the conversion
*/
class JacksonJsonConverter implements JsonConverter {

private static final JacksonJsonConverter INSTANCE = new JacksonJsonConverter(new ObjectMapper());
private static final Map<String, ObjectMapperProvider> OBJECT_MAPPERS = loadObjectMappers();

private final ObjectMapper objectMapper;

private final ObjectMapper lenientObjectMapper;

JacksonJsonConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.lenientObjectMapper = this.objectMapper.copy();
this.lenientObjectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
this.lenientObjectMapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true);
this.lenientObjectMapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true);
JacksonJsonConverter(ObjectMapperProvider provider) {
PioneerPreconditions.notNull(provider, "Could not find custom object mapper.");
this.objectMapper = provider.get();
this.lenientObjectMapper = provider.getLenient();
}

private static Map<String, ObjectMapperProvider> loadObjectMappers() {
return ServiceLoader
.load(ObjectMapperProvider.class)
.stream()
.map(ServiceLoader.Provider::get)
.collect(toMap(ObjectMapperProvider::id, identity()));
}

@Override
Expand Down Expand Up @@ -62,8 +75,8 @@ private ObjectMapper getObjectMapper(boolean lenient) {
return lenient ? lenientObjectMapper : objectMapper;
}

static JacksonJsonConverter getConverter() {
return INSTANCE;
static JacksonJsonConverter getConverter(String objectMapperId) {
return new JacksonJsonConverter(OBJECT_MAPPERS.get(objectMapperId));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ static boolean isJacksonObjectMapperClassPresent() {
}
}

static JsonConverter getJsonConverter() {
static JsonConverter getJsonConverter(String objectMapperId) {
if (JACKSON_PRESENT) {
return JacksonJsonConverter.getConverter();
return JacksonJsonConverter.getConverter(objectMapperId);
}

throw new NoJsonParserConfiguredException();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2016-2022 the original author or authors.
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License v2.0 which
* accompanies this distribution and is available at
*
* http://www.eclipse.org/legal/epl-v20.html
*/

package org.junitpioneer.jupiter.json;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;

/**
* Service interface for providing a custom {@link com.fasterxml.jackson.databind.ObjectMapper} instance at runtime.
* The default implementation doesn't register any additional Jackson modules.
*
* @see com.fasterxml.jackson.databind.Module
*/
public interface ObjectMapperProvider {

ObjectMapper get();

default ObjectMapper getLenient() {
var mapper = get();
if (mapper instanceof JsonMapper) {
return ((JsonMapper) mapper)
.rebuild()
.enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
.enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
.enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
.build();
}
return get()
.copyWith(JsonFactory
.builder()
.enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
.enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
.enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
.build());
}

String id();

}
26 changes: 26 additions & 0 deletions src/main/java/org/junitpioneer/jupiter/json/UseObjectMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright 2016-2022 the original author or authors.
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License v2.0 which
* accompanies this distribution and is available at
*
* http://www.eclipse.org/legal/epl-v20.html
*/

package org.junitpioneer.jupiter.json;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface UseObjectMapper {

String value() default "default";

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.junitpioneer.jupiter.json.DefaultObjectMapperProvider
6 changes: 6 additions & 0 deletions src/test/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
with org.junitpioneer.jupiter.issue.StoringIssueProcessor;
uses org.junitpioneer.jupiter.IssueProcessor;

provides org.junitpioneer.jupiter.json.ObjectMapperProvider
with org.junitpioneer.jupiter.json.DefaultObjectMapperProvider,
org.junitpioneer.jupiter.json.ObjectMapperProviderTests.DummyObjectMapperProvider,
org.junitpioneer.jupiter.json.ObjectMapperProviderTests.ThrowingObjectMapperProvider;
uses org.junitpioneer.jupiter.json.ObjectMapperProvider;

requires org.junit.platform.testkit;
requires org.mockito;
requires org.assertj.core;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,6 @@ void assertAllCartesianValuesSupplied() {
assertThat(displayNames.get("deconstructObjectsFromArray"))
.containsExactly("[1] Luke, 172", "[2] Luke, 66", "[3] Yoda, 172", "[4] Yoda, 66");

//assertThat(displayNames.get("customDataLocation"))
// .containsExactly("[1] Snowspeeder, 4.5", "[2] Snowspeeder, 3", "[3] Imperial Speeder Bike, 4.5",
// "[4] Imperial Speeder Bike, 3");

assertThat(displayNames.get("deconstructObjectsFromMultipleFiles"))
.containsExactly("[1] 66, Yoda", "[2] 66, Luke", "[3] 172, Yoda", "[4] 172, Luke");

Expand Down

0 comments on commit c7bb969

Please sign in to comment.