Search code examples
spring-bootkotlinapi-versioning

SpringBoot/Kotlin and Versioning through Content Negotiation: correct approach?


I have been experimenting with Content Negotiation as backend versioning for my SpringBoot/Kotlin application. I have the following:

 @GetMapping("/user", produces = [MediaType.APPLICATION_JSON_VALUE])
      fun getUsers() {
        //some code here
      }

I have found this project combining accept" header and a "Accept-Version" custom header. I wonder whether this is the correct way of implementing a content negotiation approach and if not how can I fix it?

@GetMapping("/user", produces = [MediaType.APPLICATION_JSON_VALUE], headers = ["Accept-Version=$CUSTOM_ACCEPT_HEADER"])
          fun getUsers() {
            //some code here
          }

object VersioningUtility {
  const val CUSTOM_ACCEPT_HEADER = "vnd.sample.com-v1+json"
  //here more constants as each controller can be versioned independently
}

Thank you


Solution

  • Yes, you can implement API versioning using content negotiation by having a custom header and header value as you have specified. However, since that is not a standard header, there are other scenarios which you might have to handle by yourself, such as:

    • default representation when the header is not present
    • exception scenarios when invalid media type values are passed as part of the header.

    In case you are working with only json responses, the JSON API standard for content negotiation is to send the Accept header with the value application/vnd.api+json. Since Accept is a standard request header, using that is preferred. In case you need to handle other types of responses, you can still go ahead with the custom header.

    You can implement content negotiation as below:

    @RestController
    class UserController {
    
        @GetMapping("/users", headers = ["Accept=${VersioningUtility.VERSION_1_HEADER}"])
        fun getUser(): ResponseEntity<Any> {
            return ResponseEntity(listOf(User("Abraham Lincoln")), HttpStatus.OK)
        }
    
        @GetMapping("/users", headers = ["Accept=${VersioningUtility.VERSION_2_HEADER}"])
        fun getNewUser(): ResponseEntity<Any> {
            return ResponseEntity(listOf(NewUser(Name("Abraham", "Lincoln"))), HttpStatus.OK)
        }
    }
    
    data class User(val name: String)
    data class NewUser(val name: Name)
    data class Name(val firstName: String, val lastName: String)
    
    object VersioningUtility {
        const val VERSION_1_HEADER = "application/vnd.v1+json"
        const val VERSION_2_HEADER = "application/vnd.v2+json"
    }
    

    The above with enable you to have 2 versions of the GET /users endpoint with the Accept header.

    When the curl request is made with v1 of the header value, the response would be according to the version v1

    curl -L -X GET 'http://localhost:8080/users' \
    -H 'Accept: application/vnd.v1+json'
    
    [
        {
            "name": "Abraham Lincoln"
        }
    ]
    

    When the curl request is made with v2 of the header value, the response would be according to the version v2

    curl -L -X GET 'http://localhost:8080/users' \
    -H 'Accept: application/vnd.v2+json'
    
    [
        {
            "name": {
                "firstName": "Abraham",
                "lastName": "Lincoln"
            }
        }
    ]
    

    When an invalid header value is sent, it would respond with a 406 Not Acceptable

    curl -L -X GET 'http://localhost:8080/users' \
    -H 'Accept: application/vnd.abc+json'
    
    {
        "timestamp": "2020-04-01T18:33:16.393+0000",
        "status": 406,
        "error": "Not Acceptable",
        "message": "Could not find acceptable representation",
        "path": "/users"
    }
    

    When no Accept header is sent, it would respond with the default version, ie v1 here

    curl -L -X GET 'http://localhost:8080/users'
    
    [
        {
            "name": "Abraham Lincoln"
        }
    ]
    

    Even GitHub has implemented versioning with content negotiation in a similar way and you can have a look at that in their documentation.