JSON:API is a widely used specification for modeling client–server API’s. It’s built on top of the JSON notation standard, to make sharing objects with relationships and all other relevant information such as errors, links, and meta data standardized.
However, in Kotlin, it has been poorly covered by libraries that would make the use of JSON:API easier for the developer.
When we set out on this project, we believed that the Kotlin community was missing a fresh, easy-to-use and Kotlin-first JSON:API implementation.
While considering how to make the usage as simple as possible, we decided to go with the annotation processor and code generation approach. The goal was to come up with a set of annotations that would make the bridge between Kotlin classes and JSON:API, without any additional effort.
As will be shown in this article, our JsonApiX library has achieved just that.
General info
JsonApiX is an annotation processor library made on top of the Kotlinx serialization, to make implementing JSON:API specification much easier. With the use of just a few annotations, it generates JSON:API wrappers for the Kotlin classes.
Wrappers are modeled in a way to follow the JSON:API specification format, using the annotated class’s properties to model the data and relationships part. They are serializable, so parsing in both directions is supported.
The library also has a retrofit module, with a converter to support the API implementations.
It was built for Kotlin, so it can be used on all Kotlin based projects such as Android apps and Kotlin multiplatform apps.
Basic usage
Let’s take the example of a simple class called Book
.
@JsonApiX(type = "book")
@Serializable
data class Book(
val name: String,
val pageCount: Int
)
Book
has two simple parameters: name
and pageCount
.
Every class annotated with @JsonApiX
needs to also be annotated with @Serializable
, because JsonApiX is leveraging the Kotlinx serialization to serialize and deserialize classes.
@JsonApiX
takes a type
parameter, which is one of the mandatory JSON:API parameters, and its value will be used as the value for the key type
in the output.
When we annotate a class with the @JsonApiX
annotation, the processor will generate the implementations of a number of interfaces, based on the annotated class. They are made to convert it to the JSON:API format and offer a way to parse it.
Two most important interfaces are shown below.
interface JsonApiX<out Model> {
val data: ResourceObject<Model>?
val included: List<ResourceObject<*>>?
val errors: List<Error>?
val links: Links?
val meta: Meta?
}
JsonApiX
interface is a root-level wrapper for a class. Data property wraps the primitive attributes of the original class and relationship references. Included array contains the data of the relationships if any are present. Other properties of this interface are optional fields from the JSON:API specification. Every generated implementation is serializable, allowing the serialization and deserialization of all the parameters from the specification.
Along with the wrappers, the library also generates helper functions and extensions to support the serialization/deserialization processes. Aiming to prevent the developers from having to know and use them, we’ve come up with the idea of an adapter. For each annotated class, an adapter implementation will be generated, as a composition of all the operations needed in the serialization/deserialization process.
Here is the interface we use for the adapter implementations with its most important methods.
interface TypeAdapter<Model> {
fun convertFromString(input: String): Model
fun convertToString(input: Model): String
}
To get the type adapter for your specific class, use the generated TypeAdapterFactory class.
// Gets adapter for a single instance of Book
val adapter = TypeAdapterFactory().getAdapter(Book::class)
// Gets adapter for a list of Book instances
val listAdapter = TypeAdapterFactory().getListAdapter(Book::class)
adapter.convertToString(book) // Produces JSON API String from a Book instance
adapter.convertFromString(input) // Produces Book instance from JSON:API String
listAdapter.convertToString(books) // Produces JSON:API String from a Book list
listAdapter.convertFromString(input) // Produces Book list from JSON:API String
Now, let’s extend our model and take a look at how to handle relationships in our classes.
@JsonApiX(type = "book")
@Serializable
data class Book(
val name: String,
val pageCount: Int,
@HasOne(type = "author")
val author: Author,
@HasMany(type = "character")
val characters: List<Character>
)
Our Book now has an author and a list of characters. Both of these attributes are classes themselves, so they need to be treated as relationships.
Important note: Relationship models need to also be annotated with both @JsonApiX and @Serializable. The type parameter in @JsonApiX of the relationship model needs to match the one in @HasOne/@HasMany annotations.
Generated wrappers and adapters will now account for the relationships in serialization and deserialization. No additional work is needed.
Important note: When deserializing relationships, JsonApiX relies on the included array from the JSON:API specification. If the relationship is non-nullable and missing from the included array, a JsonApiXMissingArgumentException will be thrown.
Nullability
Our Book model looks great now, right? However, do all books necessarily have characters? I don’t think so, and I’m speaking from personal experience – as a person who read a couple of physics books while studying.
Let’s make a small change in our Book model and make the characters list nullable.
@JsonApiX(type = "book")
@Serializable
data class Book(
val name: String,
val pageCount: Int,
@HasOne(type = "author")
val author: Author,
@HasMany(type = "character")
val characters: List<Character>?
)
As I mentioned before, all non-nullable relationships must be present in the included array of the JSON:API input. Otherwise, the deserialization process will fail.
However, nullable relationships do not suffer the same fate. They will simply be assigned to null
if the input is missing their data.
We’ve made our deserialization process with nullable properties in mind, so it’s safe to have nullable attributes, both primitive and relationships. The JSON:API input can be deserialized without their values.
For example, the following JSON:API input:
{
"data":{
"type":"book",
"id":"1",
"attributes":{
"name":"The Fellowship Of The Ring",
"pageCount":432
},
"relationships":{
"author":{
"data":{
"type":"author",
"id":"4"
}
}
},
"included":[
{
"type":"author",
"id":"4",
"attributes":{
"name":"J.R.R. Tolkien"
}
}
]
}
}
Will be successfully parsed to a Book instance with characters list assigned to null
.
Errors
According to the JSON:API specification, each object should have an optional errors array. The following interface is used as a root-level wrapper for JSON:API responses. It contains a nullable errors list.
interface JsonApiX<out Model> {
val data: ResourceObject<Model>?
val included: List<ResourceObject<*>>?
val errors: List<Error>?
val links: Links?
val meta: Meta?
}
A single error is modeled to wrap the most common arguments of an error. In the future, we plan to extend the error model with some optional fields, and give the developers an ability to model their custom errors.
class Error(
val code: String,
val title: String,
val detail: String,
val status: String
)
When using Retrofit in the event of a network error, a HttpException
will be thrown. To extract the Error
model from a response, you can use the HttpException.asJsonXHttpException()
extension, which will then return a JsonXHttpException
, containing the original response as well as the errors list.
try {
val book = io { sampleApiService.fetchBook() }
} catch (exception: HttpException) {
val errors = exception.asJsonXHttpException().errors
// Handle errors
}
Advanced usage
JSON:API specification includes resources that are not necessarily a part of the original models. For that reason, JsonApiX provides a way to retrieve the links and meta values from the JSON:API input without including those fields in your model. To achieve this, your model needs to extend the JsonApiModel
abstract class.
@JsonApiX(type = "book")
@Serializable
data class Book(
val name: String,
val pageCount: Int,
@HasOne(type = "author")
val author: Author,
@HasMany(type = "character")
val characters: List<Character>?
) : JsonApiModel()
JsonApiModel
is an abstract class which will provide you with getters and setters for links and meta objects.
Links
JsonApiX currently supports retrieving links from the root object, relationships and resource object(data
key) from the JSON:API specification. By default, the links model has the following implementation.
class DefaultLinks(
val self: String? = null,
val related: String? = null,
val first: String? = null,
val last: String? = null,
val next: String? = null,
val prev: String? = null
) : Links
And they can be retrieved in the following way.
// Get root level links
person.rootLinks()
// Get relationships links
person.relationshipsLinks()
// Get resource object links
person.resourceLinks()
Custom links
Developers can define their own custom link models to adapt to their specific requirements. Let’s take this custom Book links model as an example. Every custom links model must extend the Links
interface and have a JsonApiXLinks
annotation.
@Serializable
@JsonApiXLinks(type = "book", placementStrategy = LinksPlacementStrategy.ROOT)
data class BookLinks(
val authorBioLink: String,
val bookStoreLink: String
)
In this example, the annotation processor will automatically make the root-links type of a Book
class to be BookLinks.
Developers need to make sure that the type
parameter value in JsonApiXLinks
matches the one in the JsonApiX
annotation of the original model.
To retrieve them, a generic variant of the rootLinks()
method is used.
// Gets root level links as a BookLinks instance
person.rootLinks<BookLinks>()
LinksPlacementStartegy
enum is used to determine which links from the whole JSON:API object will be replaced by a custom model. It currently supports ROOT
, RELATIONSHIPS
and DATA
links.
Meta object
In JSON:API specification, meta is an optional object. Unlike links, it doesn’t have a predefined default model. For that reason, in order to use the meta feature, the developer must define a custom meta model for each class with which he wants to use it.
Let’s take the BookMeta
model as an example. Every custom meta model must extend the Meta
interface and have a JsonApiXMeta
annotation, with its type parameter matching the one from the JsonApiX
annotation of the original model.
@Serializable
@JsonApiXMeta(type = "book")
data class BookMeta(
val publisherName: String
) : Meta
In this example, the annotation processor will automatically make the meta type of a Book
class to be BookMeta
. To retrieve a meta object, a generic variant of the meta()
method is used.
// Gets the meta object
book.meta<BookMeta>()
Retrofit
To enable the Retrofit integration, JsonApiX generates the converter implementation which can be added to the retrofit builder. It takes an instance of TypeAdapterFactory
as a parameter.
Retrofit.Builder()
.addConverterFactory(JsonXConverterFactory(TypeAdapterFactory()))
.baseUrl("https://www.example.com")
.build()
JsonXConverterFactory
converts the input all the way down (or up) to the original model, so there is no need for any additional wrappers in the API service definition.
interface SampleApiService {
@GET("/book")
fun fetchBook(): Book
@POST("/create-book")
fun postNewBook(@Body book: Book)
}
Annotations summary table
Annotation | Target | Interface to extend | Description |
---|---|---|---|
@JsonApiX | Class | None | Generates JSON:API wrappers and adapters for the target class |
@HasOne | Class field | None | Indicates that a class field is a one-relationship |
@HasMany | Class field | None | Indicates that a class field is a many-relationship |
@JsonApiXLinks | Class | Links | Indicates that a target class should be used as a links model for its owner model |
@JsonApiXMeta | Class | Meta | Indicates that a target class should be used as a meta model for its owner model |
You’ll go a long way, library
Making this library was a long and exciting journey for me and my colleagues. We had to overcome many challenges, and I can’t even count how many times I was busting my head for hours, even days, over some parsing issue.
We give great thanks to the creators of Kotlinx serialization and Kotlin poet, because our work is heavily based on the features of those libraries. However, the journey is still not over, it’s rather just starting. And we have big plans for the future of the library.
Some of the most exciting upgrades we are planning to do are:
- Kotlin symbol processing support
- Expanding the
JsonApiModel
API with more customizations and data - Custom error models
- Many more…
We welcome your contributions, so we’ve decided to make this library open-source! Feel free to peek through the code and open pull request.