Skip to content
This repository has been archived by the owner on Mar 30, 2020. It is now read-only.

Commit

Permalink
create basic JacksonModule
Browse files Browse the repository at this point in the history
  • Loading branch information
CarstenWickner committed Jun 10, 2019
1 parent 67de7c3 commit 111c4c9
Show file tree
Hide file tree
Showing 3 changed files with 396 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* Copyright 2019 VicTools.
*
* 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 com.github.victools.jsonschema.module.jackson;

import com.fasterxml.classmate.ResolvedType;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.MemberScope;
import com.github.victools.jsonschema.generator.MethodScope;
import com.github.victools.jsonschema.generator.Module;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import java.lang.annotation.Annotation;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

/**
* Module for setting up schema generation aspects based on {@code jackson-annotations}:
* <ul>
* <li>Populate the "description" attributes as per {@link JsonPropertyDescription} and {@link JsonClassDescription} annotations.</li>
* <li>Apply alternative property names defined in {@link JsonProperty} annotations.</li>
* <li>Exclude properties that are deemed to be ignored per the various annotations for that purpose.</li>
* </ul>
*/
public class JacksonModule implements Module {

private ObjectMapper objectMapper;
private final Map<Class<?>, BeanDescription> beanDescriptions = new HashMap<>();

@Override
public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
this.objectMapper = builder.getObjectMapper();
builder.forFields()
.withDescriptionResolver(this::resolveDescription)
.withPropertyNameOverrideResolver(this::getPropertyNameOverride)
.withIgnoreCheck(this::shouldIgnoreField);
}

/**
* Retrieves the annotation instance of the given type, either from the field it self or (if not present) from its getter.
*
* @param <A> type of annotation
* @param field field to retrieve annotation instance from (or from its getter)
* @param annotationClass type of annotation
* @return annotation instance (or {@code null})
* @see MemberScope#getAnnotation(Class)
* @see FieldScope#findGetter()
*/
protected <A extends Annotation> A getAnnotationFromFieldOrGetter(FieldScope field, Class<A> annotationClass) {
A annotation = field.getAnnotation(annotationClass);
if (annotation == null) {
MethodScope getter = field.findGetter();
annotation = getter == null ? null : getter.getAnnotation(annotationClass);
}
return annotation;
}

/**
* Determine the given type's associated "description" in the following order of priority.
* <ol>
* <li>{@link JsonPropertyDescription} annotation on the field itself</li>
* <li>{@link JsonPropertyDescription} annotation on the field's getter method</li>
* <li>{@link JsonClassDescription} annotation on the field's type</li>
* </ol>
*
* @param field field for which to collect an available description
* @return successfully looked-up description (or {@code null})
*/
protected String resolveDescription(FieldScope field) {
// look for property specific description
JsonPropertyDescription propertyAnnotation = this.getAnnotationFromFieldOrGetter(field, JsonPropertyDescription.class);
if (propertyAnnotation != null) {
return propertyAnnotation.value();
}
// alternatively look for general class description
Class<?> rawType = field.getType().getErasedType();
JsonClassDescription classAnnotation = rawType.getAnnotation(JsonClassDescription.class);
if (classAnnotation != null) {
return classAnnotation.value();
}
return null;
}

/**
* Look-up an alternative name as per the following order of priority.
* <ol>
* <li>{@link JsonProperty} annotation on the field itself</li>
* <li>{@link JsonProperty} annotation on the field's getter method</li>
* </ol>
*
* @param field field to look-up alternative property name for
* @return alternative property name (or {@code null})
*/
protected String getPropertyNameOverride(FieldScope field) {
JsonProperty annotation = this.getAnnotationFromFieldOrGetter(field, JsonProperty.class);
if (annotation != null) {
String nameOverride = annotation.value();
// check for invalid overrides
if (nameOverride != null && !nameOverride.isEmpty() && !nameOverride.equals(field.getDeclaredName())) {
return nameOverride;
}
}
return null;
}

/**
* Create a jackson {@link BeanDescription} for the given type's erased class in order to avoid having to re-create the complexity therein.
* <br>
* This is assumed to have a negative performance impact (as one type is being introspected twice), that should be fine for schema generation.
*
* @param targetType type for whose erased class the {@link BeanDescription} should be created
* @return introspection result of given type's erased class
*/
protected final BeanDescription getBeanDescriptionForClass(ResolvedType targetType) {
// use a map to cater for some caching (and thereby performance improvement)
return this.beanDescriptions.computeIfAbsent(targetType.getErasedType(),
type -> this.objectMapper.getSerializationConfig().introspect(this.objectMapper.getTypeFactory().constructType(type)));
}

