Too Many Variations of Transfer Objects!
Look at an example to help delegate the decision about which variation to use.
Join the DZone community and get the full member experience.
Join For FreeThe life of an API. It starts with simple calls, clean transfer objects, and minimal viable products. Over time, new client and business requirements manifest themselves as variations of the same transfer object. In most cases, there are 2 variations of an object — summary and details — that are easy to rationalize. As time passes, they are followed by the mini, midi, and everything in between versions.
The amount of boilerplate code to support the variations takes time away from solving business problems. I am going to show you an example to help delegate the decision about which variation to use to the consumers of the API. Backward compatibility will be preserved as long as existing fields are not removed.
The API does not need to be as sophisticated as the Facebook Graph API or the Google API to be flexible. With a round of applause, please welcome Jackson and Javax WS. A complete working Jersey/Guice application can be found on GitHub.
The magic starts with JsonFilter annotation:
Annotation used to indicate which logical filter is to be used for filtering out properties of type (class) annotated; association made by this annotation declaring ids of filters, and com.fasterxml.jackson.databind.ObjectMapper (or objects it delegates to) providing matching filters by id.
package my.company.service.api.model;
...
import static my.company.service.api.model.Constants.FIELD_FILTER;
@JsonFilter(FIELD_FILTER)
@Value.Immutable
@Value.Style(
...
)
@JsonDeserialize(builder = TransferObject.Builder.class)
public abstract class TransferObject {
public abstract Optional<String> getCode();
public abstract Optional<String> getType();
public abstract Optional<String> getName();
...
public static class Builder extends ImmutableTransferObject.Builder {}
}
Our Jersey application will need JacksonJsonProvider to support serialization/de-serialization of the transfer objects into JSON. In our case, the provider will use a pre-defined configuration of the ObjectMapper by default.
package my.company.service.svc.config;
...
@Provider
@Consumes(MediaType.APPLICATION_JSON) // NOTE: required to support "non-standard" JSON variants
@Produces(MediaType.APPLICATION_JSON)
public class JsonProvider extends JacksonJsonProvider {
public JsonProvider() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setConfig(objectMapper.getSerializationConfig())
.setConfig(objectMapper.getDeserializationConfig())
.setSerializationInclusion(JsonInclude.Include.NON_ABSENT)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true)
.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)
.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
.registerModule(new JavaTimeModule())
.registerModule(new Jdk8Module())
.registerModule(new GuavaModule())
.findAndRegisterModules();
setMapper(objectMapper);
}
}
Now let's take a look at the code below.
package my.company.service.svc.filter;
...
import static my.company.service.api.model.Constants.FIELD_FILTER;
@Singleton
/**
* Allows to tailor the list of fields based on the list of fields specified in the request
*/
public class FieldFilteringResponseFilter implements ContainerResponseFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(FieldFilteringResponseFilter.class);
@VisibleForTesting
static final String FIELDS = "fields";
private final Map<Class, Set<String>> declaredFields =
new ConcurrentHashMap<>();
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext)
throws IOException {
Set<String> fields = getFieldsFromRequest(requestContext.getUriInfo());
// add the modifier
FieldObjectModifier modifier = new FieldObjectModifier(fields,
declaredFields,
requestContext
.getUriInfo()
.toString());
ObjectWriterInjector.set(modifier);
}
...
@VisibleForTesting
static class FieldObjectModifier extends ObjectWriterModifier {
@VisibleForTesting
Set<String> getFields() {
return fields;
}
private final Map<Class, Set<String>> declaredFields;
private final Set<String> fields;
private final String uri;
/**
* @param fields requested fields
* @param declaredFields all declared fields in the system
* @param uri the uri of the request
*/
private FieldObjectModifier(Set<String> fields, Map<Class,
Set<String>> declaredFields, String uri) {
this.fields = fields;
this.declaredFields = declaredFields;
this.uri = uri;
}
@Override
public ObjectWriter modify(EndpointConfigBase<?> endpoint,
MultivaluedMap<String, Object> responseHeaders, Object valueToWrite,
ObjectWriter objectWriter, JsonGenerator jsonGenerator) {
SimpleBeanPropertyFilter filter = getAllowAllFilter();
if (valueToWrite != null && fields != null && !fields.isEmpty()) {
Set<String> availableFields = getClassInfo(valueToWrite.getClass());
if (CollectionUtils.isNotEmpty(availableFields)) {
// filter out unknown fields
Set<String> knownFields = fields
.stream()
.filter(field -> availableFields.contains(field))
.collect(Collectors.toSet());
if (fields.size() != knownFields.size()) {
fields.removeAll(knownFields);
LOGGER.error("Unknown fields {} requested for URI {}",
fields, uri);
}
filter = new SimpleBeanPropertyFilter
.FilterExceptFilter(knownFields);
}
}
FilterProvider provider = new SimpleFilterProvider()
.addFilter(FIELD_FILTER, filter);
return objectWriter.with(provider);
}
private SimpleBeanPropertyFilter getAllowAllFilter() {
return SimpleBeanPropertyFilter
.serializeAllExcept(new HashSet<String>());
}
private Set<String> getClassInfo(Class<?> filteredClass) {
return declaredFields
.computeIfAbsent(filteredClass, newKey -> computeClassInfo(newKey));
}
private Set<String> computeClassInfo(Class filteredClass) {
BeanInfo info;
try {
info = Introspector.getBeanInfo(filteredClass);
} catch (IntrospectionException e) {
return Collections.emptySet();
}
PropertyDescriptor[] props = info.getPropertyDescriptors();
return ImmutableSet.copyOf(
Arrays.stream(props)
.map(prop -> prop.getName()).collect(Collectors.toSet()));
}
}
}
As you can see above on line 33, the ObjectWriterInjector sets an instance of ObjectWriterModifier, based on the request parameter. This allows for the customization of the ObjectWriter to keep only the fields the client requested. Behind the scenes, ObjectWriterInjector is used in the writeTo method of the ProviderBase, which is the parent class of JacksonJsonProvider.
Another benefit of this approach is that the expensive information is loaded only when requested.
package my.company.service.svc;
import static my.company.service.svc.filter.FieldFilteringResponseFilter
.getFieldsFromRequest;
...
@Path(MyService.ROOT_PATH)
public class MyServiceImpl implements MyService {
...
@Context
UriInfo uriInfo;
@Override
public TransferObject getTransferObject() {
Set<String> fields = getFieldsFromRequest(uriInfo);
if (CollectionUtils.isEmpty(fields) || fields.contains("features")) {
// TODO do heavy lifting to load the features
}
return TRANSFER_OBJECT;
}
}
The application details can be found on GitHub. Let me know your thoughts in the comments.
Opinions expressed by DZone contributors are their own.
Comments