Cradlpoint's developers save time and streamline communications by standardizing upon JSON:API for API endpoints
Working around data format definitions has long been an inevitability for all programmers. This work, depending on the number of people, teams, organizations, and companies involved, can quickly become a lengthy time sink as designs/opinions vie for supremacy. In order to effectively nullify the need for these conversations, as well as to present a consistent interface across services, most organizations (particularly in the web space) will standardize on a single API guideline or definition. One such specification is JSON:API; one of its many benefits is that it is particularly well documented, so it leaves little room for disagreements regarding how data should be represented. For this, as well as numerous other reasons, we at Cradlepoint are beginning to standardized upon JSON:API for our API endpoints.
At face value, converting (or adding) endpoints to serialize and deserialize datamodels true to the JSON:API specification should be about as straightforward (and bland) a programming assignment a developer could receive. There are countless number of JSON rendering and parsing tools – some are even complete with their own rich set of community developed plugins. Unfortunately, supporting this specification wasn’t as simple as adding “jsonTool.registerThing(new JsonApiPlugin())” to the handful of Java-based microservices that I work on. With respect to Java, viable tool-sets that plug into existing projects and datamodels to support the specification were conspicuously absent.
In the Java space, serializers and deserializers largely follow the same pattern. There is a one-to-one mapping between any given Type and its serializer and deserializer. Type serializers and deserializers are usually either auto-magically generated via reflection (easiest), auto-magically generated via AOP annotations (easier), or handwritten and registered (the least easy option, but still easy). When object conversion occurs, the appropriate serializer or deserializer is found for the Type and its logic is run. Further, while a conversion of a Type is taking place, when a non-primitive element is found to convert, its serializer or deserializer is then loaded and run, after which the original (or “outer”) serializer or deserializer continues its work. JSON conversion in this manner, however, is incompatible with the JSON:API specification.
In the JSON:API specification, the serialization (and deserialization) for any given Type is entirely dependent on the context of the JSON:API JSON object it is in. In order to fully illustrate, let us walk through a concrete example:
Assume you have entity Type “Foo," which looks as simple as this:
When you run a “Foo” through a JSON:API serializer, you will get JSON that will look something like this:
{
"data": {
"type": "Foo",
"id": "123",
"attributes": {
"number": 456
}
}
}
While a little more verbose than “traditional” JSON formats, the JSON created from the “Foo” is extremely straight-forward. Now, let us create a new compound entity Type “Bar,” which looks like this:
It is when serializing a compound Type like “Bar" that the complications of the JSON:API specifications begin to become apparent. At the highest level, it is because the JSON generated when a “Foo” is serialized on its own is decidedly not the same as its representation within a “Bar.” Although, in practice, the serialization of nested objects is even more nuanced than that. Within the “data” object of a JSON:API JSON object, there are four possible sub-objects: “attributes,” “relationships,” “links,” and “meta.” So when you take a “Bar,” with only the information within the POJO (Plain Old Java Object) described above, there is no way of knowing what “Foo” is to “Bar." Let us walk through the different ways a “Bar” (with populated “foo”) can be serialized depending on what “Foo” is to “Bar.” If “Foo” is an “attribute,” the resultant JSON:API JSON would look something like this:
{
"data": {
"type": "Bar",
"id": "987",
"attributes": {
"foo": {
"id": 123,
"number": 456
}
}
}
}
If “Foo” is a (HATEOAS) “link” to “Bar”, the JSON:API JSON would look something like:
{
"data": {
"type": "Bar",
"id": "987",
"links": {
"foo": "https://foo-service/api/v1/foos/123"
}
}
}
If “Foo” were just meta-data for “Bar,” the resultant JSON would look like this:
{
"data": {
"type": "Bar",
"id": "987",
"meta": {
"foo": {
"id": 123,
"number": 456
}
}
}
}
If “Foo” were, instead, a “relationship” to “Bar,” the JSON:API output would look like this:
{
"data": {
"type": "Bar",
"id": "987",
"relationships": {
"foo": {
"data": {
"id": "123",
"type": "Foo"
}
}
}
}
}
Finally, if one were to “expand” and include all of the data of “Foo” in the JSON for a “Bar,” where “foo” is still a “relationship,” the JSON would look like this:
{
"data": {
"type": "Bar",
"id": "987",
"relationships": {
"foo": {
"data": {
"id": "123",
"type": "Foo"
}
}
}
},
"included": [
{
"id": "123"
"type": "Foo",
"attributes": {
"number": 456
}
} ]
}
In each of the above examples, the serialization of just the “Foo” object in a “Bar” is highlighted. This was done to illustrate how different the serialization, even of such a simple Type, can change so drastically depending on the context in which it is being serialized. I also was hoping to point out how different each of the serializations of the “Foo” Type “foo” in a “Bar” is from the standalone serialization of a single “Foo.” This is, in essence, why the design of common serializer/deserializer tools-sets do not play particularly nicely with the JSON:API specification. You simply cannot, ad-hoc, load a type serializer and start serializing without knowing the context of the JSON object you are creating. Additionally, there is no way to infer that context simply from the Type definition itself. Because of this actuality, serializations become even more nuanced when you start introducing things like pagination/lists and compound objects containing compound objects. And then there’s the matter of deserializing JSON:API JSON, which is essentially solving these sample problems that have just been illustrated in reverse order.
With all of that said and shown, we have created a tool that puts all of these JSON:API serialization (and deserialization!) problems to rest for you! The tool is called JsonAPIary, which is open source and hosted on the Cradlepoint GitHub page. The JsonAPIary tool is a module for the Jackson JSON library and uses a set of annotations to eliminate any ambiguity in serialization/deserialization. In order to accomplish this, we have created the Type “JsonApiEnvelope<T>” within the JsonAPIary project that can take any Type in as a generic. This allows the JsonAPIary tool to then register a custom serializer and deserializer for the envelope into a Jackson ObjectMapper, which has JSON:API context management logic. Within each serializer and deserializer, the tool scans the Type definition of the “enveloped” object looking for JsonAPIary annotations that allow the entire context of the JSON:API JSON to be understood prior to serialization or deserialization.
With this, we hope the JsonAPIary project makes it as easy as is possible to support JSON:API compliant JSON from your web-service. An example of what it would take to completely support JSON:API serialization and deserialization on a single example “Person” object would be in totality:
First, register the JsonAPIary module with your Jackson ObjectMapper:
Then, annotate your “Person” Type:
That is all that needs to be done. Once you have the module registered and Type annotated, serializing a "Person" in JSON according to the JSON:API specification is as easy as this:
Another reason that we designed the JsonAPIary module to use the JsonAPIEnvelope Type's custom serializer and deserializer to house the JSON:API context logic, is because it allows Types annotated with JsonAPIary annotations to also continue to be converted in and out of "traditional" JSON with the same ObjectMapper. To do this, for example, the "person" object can simply be passed into the same ObjectMapper methods without being "wrapped" in the JsonAPIEnvelope Type.
All in all, while JSON:API can be a different, confusing, and perhaps even frustrating specification when one first begins implementation, it is certainly well within reach. The trick, at least for us when developing JsonAPIary, was to eliminate ambiguity between a Type and its properties that need to be reflected within its JSON. Once the JSON:API context management logic was able to be written, the rest of the challenges became far easier to solve. We hope that you will find this tool useful! Please feel free to create an issue in GitHub for any feedback that you have.
“Behind the Code” is a series of blog posts, written by Cradlepoint engineers, about behind-the-scenes topics and issues affecting software development.