Reputation: 116
We're developing a RESTful API using Java EE 7 (RESTEasy / Hibernate / Jackson).
We want the API to serialize all child entities using their IDs, by default. We're doing this mostly to maintain consistency with our deserialization strategy, where we insist on receiving an ID.
However, we also want our users to be able to choose to get an expanded view of any of our child entities, either through a custom endpoint or a query parameter (undecided). For example:
// http://localhost:8080/rest/operator/1
// =====================================
{
"operatorId": 1,
"organization": 34,
"endUser": 23
}
// http://localhost:8080/rest/operator/1?expand=organization
// =====================================
{
"operatorId": 1,
"organization": {
"organizationId": 34,
"organizationName": "name"
},
"endUser": 23
}
// http://localhost:8080/rest/operator/1?expand=enduser
// =====================================
{
"operatorId": 1,
"organization": 34,
"endUser": {
"endUserId": 23,
"endUserName": "other name"
}
}
// http://localhost:8080/rest/operator/1?expand=organization,enduser
// =====================================
{
"operatorId": 1,
"organization": {
"organizationId": 34,
"organizationName": "name"
},
"endUser": {
"endUserId": 23,
"endUserName": "other name"
}
}
Is there a way to dynamically change the behavior of Jackson to determine whether a specified AbstractEntity field is serialized in full form or as its ID? How might it be done?
Additional Info
We know of a few ways to serialize our child entities using their IDs, including:
public class Operator extends AbstractEntity {
...
@JsonIdentityInfo(generator=ObjectIdGenerators.PropertyGenerator.class, property="organizationId")
@JsonIdentityReference(alwaysAsId=true)
public getOrganization() { ... }
...
}
and
public class Operator extends AbstractEntity {
...
@JsonSerialize(using=AbstractEntityIdSerializer.class)
public getOrganization() { ... }
...
}
where AbstractEntityIdSerializer serializes the entity using its ID.
The problem is that we don't know of a way for the user to override that default behavior and revert to standard Jackson object serialization. Ideally they'd also be able to choose which child properties to serialize in full form.
It would be awesome to dynamically toggle the alwaysAsId argument of @JsonIdentityReference for any property at runtime, if that's possible, or make the equivalent change to ObjectMapper/ObjectWriter.
Update: Working(?) Solution We haven't had a chance to fully test this yet, but I've been working on a solution that leverages overriding Jackson's AnnotationIntrospector class. It seems to be working as intended.
public class CustomAnnotationIntrospector extends JacksonAnnotationIntrospector {
private final Set<String> expandFieldNames_;
public CustomAnnotationIntrospector(Set<String> expandFieldNames) {
expandFieldNames_ = expandFieldNames;
}
@Override
public ObjectIdInfo findObjectReferenceInfo(Annotated ann, ObjectIdInfo objectIdInfo) {
JsonIdentityReference ref = _findAnnotation(ann, JsonIdentityReference.class);
if (ref != null) {
for (String expandFieldName : expandFieldNames_) {
String expandFieldGetterName = "get" + expandFieldName;
String propertyName = ann.getName();
boolean fieldNameMatches = expandFieldName.equalsIgnoreCase(propertyName);
boolean fieldGetterNameMatches = expandFieldGetterName.equalsIgnoreCase(propertyName);
if (fieldNameMatches || fieldGetterNameMatches) {
return objectIdInfo.withAlwaysAsId(false);
}
}
objectIdInfo = objectIdInfo.withAlwaysAsId(ref.alwaysAsId());
}
return objectIdInfo;
}
}
At serialization time, we copy our ObjectMapper (so the AnnotationIntrospector runs again) and apply CustomAnnotationIntrospector as follows:
@Context
private HttpRequest httpRequest_;
@Override
writeTo(...) {
// Get our application's ObjectMapper.
ContextResolver<ObjectMapper> objectMapperResolver = provider_.getContextResolver(ObjectMapper.class,
MediaType.WILDCARD_TYPE);
ObjectMapper objectMapper = objectMapperResolver.getContext(Object.class);
// Get Set of fields to be expanded (pre-parsed).
Set<String> fieldNames = (Set<String>)httpRequest_.getAttribute("ExpandFields");
if (!fieldNames.isEmpty()) {
// Pass expand fields to AnnotationIntrospector.
AnnotationIntrospector expansionAnnotationIntrospector = new CustomAnnotationIntrospector(fieldNames);
// Replace ObjectMapper with copy of ObjectMapper and apply custom AnnotationIntrospector.
objectMapper = objectMapper.copy();
objectMapper.setAnnotationIntrospector(expansionAnnotationIntrospector);
}
ObjectWriter objectWriter = objectMapper.writer();
objectWriter.writeValue(...);
}
Any glaring flaws in this approach? It seems relatively straightforward and is fully dynamic.
Upvotes: 4
Views: 3283
Reputation: 2171
If you are looking for a generalized solution that needs to be extended to all of your resources you may try following approach. I tried below solution using Jersey and Jackson. It should also work with RestEasy.
Basically, you need to write a custom jackson provider which set a special serializer for an expand
field. Also, you need to pass the expand fields to the serializer so that you can decide how to do the serialization for expand fields.
@Singleton
public class ExpandFieldJacksonProvider extends JacksonJaxbJsonProvider {
@Inject
private Provider<ContainerRequestContext> provider;
@Override
protected JsonEndpointConfig _configForWriting(final ObjectMapper mapper, final Annotation[] annotations, final Class<?> defaultView) {
final AnnotationIntrospector customIntrospector = mapper.getSerializationConfig().getAnnotationIntrospector();
// Set the custom (user) introspector to be the primary one.
final ObjectMapper filteringMapper = mapper.setAnnotationIntrospector(AnnotationIntrospector.pair(customIntrospector, new JacksonAnnotationIntrospector() {
@Override
public Object findSerializer(Annotated a) {
// All expand fields should be annotated with '@ExpandField'.
ExpandField expField = a.getAnnotation(ExpandField.class);
if (expField != null) {
// Use a custom serializer for expand field
return new ExpandSerializer(expField.fieldName(), expField.idProperty());
}
return super.findSerializer(a);
}
}));
return super._configForWriting(filteringMapper, annotations, defaultView);
}
@Override
public void writeTo(final Object value, final Class<?> type, final Type genericType, final Annotation[] annotations, final MediaType mediaType, final MultivaluedMap<String, Object> httpHeaders,
final OutputStream entityStream) throws IOException {
// Set the expand fields to java's ThreadLocal so that it can be accessed in 'ExpandSerializer' class.
ExpandFieldThreadLocal.set(provider.get().getUriInfo().getQueryParameters().get("expand"));
super.writeTo(value, type, genericType, annotations, mediaType, httpHeaders, entityStream);
// Once the serialization is done, clear ThreadLocal
ExpandFieldThreadLocal.remove();
}
ExpandField.java
@Retention(RUNTIME)
public @interface ExpandField {
// name of expand field
String fieldName();
// name of Id property in expand field. For eg: oraganisationId
String idProperty();
}
ExpandFieldThreadLocal.java
public class ExpandFieldThreadLocal {
private static final ThreadLocal<List<String>> _threadLocal = new ThreadLocal<>();
public static List<String> get() {
return _threadLocal.get();
}
public static void set(List<String> expandFields) {
_threadLocal.set(expandFields);
}
public static void remove() {
_threadLocal.remove();
}
}
ExpandFieldSerializer.java
public static class ExpandSerializer extends JsonSerializer<Object> {
private String fieldName;
private String idProperty;
public ExpandSerializer(String fieldName,String idProperty) {
this.fieldName = fieldName;
this.idProperty = idProperty;
}
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException, JsonProcessingException {
// Get expand fields in current request which is set in custom jackson provider.
List<String> expandFields = ExpandFieldThreadLocal.get();
if (expandFields == null || !expandFields.contains(fieldName)) {
try {
// If 'expand' is not present in query param OR if the 'expand' field does not contain this field, write only id.
serializers.defaultSerializeValue(value.getClass().getMethod("get"+StringUtils.capitalize(idProperty)).invoke(value),gen);
} catch (Exception e) {
//Handle Exception here
}
} else {
serializers.defaultSerializeValue(value, gen);
}
}
}
Operator.java
public class Operator extends AbstractEntity {
...
@ExpandField(fieldName = "organization",idProperty="organizationId")
private organization;
...
}
The final step is to register the new ExpandFieldJacksonProvider
. In Jersey, we register it through an instance of javax.ws.rs.core.Application
as shown below. I hope there is something similar in RestEasy. By default, most of the JAX-RS libraries tend to load default JacksonJaxbJsonProvider
through auto-discovery. You have to make sure auto-discovery is disabled for Jackson and new ExpandFieldJacksonProvider
is registered.
public class JaxRsApplication extends Application{
@Override
public Set<Class<?>> getClasses() {
Set<Class<?>> clazzes=new HashSet<>();
clazzes.add(ExpandFieldJacksonProvider.class);
return clazzes;
}
}
Upvotes: 1
Reputation: 14383
The answer is Jackson's mixin feature:
You create a simple Java class that has the exact same method signature as the anotated method of the entity. You annotate that method with the modified value. the body of the method is insignificant (it would not be called):
public class OperatorExpanded {
...
@JsonIdentityInfo(generator=ObjectIdGenerators.PropertyGenerator.class, property="organizationId")
@JsonIdentityReference(alwaysAsId=false)
public Organization getOrganization() { return null; }
...
}
you tie the mixin to the entity-to-be-serialized using Jackson's module system: this can be decided at run time
ObjectMapper mapper = new ObjectMapper();
if ("organization".equals(request.getParameter("exapnd")) {
SimpleModule simpleModule = new SimpleModule();
simpleModule.setMixInAnnotation(Operator.class, OperatorExpanded.class);
mapper.registerModule(simpleModule);
}
now, the mapper will take the annotations from the mixin, but invoke the method of the entity.
Upvotes: 2