varabyte / kobweb

A modern framework for full stack web apps in Kotlin, built upon Compose HTML
https://kobweb.varabyte.com
Apache License 2.0
1.53k stars 68 forks source link

Add opinionated solution to API routes to reduce boilerplate. #409

Open bitspittle opened 9 months ago

bitspittle commented 9 months ago

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:

@GetMapping
fun getBook(@RequestParam bookId: Long): BookDTO {
    return getBook(bookId) ?: throw NotFoundException()
}

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:

@Api
suspend fun getBook(context: ApiContext) {
    try {
        if (context.req.method != HttpMethod.GET) {
            context.res.status = 405
            return
        }

        val bookId = context.req.params["bookId"]?.toLong()
        if (bookId == null) {
            context.res.status = 400
            context.res.setBodyText("Required param 'bookId' is missing")
            return
        }

        val book = getBook(bookId)
        if (book != null) {
            context.res.setBodyText(Json.encodeToString(book))
        } else {
            context.res.status = 404
        }
    } catch (e: Exception) {
        context.res.status = 500
        context.res.setBodyText(e.message ?: "")
    }
}

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 with handleGet, handlePost, etc. methods

@Api
suspend fun getBook(context: ApiContext) = context.handleGet {
   val bookId = context.params[0].toLong()
   return getBook(bookId) ?: throw NotFoundException()
}

Add / 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:

@ApiGet() // Or @Api(verb = GET)
suspend fun getBook(context: ApiContext, bookId: Long): BookDTO {
   return getBook(bookId) ?: throw NotFoundException()
}

Leave Kobweb flexible and low-ish level and add an extension library that layers improvements on top

// build.gradle.kts
plugins {
   apply(libs.plugins.kobwebx.springbootapis)
}

jsMain {
   dependencies {
      implementation(libs.kobwebx.springbootapis)
   }
}

@GetMapping // Converted to Kobweb `@Api`
fun getBook(@RequestParam bookId: Long): BookDTO {
    return getBook(bookId) ?: throw NotFoundException()
}
bitspittle commented 9 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:

https://github.com/varabyte/kobweb-templates/blob/main/examples/todo/site/src/jvmMain/kotlin/todo/api/Add.kt

@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)

Remblej commented 9 months ago

I see two attractive but very different approaches.

Current implementations plus syntactic sugar

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")
    }
}

Annotations Spring Boot like

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.

Auto-generate http client and hide it under abstraction

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

Remblej commented 9 months ago

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