diff --git a/README.md b/README.md index 0519e49dc..1fbd54734 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/Apis.kt b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/Apis.kt index 86f5ea85c..14f9089f4 100644 --- a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/Apis.kt +++ b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/Apis.kt @@ -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 @@ -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() private val apiStreamHandlers = mutableMapOf() @@ -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() } } diff --git a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Request.kt b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Request.kt index ce8ab2107..78a3b1592 100644 --- a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Request.kt +++ b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Request.kt @@ -37,26 +37,15 @@ import com.varabyte.kobweb.api.ApiContext * * @see Response */ -class Request( - val connection: Connection, - val method: HttpMethod, - val params: Map, - val queryParams: Map, - val headers: Map>, - val cookies: Map, - val body: ByteArray?, - val contentType: String?, -) { - /** Convenience constructor if you don't care about dynamic parameters. */ - constructor( - connection: Connection, - method: HttpMethod, - params: Map, - headers: Map>, - cookies: Map, - body: ByteArray?, - contentType: String?, - ) : this(connection, method, params, params, headers, cookies, body, contentType) +interface Request { + val connection: Connection + val method: HttpMethod + val params: Map + val queryParams: Map + val headers: Map> + val cookies: Map + val body: ByteArray? + val contentType: String? /** * Top-level container class for views about a connection for some request. @@ -105,6 +94,36 @@ class Request( } } +class MutableRequest( + override val connection: Request.Connection, + override val method: HttpMethod, + params: Map, + queryParams: Map, + headers: Map>, + cookies: Map, + 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 = params.toMutableMap() + override val queryParams: MutableMap = queryParams.toMutableMap() + override val headers: MutableMap> = headers + .mapValues { entry -> entry.value.toMutableList() } + .toMutableMap() + + override val cookies: MutableMap = cookies.toMutableMap() +} + /** * Convenience method to pull body text out from a request. * diff --git a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Response.kt b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Response.kt index 843a72494..f7984766d 100644 --- a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Response.kt +++ b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/http/Response.kt @@ -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) @@ -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" +} diff --git a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/intercept/ApiInterceptor.kt b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/intercept/ApiInterceptor.kt new file mode 100644 index 000000000..64e9bd6fd --- /dev/null +++ b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/intercept/ApiInterceptor.kt @@ -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 diff --git a/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/intercept/ApiInterceptorContext.kt b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/intercept/ApiInterceptorContext.kt new file mode 100644 index 000000000..03f3d9e93 --- /dev/null +++ b/backend/kobweb-api/src/main/kotlin/com/varabyte/kobweb/api/intercept/ApiInterceptorContext.kt @@ -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, +) \ No newline at end of file diff --git a/backend/server/src/main/kotlin/com/varabyte/kobweb/server/plugins/Routing.kt b/backend/server/src/main/kotlin/com/varabyte/kobweb/server/plugins/Routing.kt index bc86f279d..ad97ae76e 100644 --- a/backend/server/src/main/kotlin/com/varabyte/kobweb/server/plugins/Routing.kt +++ b/backend/server/src/main/kotlin/com/varabyte/kobweb/server/plugins/Routing.kt @@ -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 @@ -157,13 +158,14 @@ 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, @@ -171,19 +173,15 @@ private suspend fun RoutingContext.handleApiCall( ) 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) diff --git a/playground/site/src/jvmMain/kotlin/playground/api/Middleware.kt b/playground/site/src/jvmMain/kotlin/playground/api/Middleware.kt new file mode 100644 index 000000000..1abb75641 --- /dev/null +++ b/playground/site/src/jvmMain/kotlin/playground/api/Middleware.kt @@ -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() +} diff --git a/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/tasks/KobwebGenerateApisFactoryTask.kt b/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/tasks/KobwebGenerateApisFactoryTask.kt index af377824f..5f74bcd17 100644 --- a/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/tasks/KobwebGenerateApisFactoryTask.kt +++ b/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/tasks/KobwebGenerateApisFactoryTask.kt @@ -4,6 +4,7 @@ import com.varabyte.kobweb.gradle.application.extensions.AppBlock import com.varabyte.kobweb.gradle.application.templates.createApisFactoryImpl import com.varabyte.kobweb.gradle.core.util.searchZipFor import com.varabyte.kobweb.ksp.KOBWEB_METADATA_BACKEND +import com.varabyte.kobweb.project.backend.AppBackendData import com.varabyte.kobweb.project.backend.BackendData import com.varabyte.kobweb.project.backend.merge import kotlinx.serialization.json.Json @@ -31,10 +32,12 @@ abstract class KobwebGenerateApisFactoryTask @Inject constructor(private val app @TaskAction fun execute() { - val backendData = buildList { - kspGenFile.orNull?.let { - add(Json.decodeFromString(it.asFile.readText())) - } + val unmergedAppBackendData = + kspGenFile.orNull?.let { Json.decodeFromString(it.asFile.readText()) } + ?: AppBackendData() + + val mergedBackendData = buildList { + add(unmergedAppBackendData.backendData) compileClasspath.forEach { file -> file.searchZipFor(KOBWEB_METADATA_BACKEND) { bytes -> add(Json.decodeFromString(bytes.decodeToString())) @@ -42,7 +45,12 @@ abstract class KobwebGenerateApisFactoryTask @Inject constructor(private val app } }.merge(throwError = { throw GradleException(it) }) + val appBackendData = AppBackendData( + unmergedAppBackendData.apiInterceptorMethod, + mergedBackendData + ) + val apisFactoryFile = getGenApisFactoryFile().get().asFile.resolve("ApisFactoryImpl.kt") - apisFactoryFile.writeText(createApisFactoryImpl(backendData)) + apisFactoryFile.writeText(createApisFactoryImpl(appBackendData)) } } diff --git a/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/templates/ApisFactoryTemplate.kt b/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/templates/ApisFactoryTemplate.kt index 1a41f5c8f..c12ef7961 100644 --- a/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/templates/ApisFactoryTemplate.kt +++ b/tools/gradle-plugins/application/src/main/kotlin/com/varabyte/kobweb/gradle/application/templates/ApisFactoryTemplate.kt @@ -7,15 +7,15 @@ import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.TypeSpec -import com.varabyte.kobweb.project.backend.BackendData +import com.varabyte.kobweb.project.backend.AppBackendData -fun createApisFactoryImpl(backendData: BackendData): String { +fun createApisFactoryImpl(appBackendData: AppBackendData): String { // Final code should look something like: // // class ApisFactoryImpl : ApisFactory { // override fun create(env: Environment, events: Events, logger: Logger): Apis { // val data = MutableData() - // val apis = Apis(env, data, logger) + // val apis = Apis(env, data, logger, apiInterceptor = { ctx -> example.api.interceptRequest(ctx) }) // apis.register("/add") { ctx -> example.api.add(ctx) } // apis.register("/remove") { ctx -> example.api.remove(ctx) } // apis.registerStream("/echo") { ctx -> example.api.echo } @@ -36,6 +36,7 @@ fun createApisFactoryImpl(backendData: BackendData): String { val classEvents = ClassName("$apiPackage.event", "Events") val classInitApiContext = ClassName("$apiPackage.init", "InitApiContext") val classLogger = ClassName("$apiPackage.log", "Logger") + val backendData = appBackendData.backendData fileBuilder.addType( TypeSpec.classBuilder("ApisFactoryImpl") @@ -49,7 +50,16 @@ fun createApisFactoryImpl(backendData: BackendData): String { .returns(classApis) .addCode(CodeBlock.builder().apply { addStatement("val data = %T()", classMutableData) - addStatement("val apis = %T(env, data, logger)", classApis) + addStatement( + buildString { + append("val apis = %T(env, data, logger") + appBackendData.apiInterceptorMethod?.let { requestInterceptorMethod -> + append(", apiInterceptor = { ctx -> ${requestInterceptorMethod.fqn}(ctx) }") + } + append(")") + }, + classApis + ) backendData.apiMethods.sortedBy { entry -> entry.route }.forEach { entry -> addStatement("apis.register(%S) { ctx -> ${entry.fqn}(ctx) }", entry.route) } diff --git a/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/KobwebProcessorProvider.kt b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/KobwebProcessorProvider.kt index 62bf4b7c0..edeb82da8 100644 --- a/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/KobwebProcessorProvider.kt +++ b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/KobwebProcessorProvider.kt @@ -11,6 +11,7 @@ import com.varabyte.kobweb.ProcessorMode import com.varabyte.kobweb.backendFile import com.varabyte.kobweb.frontendFile import com.varabyte.kobweb.ksp.backend.BackendProcessor +import com.varabyte.kobweb.ksp.backend.AppBackendProcessor import com.varabyte.kobweb.ksp.frontend.AppFrontendProcessor import com.varabyte.kobweb.ksp.frontend.FrontendProcessor @@ -54,12 +55,26 @@ class KobwebProcessorProvider : SymbolProcessorProvider { val apiPackage = environment.options[KSP_API_PACKAGE_KEY] ?: error("KobwebProcessorProvider: Missing api package ($KSP_API_PACKAGE_KEY)") - BackendProcessor( - codeGenerator = environment.codeGenerator, - logger = environment.logger, - genFile = processorMode.backendFile, - qualifiedApiPackage = apiPackage, - ) + when (processorMode) { + ProcessorMode.APP -> { + AppBackendProcessor( + codeGenerator = environment.codeGenerator, + logger = environment.logger, + genFile = processorMode.backendFile, + qualifiedApiPackage = apiPackage, + ) + } + + ProcessorMode.LIBRARY -> { + BackendProcessor( + isLibrary = true, + codeGenerator = environment.codeGenerator, + logger = environment.logger, + genFile = processorMode.backendFile, + qualifiedApiPackage = apiPackage, + ) + } + } } else -> { diff --git a/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/backend/AppBackendProcessor.kt b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/backend/AppBackendProcessor.kt new file mode 100644 index 000000000..1a501b85a --- /dev/null +++ b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/backend/AppBackendProcessor.kt @@ -0,0 +1,77 @@ +package com.varabyte.kobweb.ksp.backend + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.varabyte.kobweb.ksp.common.API_INTERCEPTOR_FQN +import com.varabyte.kobweb.ksp.common.RESPONSE_FQN +import com.varabyte.kobweb.project.backend.ApiInterceptorEntry +import com.varabyte.kobweb.project.backend.AppBackendData +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class AppBackendProcessor( + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger, + private val genFile: String, + qualifiedApiPackage: String, +) : SymbolProcessor { + private val fileDependencies = mutableSetOf() + + private var apiInterceptorMethod: ApiInterceptorEntry? = null + + // use single processor so that results are stored between round during multi-round processing + private val backendProcessor = BackendProcessor( + isLibrary = false, + codeGenerator = codeGenerator, + logger = logger, + genFile = "", + qualifiedApiPackage = qualifiedApiPackage, + ) + + override fun process(resolver: Resolver): List { + resolver.getSymbolsWithAnnotation(API_INTERCEPTOR_FQN).toList().let { apiInterceptorMethods -> + if (apiInterceptorMethods.size > 1 || (apiInterceptorMethod != null && apiInterceptorMethods.isNotEmpty())) { + logger.error("At most one @ApiInterceptor function is allowed per project.") + } else { + apiInterceptorMethods.singleOrNull()?.let { + val funDeclaration = (it as KSFunctionDeclaration) + funDeclaration.returnType?.resolve()?.let { returnType -> + if (returnType.declaration.qualifiedName?.asString() != RESPONSE_FQN || returnType.isMarkedNullable) { + logger.error("The method annotated with @ApiInterceptor must return `Response`, got `${returnType.declaration.qualifiedName?.asString()}${if (returnType.isMarkedNullable) "?" else ""}`") + } + } + + fileDependencies.add(it.containingFile!!) + apiInterceptorMethod = + funDeclaration.qualifiedName?.asString()?.let { fqn -> ApiInterceptorEntry(fqn) } + } + } + } + + return emptyList() + } + + override fun finish() { + val backendResult = backendProcessor.getProcessorResult() + fileDependencies.addAll(backendResult.fileDependencies) + val appBackendData = AppBackendData( + apiInterceptorMethod, + backendResult.data + ) + + val (path, extension) = genFile.split('.') + codeGenerator.createNewFileByPath( + Dependencies(aggregating = true, *fileDependencies.toTypedArray()), + path = path, + extensionName = extension, + ).writer().use { writer -> + writer.write(Json.encodeToString(appBackendData)) + } + } +} diff --git a/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/backend/BackendProcessor.kt b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/backend/BackendProcessor.kt index bc4437b72..7355c15cd 100644 --- a/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/backend/BackendProcessor.kt +++ b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/backend/BackendProcessor.kt @@ -14,12 +14,14 @@ import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.google.devtools.ksp.symbol.KSPropertyDeclaration import com.google.devtools.ksp.symbol.KSVisitorVoid import com.varabyte.kobweb.ksp.common.API_FQN +import com.varabyte.kobweb.ksp.common.API_INTERCEPTOR_FQN import com.varabyte.kobweb.ksp.common.API_STREAM_FQN import com.varabyte.kobweb.ksp.common.API_STREAM_SIMPLE_NAME import com.varabyte.kobweb.ksp.common.INIT_API_FQN import com.varabyte.kobweb.ksp.common.PACKAGE_MAPPING_API_FQN import com.varabyte.kobweb.ksp.common.getPackageMappings import com.varabyte.kobweb.ksp.common.processRoute +import com.varabyte.kobweb.ksp.frontend.FrontendProcessor import com.varabyte.kobweb.ksp.symbol.getAnnotationsByName import com.varabyte.kobweb.ksp.symbol.resolveQualifiedName import com.varabyte.kobweb.ksp.symbol.suppresses @@ -28,10 +30,12 @@ import com.varabyte.kobweb.project.backend.ApiStreamEntry import com.varabyte.kobweb.project.backend.BackendData import com.varabyte.kobweb.project.backend.InitApiEntry import com.varabyte.kobweb.project.backend.assertValid +import com.varabyte.kobweb.project.frontend.FrontendData import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json class BackendProcessor( + private val isLibrary: Boolean, private val codeGenerator: CodeGenerator, private val logger: KSPLogger, private val genFile: String, @@ -50,7 +54,7 @@ class BackendProcessor( // We track all files we depend on so that ksp can perform smart recompilation // Even though our output is aggregating so generally requires full reprocessing, this at minimum means processing // will be skipped if the only change is deleted file(s) that we do not depend on. - private val fileDependencies = mutableListOf() + private val fileDependencies = mutableSetOf() override fun process(resolver: Resolver): List { initMethods += resolver.getSymbolsWithAnnotation(INIT_API_FQN).map { annotatedFun -> @@ -59,6 +63,15 @@ class BackendProcessor( InitApiEntry(name) } + if (isLibrary) { + resolver.getSymbolsWithAnnotation(API_INTERCEPTOR_FQN).toList().forEach { apiInterceptorMethod -> + logger.error( + "@ApiInterceptor functions cannot be defined in library projects.", + apiInterceptorMethod + ) + } + } + val newFiles = resolver.getNewFiles() // package mapping must be processed before api methods & streams @@ -118,7 +131,16 @@ class BackendProcessor( } } - override fun finish() { + /** + * Get the finalized metadata acquired over all rounds of processing. + * + * This function should only be called from [SymbolProcessor.finish] as it relies on all rounds of processing being + * complete. + * + * @return A [Result] containing the finalized [FrontendData] and the file dependencies that should be + * passed in when using KSP's [CodeGenerator] to store the data. + */ + fun getProcessorResult(): Result { // api declarations must be processed at the end, as they rely on package mappings, // which may be populated over several rounds val apiMethods = apiMethodsDeclarations.mapNotNull { annotatedFun -> @@ -145,15 +167,27 @@ class BackendProcessor( it.assertValid(throwError = { msg -> logger.error(msg) }) } + return Result(backendData, fileDependencies) + } + + + override fun finish() { val (path, extension) = genFile.split('.') + val result = getProcessorResult() codeGenerator.createNewFileByPath( Dependencies(aggregating = true, *fileDependencies.toTypedArray()), path = path, extensionName = extension, ).writer().use { writer -> - writer.write(Json.encodeToString(backendData)) + writer.write(Json.encodeToString(result.data)) } } + + /** + * Represents the result of [FrontendProcessor]'s processing, consisting of the generated [FrontendData] and the + * files that contained relevant declarations. + */ + data class Result(val data: BackendData, val fileDependencies: Set) } private fun processApiFun( diff --git a/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/common/NameConstants.kt b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/common/NameConstants.kt index bde4a5bac..867a1df07 100644 --- a/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/common/NameConstants.kt +++ b/tools/ksp/site-processors/src/main/kotlin/com/varabyte/kobweb/ksp/common/NameConstants.kt @@ -5,7 +5,10 @@ private const val KOBWEB_CORE_FQN_PREFIX = "${KOBWEB_FQN_PREFIX}core." private const val KOBWEB_SILK_FQN_PREFIX = "${KOBWEB_FQN_PREFIX}silk." private const val KOBWEB_API_FQN_PREFIX = "${KOBWEB_FQN_PREFIX}api." +const val RESPONSE_FQN = "${KOBWEB_API_FQN_PREFIX}http.Response" + const val INIT_API_FQN = "${KOBWEB_API_FQN_PREFIX}init.InitApi" +const val API_INTERCEPTOR_FQN = "${KOBWEB_API_FQN_PREFIX}intercept.ApiInterceptor" const val PACKAGE_MAPPING_API_FQN = "${KOBWEB_API_FQN_PREFIX}PackageMapping" const val API_FQN = "${KOBWEB_API_FQN_PREFIX}Api" const val API_STREAM_SIMPLE_NAME = "ApiStream" diff --git a/tools/processor-common/src/main/kotlin/com/varabyte/kobweb/project/backend/BackendData.kt b/tools/processor-common/src/main/kotlin/com/varabyte/kobweb/project/backend/BackendData.kt index 4a277d9af..304f21840 100644 --- a/tools/processor-common/src/main/kotlin/com/varabyte/kobweb/project/backend/BackendData.kt +++ b/tools/processor-common/src/main/kotlin/com/varabyte/kobweb/project/backend/BackendData.kt @@ -11,11 +11,18 @@ import kotlinx.serialization.Serializable */ @Serializable class BackendData( - val initMethods: List, - val apiMethods: List, - val apiStreamMethods: List + val initMethods: List = emptyList(), + val apiMethods: List = emptyList(), + val apiStreamMethods: List = emptyList(), ) +@Serializable +class AppBackendData( + val apiInterceptorMethod: ApiInterceptorEntry? = null, + val backendData: BackendData = BackendData(), +) + + /** * Merge multiple [BackendData] objects together. * @@ -45,6 +52,9 @@ private fun Iterable.assertValidApis(throwError: (String) -> Unit) { @Serializable class InitApiEntry(val fqn: String) +@Serializable +class ApiInterceptorEntry(val fqn: String) + @Serializable class ApiStreamEntry(val fqn: String, val route: String)