Open bitspittle opened 11 months ago
One thing that would help make a good decision here is know the sorts of code that people are writing in their backends.
For example, my "todo" example's project has an API route like this, which is pretty simple:
@Api
fun addTodo(ctx: ApiContext) {
if (ctx.req.method != HttpMethod.POST) return
val ownerId = ctx.req.params["owner"]
val todo = ctx.req.params["todo"]
if (ownerId == null || todo == null) {
return
}
ctx.data.getValue<TodoStore>().add(ownerId, todo)
ctx.res.status = 200
}
Exceptions should automatically get logged and result in an error status code (but I'd have to double check)
I see two attractive but very different approaches.
Stick close to the current implementation, but add nice helpers to reduce boilerplate. Your example with handleGet
is very cool.
To build on it: let's assume upstream code is prepared to handle exceptions like NotFoundException
to produce correct status code. In this case we solved 2 out of 3 issues: supported http method (405) and internal errors (500). One thing left is to support required parameters (400). This can be done relatively simply - by providing a method that would do the check and throw exception:
fun Request.requiredParam(name: String): String {
return params[name] ?: throw BadRequestException("Missing mandatory parameter '$name'")
}
Then user's route function could look like this:
@Api
suspend fun getBook(context: ApiContext) = context.handleGet {
val bookId = context.req.requiredParam("bookId").toLong()
val book = getBook(bookId) ?: throw NotFoundExceptin()
context.res.setBodyText(Json.encodeToString(book))
}
requiredParam
could support type conversion, but it would be a bit more complicated, I was exploring in this direction:
inline fun <reified T> Request.requiredParam(name: String): T {
val value = params[name] ?: throw BadRequestException("Missing mandatory parameter '$name'")
return when (T::class) {
String::class -> value as T
Int::class -> value.toInt() as T
Long::class -> value.toLong() as T
...
else -> throw Exception("Type ${T::class.simpleName} not supported")
}
}
Using function signature plus annotations to parse information about params return types etc in compile time (or on start up with reflections), instead of at run time when handling requests like with current approach. I personally like this approach, but it has it's opponents. All in all, if all can be solved with 1st approach, it's probably much easier to do so. The reason why I'm even writing about it is that it might make related idea (below) easier to implement.
Regardless of server side handling, there is cool potential to make developers life a lot easier on the client side. This is heavily inspired by Feign, which automatically generates http client based on api definition (interfaces with annotated methods). The argument for this approach is the following: Since Kobweb aims to provide quick and convenient server-and-client framework, it would be cool to provide seamless integration between client and server. We have big advantage of having both in Kotlin and in the same gradle project, so potentially implementation wouldn't be overwhelming. Okay, but what do I mean? Let's imagine the following client (js) code:
// jsMain module
@Composable
fun BookComponent(bookId: Long) {
val scope = rememberCoroutineScope()
var book: BookDTO? by remember { mutableStateOf(null) }
scope.launch {
book = window.apis.bookApis.getBook(bookId)
}
...
}
getBook
method is generated automatically based on API definition in jvmMain
. It's typesafe so you don't have to worry about missing parameters, misspelled paths, heck you don't have to worry about anything regarding network layer. As a user you just call a method and you don't care if it uses REST, RPC or whatever else.
I'm happy to hear (or see) your thoughts on this
To expand what I wrote in Current implementations plus syntactic sugar
section: Here is my complete MVP of generalizing boilerplate:
@Api
suspend fun getBook(context: ApiContext) = context.handle(GET) {
val bookId: Long = context.req.requiredParam("bookId").toLong()
val book = getBook(bookId) ?: throw NotFoundException()
context.res.setBodyText(Json.encodeToString(book))
}
private fun getBook(id: Long): BookDTO? = transaction {
val chapters = ChapterEntity.find { Chapters.bookId eq id }.toList()
BookEntity.findById(id)?.toDTO(chapters)
}
fun Request.requiredParam(name: String): String {
return params[name] ?: throw BadRequestException("Missing mandatory parameter '$name'")
}
fun ApiContext.handle(method: HttpMethod, callBack: (ApiContext) -> Unit) {
if (req.method != method) {
res.status = 405
return
}
try {
callBack(this)
} catch (e: Exception) {
when (e) {
is NotFoundException -> {
res.status = 404
res.setBodyText(e.message ?: "Resource not found")
}
is BadRequestException -> {
res.status = 400
res.setBodyText(e.message ?: "Bad request")
}
else -> {
res.status = 500
res.setBodyText(e.message ?: "Internal server error")
}
}
}
}
class NotFoundException(message: String? = null) : Exception(message)
class BadRequestException(message: String? = null) : Exception(message)
Functions: Request.requiredParam
, ApiContext.handle
and exception classes could be part of Kobweb.
One thing is not ideal yet in my example - when user sends ?bookId=qwe
, the server responds with 500 on parsing string to long, ideally we would have out of the box of handling it with 400 (like with type aware Request.requiredParam
From a comment on my Discord:
I have simple GET request returning book for given id, in Spring Boot I would do something like this:
In Kobweb (from what I understand) there is no validation for allowed methods, required params or exception handling (returning 400 or 500). so my code looks like this:
Which seems like tons of boilerplate code (I immediately try to wrap this into custom generic solution, but I wonder why it's not in place already).
So far, Kobweb API routes were inspired by NextJS API routes. They are indeed somewhat low-level, in the sense that it still leaves things up to the user to determine how to respond to an API endpoint in a fairly flexible way.
For example, you could potentially have a single endpoint handle get, post, and delete verbs if you want.
Still, as Kobweb wants to be an opinionated framework in order to reduce boilerplate that most users don't care about, we could probably do better.
A few possible ways forward here that I can think of (code all open for discussion):
Add Some utility methods to the Kobweb backend APIs
e.g. Extend
ApiContext
withhandleGet
,handlePost
, etc. methodsAdd / tweak new annotations into the Kobweb backend APIs
Not too familiar with
@RequestParam
but let's just assume the Kobweb Gradle plugins can figure this out:Leave Kobweb flexible and low-ish level and add an extension library that layers improvements on top