Introduction

Your API documentation is the ‘curbside appeal’ of your product. From a tech point of view, the first judgment of your services comes from how well you can tell others how to integrate with them. The ability to be descriptive with your responses and payloads significantly helps. When building out our service – which manages Identity across our platform – we ran into an issue.  Our services are written in Kotlin/JVM + Spring, and in an attempt to cut down on code repetition I'd decided to make one of our response classes contain a value of List<Any>. If you want your OpenAPI spec to be auto-generated and inferred from your classes, this leaves you with an issue: your OpenAPI spec would then detail a list of empty objects, rather than your model schema.  We wanted to see if we could use the JsonView package from Jackson to help us do a better job.

Jackson annotations and the value of JsonViews

What we want to achieve

We will have 3 endpoints that all return a list of Users, but each with a different set of keys for each User. They are as follows:

  • GET admin/accounts/{account_id}/users - returns a list of users with all user fields and can only be called by an admin
  • GET accounts/{account_id/users - returns a list of users but with only fields required by our web application.
  • GET accounts/{account_id}/user-name - returns a list of users with only the user’s names

We want to create an easily replicable pattern for choosing which fields are returned from a data class depending on the endpoint that is calling it.  This will reduce code repetition and complexity throughout our application.

How we intend to achieve it using JsonView

Jackson has a JsonView package which allows you to serialize/deserialize and customize the views of objects and integrates with Spring nicely.

My thought process was as follows:

  • We will have 3 views.  An AdminView will display all of the information about a user and a StandardView will show everything but the created_by field of a user. Finally, a NameView will only return the name of each user.
  • We will have a single service function, and repository function to gather the users from the database
  • We will use the @JsonView annotation to determine which fields get returned from the User data class with each endpoint

The value of JsonView

@JsonView is valuable as you now have a single class which will respond with different things depending on the endpoint that calls it. Without the JsonView, you’d have to create:

  • 3 separate response classes. One which contains the full User information, another which contains a slimmed down User (without the created_by field) and then a final one which only returns the User’s names.
  • 3 separate service methods, 1 for each endpoint to call and return the correct response class.
  • Each of those methods would need tests.
  • If you made changes to the User model, like adding a new field, you would then need to update all 3 classes.

Implementing JsonViews

Implementing JsonViews is relatively simple. The following tasks are necessary:

  • Creating a Bean
  • Setting the Views
  • Assigning Views to Methods in your Controller
  • Decorating keys in your response classes with your View

AppConfig and Bean creation: What is a Bean?

The Spring Inversion of Control container is a core component of Spring built for managing the configuration and lifecycle of Beans. The Bean in the example below is known as a ‘factory method’, which is a method annotated with the @Bean annotation within a @Configuration annotated class. This setup is known as the BeanFactory. Once a Bean is instantiated within a factory the rest of your application does not need to know how to create the object it returns and therefore the functionality of that Bean can be injected where you need it in your application.

Out of the box, an application created by the start.spring.io wizard doesn’t come with a BeanFactory for the WebMvcConfigurer, so creating one to manage configuration globally within your application is a good idea.  Here is an example of one:

package com.complyadvantage.example.config

import com.fasterxml.jackson.databind.MapperFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestTemplate
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class AppConfig : WebMvcConfigurer {

   @Bean
   fun restTemplate(): RestTemplate {
       return RestTemplate()
   }

   @Bean
   fun objectMapper(): ObjectMapper {
       return JsonMapper.builder()
           .addModule(KotlinModule.Builder().build())
           .enable(MapperFeature.DEFAULT_VIEW_INCLUSION)
           .build()
   }

}

The BeanFactory above contains 2 Beans.  The first is importing RestTemplate - which is necessary for an application if it intends to interact with external HTTP services.  This is not relevant as part of the JsonView work - it is there to give an example of how the BeanFactory can have multiple Beans. However, the second Bean is to create a custom ObjectMapper that is used by Spring Boot to serialize and deserialize JSON data for incoming and outgoing requests which is necessary for the JsonView work.

fun objectMapper(): ObjectMapper {
       return JsonMapper.builder()
           .addModule(KotlinModule.Builder().build())
           .enable(MapperFeature.DEFAULT_VIEW_INCLUSION)
           .build()
   }
  • KotlinModule is there to handle Kotlin-specific data structures and behaviours like default parameter values and null-safety in Kotlin data classes.
  • We enable MapperFeature.DEFAULT_VIEW_INCLUSION to allow serialization of properties without @JsonView. It means all properties of an object by default will be included.

Views

the

Creating a View is simple. Here we create an AdminView. This is what's going to be used to only show fields within the response if they are decorated with the AdminView. I have also created a StandardView, which will be used to define the fields returned when not calling an admin endpoint.

package com.complyadvantage.example.models.views

class Views {
        interface AdminView
        interface StandardView
        interface NameView
}

Assigning Views to Methods in the Controller

You assign a View to a controller method as follows:

@JsonView(Views.AdminView::class)

It is possible to add multiple Views to the single method by annotating the method that handles the API endpoint within your controller:

@JsonView(Views.AdminView::class,Views.StandardView::class)

In our case, within the AccountController we would be adding the AdminView to the endpoint where we need to see all information on each User.  We will apply the StandardView which has a slightly limited response for each User and finally the NameView to the user-name endpoint.

package com.complyadvantage.example.http.controller


class AccountController(var accountService: AccountService, var userService: UserService) {

    @GetMapping(value = ["/admin/accounts/{account_id}/users"],produces = [MediaType.APPLICATION_JSON_VALUE])
    @JsonView(Views.AdminView::class)
    fun getAccountUsers(
      @PathVariable(name = "account_id")
      accountId: UUID,
      ): ResponseEntity<List[User]> {
        return userService.getUsersByAccountIdentifier(
            accountId = accountId,
          )
    }
    
   	@GetMapping(value = ["/accounts/{account_id}/users"],produces = [MediaType.APPLICATION_JSON_VALUE])
    	@JsonView(Views.StandardView::class)
      fun getAccountUsers(
        @PathVariable(name = "account_id")
        accountId: UUID,
      ): ResponseEntity<List[User]> {
        return userService.getUsersByAccountIdentifier(
            accountId = accountId,
          )
      }

    @GetMapping(value = ["/accounts/{account_id}/user-name"],produces = [MediaType.APPLICATION_JSON_VALUE])
    	@JsonView(Views.NameView::class)
      fun getAccountUsers(
        @PathVariable(name = "account_id")
        accountId: UUID,
      ): ResponseEntity<List[User]> {
        return userService.getUsersByAccountIdentifier(
            accountId = accountId,
          )
      }
}

In the code above we have defined our 3 endpoints:

  • The first is the admin request in which the response should return all of the fields applied to a User.  So this will return all of the fields either annotated with AdminView, or not annotated at all.
  • The second is the StandardView, in which the response should return all the fields apart from the created_by field.  This will return all of the fields either annotated with StandardView, or not annotated at all.
  • The final endpoint only returns a list of each User’s name. As the other 2 fields of a User are annotated, it will only return the fields which are not annotated at all.

Defining which fields are visible in the response for each View


Below you will see the data class for a User from which a list of these is created and returned in each of the endpoints above. 

data class User(
val identifier: UUID,
val name: String,
val created_by: UUID
)

We now need to apply the correct JsonView annotations to each field to make sure only the appropriate fields are returned for each endpoint.

data class User(
	@field:JsonView(Views.AdminView::class, Views.StandardView::class)
    val identifier: UUID,

	@field:JsonView(Views.AdminView::class, Views.StandardView::class, Views.NameView::class)
    val name: String,

    @field:JsonView(Views.AdminView::class)
    val created_by: UUID
)

Every field here is annotated with a view. You may note that AdminView is used on every field. Fields without any annotation will be returned on every view due to the MapperFeature.DEFAULT_VIEW_INCLUSION configuration we have enabled.

Responses

Here are some example responses for each endpoint:

GET admin/accounts/{account_id}/users

[
    {
        "identifier": "59314969-d811-40dd-8d93-f5381ff70bbe",
        "name": "John Snow",
        "created_by": "69f9c7eb-a2eb-4b6a-a68d-bc9b89ecb4e2"
    },
    {
        "identifier": "42bc63e7-fc0a-4a0d-a497-5db81aca4639",
        "name": "Sam Jones",
        "created_by": "615ed5d6-7ffa-4924-ba68-69bacb234811"
    },
    {
        "identifier": "8154f380-3fb8-4f8e-a49b-b0f670688e25",
        "name": "Sarah Hill",
        "created_by": "615ed5d6-7ffa-4924-ba68-69bacb234811"
    }
]

GET accounts/{account_id}/users

[
    {
        "identifier": "59314969-d811-40dd-8d93-f5381ff70bbe",
        "name": "John Snow"
    },
    {
        "identifier": "42bc63e7-fc0a-4a0d-a497-5db81aca4639",
        "name": "Sam Jones"
    },
    {
        "identifier": "8154f380-3fb8-4f8e-a49b-b0f670688e25",
        "name": "Sarah Hill"
    }
]

GET accounts/{account_id}/users-name

[
    {
        "name": "John Snow"
    },
    {
        "name": "Sam Jones"
    },
    {
        "name": "Sarah Hill"
    }
]

Outcome

We now have the basis for expanding Views across our API - minimising code repetition and allowing for an accurate OpenAPI spec that’s detailed and changed dynamically based on the View. As our codebase grows with our endpoint offering, these changes will matter more and should reduce errors within our services. We have avoided a lot of code repetition between the creation of User response classes and service functions unique to each endpoint.  This also means avoiding writing 3 times the amount of tests than what is strictly necessary.  We have avoided the use of List<Any> which is not only a little precarious/ill-advised within a strongly typed language such as Kotlin, but also made a mess of our OpenAPI specification. 

Appendix: The Final OpenAPI Specification

openapi: 3.0.1
info:
  title: ComplyAdvantage JsonView Example spec
  version: v0

paths:
    /accounts/{account_id}/user-name:
      get:
        operationId: getAccountUserName
        parameters:
        - in: path
          name: account_id
          required: true
          schema:
            type: string
        responses:
          "200":
            content:
              '*/*':
                schema:
                  type: array
                  items:
                    $ref: "#/components/schemas/User_NameView"
            description: OK
        tags:
        - Accounts
    /accounts/{account_id}/users:
      get:
        operationId: getAccountUsers
        parameters:
        - in: path
          name: account_id
          required: true
          schema:
            type: string
        responses:
          "200":
            content:
              '*/*':
                schema:
                  type: array
                  items:
                    $ref: "#/components/schemas/User_StandardView"
            description: OK
        tags:
        - Accounts
    /admin/accounts/{account_id}/users:
      get:
        operationId: getAccountUsersAdmin
        parameters:
        - in: path
          name: account_id
          required: true
          schema:
            type: string
        responses:
          "200":
            content:
              '*/*':
                schema:
                  type: array
                  items:
                    $ref: "#/components/schemas/User_AdminView"
            description: OK
        tags:
        - Accounts
components:
  schemas:
    User_AdminView:
      type: object
      properties:
        created_by:
          type: string
          format: uuid
        identifier:
          type: string
          format: uuid
        name:
          type: string
      required:
      - created_by
      - identifier
      - name
    User_NameView:
      type: object
      properties:
        name:
          type: string
      required:
      - name
    User_StandardView:
      type: object
      properties:
        identifier:
          type: string
          format: uuid
        name:
          type: string
      required:
      - identifier
      - name

Author: Rob Loustau