-
GOAL:
Ease creation of feature-full HTTP API.SOLUTION:
Provide helpers to access request elements that are able to parse input into requested type and throw friendly exceptions that can be easily caught by exception handling mechanism. -
GOAL:
Allow automatic analysis of your API without analyzing code.SOLUTION:
Make everything declarative. Such declarations can be later accessed by analyzers to describe/understand the API, i.e. for OpenAPI. -
GOAL:
Reduce usage of annotations to zero.SOLUTION:
Use property delegation syntax present in Kotlin language.
Note
|
Check the implementation to see what is possible and what is not. Know the tool you use. It is not a Spring Framework, so no excuses. :) |
install(Routing) {
route("/api") {
myController(SomeDependency())
}
}
// Encapsulating handler classes in controller function
// allows simple installation of controller and passing of dependencies
fun Route.myController(
service: SomeDependency
) {
class GetMyEntity : EmptyBodyInput() {
val id: Int by path()
val requiredParam: Boolean by query()
val otherParam: Boolean by query("realParamName", default = true)
override suspend fun Ctx.respond() {
val entity = service.getEntity(id, requiredParam, otherParam)
call.respond(entity)
}
}
class Upload : Input<ByteArray>(contentType = Application.OctetStream) {
val fileName: String by header("My-File-Name")
override suspend fun Ctx.respond() {
File(fileName).writeBytes(body())
application.log.info("Finished upload of $fileName")
call.respondText("200 OK", contentType = Text.Plain)
}
}
// Routing
route("/my-entities") {
GET("/{id}") { GetMyEntity() }
.responds<MyEntity>(OK)
.errors(BadRequest)
.errors(NotFound)
}
POST("/upload") { Upload() }
.responds<String>(OK, contentType = Text.Plain)
.errors(BadRequest)
}
install(Routing) {
route("/api") {
MyController(SomeDependency()).run { register() }
}
}
// Encapsulating handler classes in controller class
// allows simple installation of controller and passing of dependencies
class MyController(
private val service: SomeDependency
) {
fun Route.register() {
route("/my-entities") {
GET("/{id}") { GetMyEntity() }
.responds<MyEntity>(OK)
.errors(BadRequest)
.errors(NotFound)
}
POST("/upload") { Upload() }
.responds<String>(OK, contentType = Text.Plain)
.errors(BadRequest)
}
inner class GetMyEntity : EmptyBodyInput() {
val id: Int by path()
val requiredParam: Boolean by query()
val otherParam: Boolean by query("realParamName", default = true)
override suspend fun Ctx.respond() {
val entity = service.getEntity(id, requiredParam, otherParam)
call.respond(entity)
}
}
inner class Upload : Input<ByteArray>(contentType = Application.OctetStream) {
val fileName: String by header("My-File-Name")
override suspend fun Ctx.respond() {
File(fileName).writeBytes(body())
application.log.info("Finished upload of $fileName")
call.respondText("200 OK", contentType = Text.Plain)
}
}
}
Since "the dawn of time" there has been the problem of applying PATCH and PUT modifications on the resource at hand. Whereas the PUT method has a well understood semantic of "entirely replacing" the target resource, the PATCH method is defined just as a partial modification. There is a number of proposals and approaches to describing this partial modification, without a single accepted standard.
Ktor Controllers follow semantics defined by RFC 7396 - JSON Merge Patch. However, this has to be taken with a grain of salt as type system imposes some constraints which are not considered by this rfc because it is defined on generic JSON.
Implementing a PATCH poses additional problem, unlike a PUT, missing values cannot be
treated as null
- we want to clear a value only if explicitly stated in PATCH request.
This is problematic as type system actually uses null
to indicate a missing value.
We would need a null
of null
kind of concept, which unfortunately is not there.
Thus, for every updated property we need to somehow check if it is present in the request.
All this with PUT requests still using null
for missing values.
Ktor Controllers use delegates for patch properties and delegates can hold the information whether a property
was passed or not. We can skip missing property, throw or just use null
if acceptable.
You can describe(remember that we want to be declarative) your PATCH
and PUT
with generic
PatchOf
base class. It provides you with patchOf
generic delegate builder and functions
to modify your target resource object:
-
patch
- modifies object in-place with PATCH semantics -
patched
- returns a copy of object modified with PATCH semantics -
update
- modifies object in-place with PUT semantics -
updated
- returns a copy of object modified with PUT semantics
Note
|
patch and update require all delegates to target mutable properties - defined with var .
|
data class User(
val id: Long,
val login: String,
val name: String,
val age: Int
)
class UserPatch : PatchOf<User>() {
val name by patchOf(User::name)
val age by patchOf(User::age)
}
class UpdateUser : Input<UserPatch>() {
val id: Long by path()
override suspend fun Ctx.respond() {
val patch: UserPatch = body()
val user = service.getUser(id)
service.save(patch.patched(user))
call.respond(NoContent, EmptyContent)
}
}
PATCH("/users/{id}") { UpdateUser() }
.responds<EmptyContent>(NoContent)
.errors(BadRequest)
Warning
|
Missing values are implicitly considered a bad input and cause a subtype of BadRequestException
to be thrown.
|
If you have some custom logic to apply during patch/update or special fields in PATCH
/PUT
request, you can add them with normal code as PatchOf
is open to override:
class CustomerPatch : PatchOf<Customer>() {
var name by patchOf(Customer::name)
var age by patchOf(Customer::age)
var clearAddress : Boolean = false // special field
override fun patch(obj: Customer) {
super.patch(obj)
if(clearAddress) {
obj.addressLine1 = null
obj.addressLine2 = null
obj.addressLine3 = null
}
}
// The same for `patched`, `update` and `updated`...
}
There are a lot of possibilities of how you can place properties in your class(to be patched).
Inside or outside of primary constructor, var
or val
, as a member or extension, etc.
Unfortunately, being able to "automatically" apply modifications to your target object comes with some limitations:
-
You cannot define delegated properties in primary constructor - this is actually a Kotlin’s limitation
-
Since delegates need to be updated after object initialization, they must be defined as mutable -
var
-
update
andpatch
modify patched object and require all delegates to be mapped to mutable properties -var
-
updated
andpatched
return copy and thus need some "copy constructor" - they usecopy
function of data classes as it is the only well defined and standard way of copying objects - in resultupdated
andpatched
only work for data classes-
Additionally you cannot have delegates targeting properties outside of primary constructor because they would not be included in the
copy
function (it may be possible to improve the implementation to work around this limitation)
-
-
In case of nested structures, limitations of your deserializer apply
-
In case of nested patches, if patched object has
null
in target field then new instance needs to be created:-
Instantiated type needs a primary constructor
-
Patch class of instantiated type cannot have delegates outside of primary constructor (it may be possible to improve the implementation to work around this limitation)
-
Patch class of instantiated type must have delegates for all non-optional parameters of primary constructor
-
Fortunately, all of these can be verified and an exception is thrown if an illegal structure is detected(see tests). Unfortunately, most of them only at runtime. Therefore, make sure to write tests for your patch classes.
In general, PatchOf
should cover most of the reasonable use cases. If you are the unfortunate one,
share your use case and we will see what can be done.
When I started writing Ktor Controllers I wasn’t sure if I will be able to achieve the goal I set
for myself - it was an experiment on Kotlin generics, delegates and empowering the compiler.
There was a lot of going back and forth, rewriting, thinking… At some point, I almost gave in
thinking that this is not going to work(especially PatchOf
).
I did not achieve the elegance I set out for. At the time of writing, there were some limitations
of generics and Kotlin compiler that could not be worked around - or had to be worked around which
contributed unwanted complexity. As an example: given generic type T: Any
you can make it nullable
with T?
, however, the other way around is not possible - you cannot make T: Any?
non-nullable
with something like T!!
, even if you are fine with NPE. There is so much more information that
compiler could possibly infer from the code and warn you about.
However, considering current capabilities of Kotlin language and compiler, I am satisfied. I am certainly going with it to production. I love the readability and control I have over what is going on. There are no annotations, what I read is what is executed. This is general design decision behind Ktor, I believe.
Set of additional features included in this library.
This is CallId feature with predefined configuration.
This:
install(UUIDCallId)
Is equivalent to:
install(CallId) {
header(HttpHeaders.XCorrelationId)
generate { UUID.randomUUID().toString() }
verify { it.isNotBlank() }
}
The actual header used can be configured.
Note
|
UUIDCallId.key === CallId.key
|