/**
* Determine whether a given field should be ignored, according to various jackson annotations for that purpose,
* <br>
* e.g. {@code JsonIgnore}, {@code JsonIgnoreType}, {@code JsonIgnoreProperties}
*
* @param field field to check
* @return whether field should be excluded
*/
protected boolean shouldIgnoreField(FieldScope field) {
// instead of re-creating the various ways a property may be included/excluded in jackson: just use its built-in introspection
BeanDescription beanDescription = this.getBeanDescriptionForClass(field.getDeclaringType());
// some kinds of field ignorals are only available via an annotation introspector
Set<String> ignoredProperties = this.objectMapper.getSerializationConfig().getAnnotationIntrospector()
.findPropertyIgnorals(beanDescription.getClassInfo()).getIgnored();
String fieldName = field.getName();
if (ignoredProperties.contains(fieldName)) {
return true;
}
// other kinds of field ignorals are handled implicitly, i.e. are only available by way of being absent
return beanDescription.findProperties().stream()
.noneMatch(propertyDefinition -> fieldName.equals(propertyDefinition.getInternalName()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright 2019 VicTools.
*
* 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 com.github.victools.jsonschema.module.jackson;

import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.github.victools.jsonschema.generator.ConfigFunction;
import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigPart;
import junitparams.JUnitParamsRunner;
import junitparams.Parameters;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;

/**
* Test for the {@link JacksonModule}.
*/
@RunWith(JUnitParamsRunner.class)
public class JacksonModuleTest {

private SchemaGeneratorConfigBuilder configBuilder;
private SchemaGeneratorConfigPart<FieldScope> fieldConfigPart;

@Before
public void setUp() {
this.configBuilder = Mockito.mock(SchemaGeneratorConfigBuilder.class);
this.fieldConfigPart = Mockito.spy(new SchemaGeneratorConfigPart<>());
Mockito.when(this.configBuilder.forFields()).thenReturn(this.fieldConfigPart);
}

@Test
public void testApplyToConfigBuilder() {
new JacksonModule().applyToConfigBuilder(this.configBuilder);

Mockito.verify(this.configBuilder).getObjectMapper();
Mockito.verify(this.configBuilder).forFields();

Mockito.verify(this.fieldConfigPart).withDescriptionResolver(Mockito.any());
Mockito.verify(this.fieldConfigPart).withIgnoreCheck(Mockito.any());
Mockito.verify(this.fieldConfigPart).withPropertyNameOverrideResolver(Mockito.any());

Mockito.verifyNoMoreInteractions(this.configBuilder, this.fieldConfigPart);
}

Object parametersForTestPropertyNameOverride() {
return new Object[][]{
{"unannotatedField", null},
{"fieldWithEmptyPropertyAnnotation", null},
{"fieldWithSameValuePropertyAnnotation", null},
{"fieldWithNameOverride", "field override 1"},
{"fieldWithNameOverrideOnGetter", "method override 1"},
{"fieldWithNameOverrideAndOnGetter", "field override 2"}
};
}

@Test
@Parameters
public void testPropertyNameOverride(String fieldName, String expectedOverrideValue) throws Exception {
new JacksonModule().applyToConfigBuilder(this.configBuilder);

ArgumentCaptor<ConfigFunction<FieldScope, String>> captor = ArgumentCaptor.forClass(ConfigFunction.class);
Mockito.verify(this.fieldConfigPart).withPropertyNameOverrideResolver(captor.capture());

FieldScope field = new TestType(TestClassForPropertyNameOverride.class).getMemberField(fieldName);
String overrideValue = captor.getValue().apply(field);
Assert.assertEquals(expectedOverrideValue, overrideValue);
}

Object parametersForTestDescriptionResolver() {
return new Object[][]{
{"unannotatedField", null},
{"fieldWithDescription", "field description 1"},
{"fieldWithDescriptionOnGetter", "getter description 1"},
{"fieldWithDescriptionAndOnGetter", "field description 2"},
{"fieldWithDescriptionOnType", "class description text"}
};
}

@Test
@Parameters
public void testDescriptionResolver(String fieldName, String expectedDescription) throws Exception {
new JacksonModule().applyToConfigBuilder(this.configBuilder);

FieldScope field = new TestType(TestClassForDescription.class).getMemberField(fieldName);

ArgumentCaptor<ConfigFunction<FieldScope, String>> captor = ArgumentCaptor.forClass(ConfigFunction.class);
Mockito.verify(this.fieldConfigPart).withDescriptionResolver(captor.capture());
String description = captor.getValue().apply(field);
Assert.assertEquals(expectedDescription, description);
}

private static class TestClassForPropertyNameOverride {

Integer unannotatedField;
@JsonProperty
Double fieldWithEmptyPropertyAnnotation;
@JsonProperty(value = "fieldWithSameValuePropertyAnnotation")
Float fieldWithSameValuePropertyAnnotation;
@JsonProperty(value = "field override 1")
Long fieldWithNameOverride;
Boolean fieldWithNameOverrideOnGetter;
@JsonProperty(value = "field override 2")
String fieldWithNameOverrideAndOnGetter;

public Integer getUnannotatedField() {
return this.unannotatedField;
}

@JsonProperty(value = "method override 1")
public boolean isFieldWithNameOverrideOnGetter() {
return this.fieldWithNameOverrideOnGetter;
}

@JsonProperty(value = "method override 2")
public String getFieldWithNameOverrideAndOnGetter() {
return this.fieldWithNameOverrideAndOnGetter;
}
}

@JsonClassDescription(value = "class description text")
private static class TestClassForDescription {

Integer unannotatedField;
@JsonPropertyDescription(value = "field description 1")
Double fieldWithDescription;
Float fieldWithDescriptionOnGetter;
@JsonPropertyDescription(value = "field description 2")
Long fieldWithDescriptionAndOnGetter;
TestClassForDescription fieldWithDescriptionOnType;

@JsonPropertyDescription(value = "getter description 1")
public Float getFieldWithDescriptionOnGetter() {
return this.fieldWithDescriptionOnGetter;
}

@JsonPropertyDescription(value = "getter description 2")
public Long getFieldWithDescriptionAndOnGetter() {
return fieldWithDescriptionAndOnGetter;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2019 VicTools.
*
* 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 com.github.victools.jsonschema.module.jackson;

import com.fasterxml.classmate.ResolvedType;
import com.fasterxml.classmate.ResolvedTypeWithMembers;
import com.fasterxml.classmate.members.ResolvedField;
import com.fasterxml.classmate.members.ResolvedMethod;
import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.MethodScope;
import com.github.victools.jsonschema.generator.TypeContext;
import com.github.victools.jsonschema.generator.impl.TypeContextFactory;
import java.util.stream.Stream;

/**
* Helper class for constructing {@link FieldScope} and {@link MethodScope} instances in tests.
*/
public class TestType {

private final TypeContext context;
private final ResolvedType resolvedTestClass;
private final ResolvedTypeWithMembers testClassMembers;

public TestType(Class<?> testClass) {
this.context = TypeContextFactory.createDefaultTypeContext();
this.resolvedTestClass = this.context.resolve(testClass);
this.testClassMembers = this.context.resolveWithMembers(this.resolvedTestClass);
}

public FieldScope getMemberField(String fieldName) {
return this.getField(this.testClassMembers.getMemberFields(), fieldName);
}

public FieldScope getStaticField(String fieldName) {
return this.getField(this.testClassMembers.getStaticFields(), fieldName);
}

private FieldScope getField(ResolvedField[] fields, String fieldName) {
return Stream.of(fields)
.filter(field -> fieldName.equals(field.getName()))
.findAny()
.map(field -> this.context.createFieldScope(field, this.testClassMembers))
.get();
}

public MethodScope getMemberMethod(String methodName) {
return this.getMethod(this.testClassMembers.getMemberMethods(), methodName);
}

public MethodScope getStaticMethod(String methodName) {
return this.getMethod(this.testClassMembers.getStaticMethods(), methodName);
}

private MethodScope getMethod(ResolvedMethod[] methods, String methodName) {
return Stream.of(methods)
.filter(method -> methodName.equals(method.getName()))
.findAny()
.map(method -> this.context.createMethodScope(method, this.testClassMembers))
.get();
}
}

0 comments on commit 111c4c9

Please sign in to comment.