Skip to content

Commit

Permalink
Add support for middleware @ApiInterceptor
Browse files Browse the repository at this point in the history
This allows users to define a method on the server like:

```
@ApiInterceptor
suspend fun interceptRequest(
  ctx: ApiInterceptorContext
) : Response {
  return when {
    ctx.path == "/legacy" -> ctx.dispatcher.dispatch("/new")
    else -> ctx.dispatcher.dispatch()
  }
}
```

This should be able to allow users to define robust authentication
support, as one example -- basically check the incoming path to see
if it is protected, then pull information out of the request cookies
to see if the user it authenticated, and if not, abort early with a
401 response.

Bug #635
  • Loading branch information
bitspittle committed Dec 30, 2024
1 parent e66bb22 commit 7dcf8a0
Show file tree
Hide file tree
Showing 15 changed files with 425 additions and 74 deletions.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3218,6 +3218,55 @@ suspend fun redirect(ctx: ApiContext) {
Simple!
### Intercepting API routes
Kobweb provides a way to intercept all incoming API requests, getting a first chance to handle them before they get
passed to the actual API route handler.
To intercept all routes, declare a suspend method annotated with `@ApiInterceptor`. This method must take a
`ApiInterceptorContext` parameter and return a `Response`:
```kotlin
@ApiInterceptor
suspend fun interceptRequest(ctx: ApiInterceptorContext): Response {
return when {
ctx.path == "/example" -> Response().apply { setBodyText("Intercepted!") }
// Default: pass request to the route it is normally handled by
else -> ctx.dispatcher.dispatch()
}
}
```
The `ApiInterceptorContext` class provides access to a mutable version of the incoming request, which gives you a chance
to modify its headers, cookies, or query parameters first, which can be useful.
The `ctx.dispatcher.dispatch` method takes an optional path you can specify so that you can delegate a request to a
different API route:
```kotlin
@ApiInterceptor
suspend fun interceptRequest(ctx: ApiInterceptorContext): Response {
return when {
// User will think "/legacy" handled the request;
// actually, "/new" did
ctx.path == "/legacy" -> ctx.dispatcher.dispatch("/new")
else -> ctx.dispatcher.dispatch()
}
}
```
Perhaps you aren't interested in interfering with any incoming routes, but you want to modify all responses before they
get sent back to the client. You can use this pattern for that:
```kotlin
@ApiInterceptor
suspend fun interceptResponse(ctx: ApiInterceptorContext): Response {
return ctx.dispatcher.dispatch().also { res ->
res.headers["X-Intercepted"] = "true"
}
}
```
### Dynamic API routes
Similar to [dynamic `@Page` routes](#dynamic-routes), you can define API routes using curly braces in the same way to
Expand Down
60 changes: 40 additions & 20 deletions backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/Apis.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package com.varabyte.kobweb.api

import com.varabyte.kobweb.api.data.Data
import com.varabyte.kobweb.api.env.Environment
import com.varabyte.kobweb.api.http.MutableRequest
import com.varabyte.kobweb.api.http.Request
import com.varabyte.kobweb.api.http.Response
import com.varabyte.kobweb.api.intercept.ApiInterceptorContext
import com.varabyte.kobweb.api.log.Logger
import com.varabyte.kobweb.api.stream.ApiStream
import com.varabyte.kobweb.api.stream.StreamEvent
Expand All @@ -16,7 +18,36 @@ typealias ApiHandler = suspend (ApiContext) -> Unit
* The class which manages all API paths and handlers within a Kobweb project.
*/
@Suppress("unused") // Called by generated code
class Apis(private val env: Environment, private val data: Data, private val logger: Logger) {
class Apis(
private val env: Environment,
private val data: Data,
private val logger: Logger,
private val apiInterceptor: (suspend (ApiInterceptorContext) -> Response)? = null,
) {
inner class Dispatcher(private val defaultPath: String, private val request: Request) {
suspend fun dispatch(path: String = defaultPath): Response {
return apiHandlers.resolve(path, allowRedirects = false)?.let { entries ->
val captured = entries.captureDynamicValues()
// Captured params, if any, should take precedence over query parameters
val request = if (captured.isEmpty()) request else
MutableRequest(
request.connection,
request.method,
request.queryParams + captured,
request.queryParams,
request.headers,
request.cookies,
request.body,
request.contentType
)

val apiCtx = ApiContext(env, request, data, logger)
entries.last().node.data!!.invoke(apiCtx)
apiCtx.res
} ?: Response().apply { status = 404 }
}
}

private val apiHandlers = RouteTree<ApiHandler>()
private val apiStreamHandlers = mutableMapOf<String, ApiStream>()

Expand All @@ -30,25 +61,14 @@ class Apis(private val env: Environment, private val data: Data, private val log
apiStreamHandlers[path.removePrefix("/")] = streamHandler
}

suspend fun handle(path: String, request: Request): Response? {
return apiHandlers.resolve(path, allowRedirects = false)?.let { entries ->
val captured = entries.captureDynamicValues()
// Captured params, if any, should take precedence over query parameters
@Suppress("NAME_SHADOWING") val request = if (captured.isEmpty()) request else
Request(
request.connection,
request.method,
request.queryParams + captured,
request.queryParams,
request.headers,
request.cookies,
request.body,
request.contentType
)

val apiCtx = ApiContext(env, request, data, logger)
entries.last().node.data!!.invoke(apiCtx)
apiCtx.res
suspend fun handle(path: String, request: Request): Response {
val dispatcher = Dispatcher(path, request)
return apiInterceptor?.let { intercept ->
val mutableRequest = MutableRequest(request)
val ctx = ApiInterceptorContext(env, dispatcher, path, mutableRequest, data, logger)
intercept(ctx)
} ?: run {
dispatcher.dispatch()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,26 +37,15 @@ import com.varabyte.kobweb.api.ApiContext
*
* @see Response
*/
class Request(
val connection: Connection,
val method: HttpMethod,
val params: Map<String, String>,
val queryParams: Map<String, String>,
val headers: Map<String, List<String>>,
val cookies: Map<String, String>,
val body: ByteArray?,
val contentType: String?,
) {
/** Convenience constructor if you don't care about dynamic parameters. */
constructor(
connection: Connection,
method: HttpMethod,
params: Map<String, String>,
headers: Map<String, List<String>>,
cookies: Map<String, String>,
body: ByteArray?,
contentType: String?,
) : this(connection, method, params, params, headers, cookies, body, contentType)
interface Request {
val connection: Connection
val method: HttpMethod
val params: Map<String, String>
val queryParams: Map<String, String>
val headers: Map<String, List<String>>
val cookies: Map<String, String>
val body: ByteArray?
val contentType: String?

/**
* Top-level container class for views about a connection for some request.
Expand Down Expand Up @@ -105,6 +94,36 @@ class Request(
}
}

class MutableRequest(
override val connection: Request.Connection,
override val method: HttpMethod,
params: Map<String, String>,
queryParams: Map<String, String>,
headers: Map<String, List<String>>,
cookies: Map<String, String>,
override var body: ByteArray?,
override var contentType: String?,
) : Request {
constructor(request: Request) : this(
request.connection,
request.method,
request.params,
request.queryParams,
request.headers,
request.cookies,
request.body,
request.contentType,
)

override val params: MutableMap<String, String> = params.toMutableMap()
override val queryParams: MutableMap<String, String> = queryParams.toMutableMap()
override val headers: MutableMap<String, MutableList<String>> = headers
.mapValues { entry -> entry.value.toMutableList() }
.toMutableMap()

override val cookies: MutableMap<String, String> = cookies.toMutableMap()
}

/**
* Convenience method to pull body text out from a request.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package com.varabyte.kobweb.api.http

import com.varabyte.kobweb.api.ApiContext

private val VALID_REDIRECT_STATUS_CODES = setOf(301, 302, 303, 307, 308)
private const val API_PREFIX = "/api"
private const val API_PREFIX_WITH_TRAILING_SLASH = "$API_PREFIX/"

/** A convenience value you can use if you want to express intention that your body should be empty */
val EMPTY_BODY = ByteArray(0)

Expand Down Expand Up @@ -60,3 +64,23 @@ class Response {
fun Response.setBodyText(text: String) {
body = text.toByteArray(Charsets.UTF_8)
}

/**
* Set this to a response that tells the client that the requested resource has moved to a new location.
*
* @param status The specific redirect status code to use. See
* [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) for more information. Defaults to
* 307 (temporary redirect).
*
* @param isApiPath If true, [newPath] will be prefixed with the "/api" prefix (unless already prefixed). This is useful
* if you're redirecting the user away from one API endpoint to another. You can of course just prepend "/api" to the
* path yourself, but in most cases, Kobweb tries to hide the "/api" prefix from the user, so it's a bit strange for
* us to force them to manually reference it here. Therefore, this parameter is provided as a convenience and as a way
* to document this situation.
*/
fun Response.setAsRedirect(newPath: String, status: Int = 307, isApiPath: Boolean = false) {
check(status in VALID_REDIRECT_STATUS_CODES) { "Redirect status code is invalid ($status); must be one of $VALID_REDIRECT_STATUS_CODES" }
this.status = status
headers["Location"] =
if (!isApiPath || newPath.startsWith(API_PREFIX_WITH_TRAILING_SLASH)) newPath else "$API_PREFIX$newPath"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.varabyte.kobweb.api.intercept

import com.varabyte.kobweb.api.http.Response

/**
* An annotation which tags a method that will be given a chance to process all incoming requests.
*
* The signature of the method being annotated must take a [ApiInterceptorContext] as its only parameter, and
* return a [Response].
*
* The "no-op" implementation of an interceptor that doesn't change the normal behavior of a Kobweb server is as
* follows:
*
* ```
* @ApiInterceptor
* suspend fun intercept(ctx: ApiInterceptorContext): Response {
* return ctx.dispatcher.dispatch()
* }
* ```
*
* but you can of course intercept specific API paths with the following pattern:
*
* ```
* @ApiInterceptor
* suspend fun intercept(ctx: ApiInterceptorContext): Response {
* if (ctx.path == "/example") {
* return Response().apply { setBodyText("Intercepted!") }
* }
* return ctx.dispatcher.dispatch()
* }
* ```
*
* This only works on intercepting API routes. API streams are not involved.
*/
@Target(AnnotationTarget.FUNCTION)
annotation class ApiInterceptor
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.varabyte.kobweb.api.intercept

import com.varabyte.kobweb.api.Apis
import com.varabyte.kobweb.api.data.Data
import com.varabyte.kobweb.api.env.Environment
import com.varabyte.kobweb.api.http.MutableRequest
import com.varabyte.kobweb.api.init.InitApi
import com.varabyte.kobweb.api.init.InitApiContext
import com.varabyte.kobweb.api.log.Logger

/**
* A context for a method annotated with [ApiInterceptor].
*
* The classes can be used to help the user write interception logic, dispatching the request either to its original
* endpoint, an alternate one, or simply returning a custom response instead entirely. See [ApiInterceptor] for more
* information.
*
* @property env The current server environment, in case you need to branch logic in development vs production
* environments.
* @property path The path of the API endpoint being requested.
* @property req Request information sent from the client. This instance of the request is mutable, meaning some fields
* (headers, cookies, body, and content-type) can still be changed.
* @property data Readonly data store potentially populated by methods annotated with [InitApi].
* See also: [InitApiContext].
* @property logger A logger which can be used to log messages into the log files.
*/
class ApiInterceptorContext(
val env: Environment,
val dispatcher: Apis.Dispatcher,
val path: String,
val req: MutableRequest,
val data: Data,
val logger: Logger,
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.varabyte.kobweb.api.Apis
import com.varabyte.kobweb.api.event.EventDispatcher
import com.varabyte.kobweb.api.http.EMPTY_BODY
import com.varabyte.kobweb.api.http.HttpMethod
import com.varabyte.kobweb.api.http.MutableRequest
import com.varabyte.kobweb.api.http.Request
import com.varabyte.kobweb.api.log.Logger
import com.varabyte.kobweb.api.stream.ApiStream
Expand Down Expand Up @@ -157,33 +158,30 @@ private suspend fun RoutingContext.handleApiCall(
.toMap()

val headers = call.request.headers.entries().associate { it.key to it.value }
val request = Request(
val request = MutableRequest(
Request.Connection(
origin = call.request.origin.toRequestConnectionDetails(),
local = call.request.local.toRequestConnectionDetails(),
),
httpMethod,
query,
query,
headers,
call.request.cookies.rawCookies,
body,
bodyContentType
)
try {
val response = apiJar.apis.handle("/$pathStr", request)
if (response != null) {
response.headers.forEach { (key, value) ->
call.response.headers.append(key, value)
}
call.respondBytes(
response.body.takeIf { httpMethod != HttpMethod.HEAD } ?: EMPTY_BODY,
status = HttpStatusCode.fromValue(response.status),
contentType = response.contentType?.takeIf { httpMethod != HttpMethod.HEAD }
?.let { ContentType.parse(it) }
)
} else {
call.respond(HttpStatusCode.NotFound)
response.headers.forEach { (key, value) ->
call.response.headers.append(key, value)
}
call.respondBytes(
response.body.takeIf { httpMethod != HttpMethod.HEAD } ?: EMPTY_BODY,
status = HttpStatusCode.fromValue(response.status),
contentType = response.contentType?.takeIf { httpMethod != HttpMethod.HEAD }
?.let { ContentType.parse(it) }
)
} catch (t: Throwable) {
val fullErrorString = t.stackTraceToString()
logger.error(fullErrorString)
Expand Down
14 changes: 14 additions & 0 deletions playground/site/src/jvmMain/kotlin/playground/api/Middleware.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package playground.api

import com.varabyte.kobweb.api.http.Response
import com.varabyte.kobweb.api.intercept.ApiInterceptor
import com.varabyte.kobweb.api.intercept.ApiInterceptorContext

// Visit http://localhost:8080/api/hello and then open .kobweb/server/logs/kobweb-server.log
// to confirm that this interceptor was triggered.

@ApiInterceptor
suspend fun interceptRequest(ctx: ApiInterceptorContext): Response {
ctx.logger.debug("Intercepting request for ${ctx.path}.")
return ctx.dispatcher.dispatch()
}
Loading

0 comments on commit 7dcf8a0

Please sign in to comment.