Skip to content

Commit

Permalink
Type safe routing annotation
Browse files Browse the repository at this point in the history
  • Loading branch information
programadorthi committed Jan 30, 2025
1 parent 267c196 commit f3be4ee
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 49 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package dev.programadorthi.routing.annotation

import kotlin.reflect.KClass

@Target(
AnnotationTarget.CLASS,
AnnotationTarget.CONSTRUCTOR,
AnnotationTarget.FUNCTION,
)
public annotation class TypeSafeRoute(
val type: KClass<*>,
val method: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.MemberName
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.toTypeName
import com.squareup.kotlinpoet.ksp.writeTo
import dev.programadorthi.routing.annotation.Body
import dev.programadorthi.routing.annotation.Path
import dev.programadorthi.routing.annotation.Route
import dev.programadorthi.routing.annotation.TypeSafeRoute

public class RoutingProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
Expand Down Expand Up @@ -66,8 +68,9 @@ private class RoutingProcessor(
.addModifiers(KModifier.INTERNAL)
.receiver(route)

resolver
.getSymbolsWithAnnotation(Route::class.java.name)
val routes = resolver.getSymbolsWithAnnotation(Route::class.java.name)
val typedRoutes = resolver.getSymbolsWithAnnotation(TypeSafeRoute::class.java.name)
(routes + typedRoutes)
.filterIsInstance<KSDeclaration>()
.forEach { symbol ->
symbol.transform(ksFiles, configureSpec, resolver)
Expand All @@ -92,16 +95,18 @@ private class RoutingProcessor(
"$qualifiedName must not be private"
}
containingFile?.let(ksFiles::add)
val routeAnnotation = checkNotNull(getAnnotationsByType(Route::class).firstOrNull()) {
"Invalid state because is missing @Route to '$qualifiedName'"
val routeAnnotation = getAnnotationsByType(Route::class).firstOrNull()
val typedAnnotation = getAnnotationsByType(TypeSafeRoute::class).firstOrNull()
check(routeAnnotation == null || typedAnnotation == null) {
"@Route and @TypeSafeRoute can't be used together. Choose one or other"
}
when (this) {
is KSFunctionDeclaration -> {
check(functionKind == FunctionKind.TOP_LEVEL) {
"$qualifiedName must be a top level fun"
}
logger.info(">>>> transforming fun: $qualifiedName")
wrapFunctionWithHandle(routeAnnotation, qualifiedName, configureSpec, resolver, null)
wrapFunctionWithHandle(routeAnnotation ?: typedAnnotation, qualifiedName, configureSpec, resolver, null)
}

is KSClassDeclaration -> {
Expand All @@ -120,9 +125,12 @@ private class RoutingProcessor(
.filterIsInstance<KSFunctionDeclaration>()
.filter { func -> func.simpleName.asString() == CONSTRUCTOR_NAME }
.forEach { constructor ->
val annotation = constructor.getAnnotationsByType(Route::class).firstOrNull() ?: routeAnnotation
val rAnnotation =
constructor.getAnnotationsByType(Route::class).firstOrNull() ?: routeAnnotation
val tAnnotation =
constructor.getAnnotationsByType(TypeSafeRoute::class).firstOrNull() ?: typedAnnotation
constructor.wrapFunctionWithHandle(
annotation,
rAnnotation ?: tAnnotation,
qualifiedName,
configureSpec,
resolver,
Expand All @@ -136,77 +144,116 @@ private class RoutingProcessor(
}

private fun KSFunctionDeclaration.wrapFunctionWithHandle(
routeAnnotation: Route,
annotation: Any?,
qualifiedName: String,
configureSpec: FunSpec.Builder,
resolver: Resolver,
classKind: ClassKind?,
) {
val isRegexRoute = routeAnnotation.regex.isNotBlank()
check(isRegexRoute || routeAnnotation.path.isNotBlank()) {
"@Route requires a path or a regex"
}
check(!isRegexRoute || routeAnnotation.name.isBlank()) {
"@Route having regex can't be named"
}
val routeAnnotation = annotation as? Route
val typedAnnotation = annotation as? TypeSafeRoute

val memberName = when {
annotations.any { it.shortName.asString() == "Composable" } -> composable
classKind != null -> screen
typedAnnotation != null -> resourceHandle
else -> handle
}

val codeBlock = when {
typedAnnotation != null -> {
val type = annotations
.filter { it.shortName.asString() == "TypeSafeRoute" }
.mapNotNull { it.arguments.find { it.name?.asString() == "type" }?.value as? KSType }
.firstOrNull() ?: error("'$qualifiedName' should be annotated with @TypeSafeRoute")
when {
typedAnnotation.method.isBlank() ->
configureSpec
.beginControlFlow("%M<%T>", memberName, type.toClassName())

else ->
configureSpec
.beginControlFlow(
"%M<%T>(method = %M(value = \"${typedAnnotation.method}\"))",
memberName,
type.toClassName(),
routeMethod
)
}
generateHandleBody(false, typedAnnotation, resolver, qualifiedName, classKind, type)
}

routeAnnotation != null -> {
val isRegexRoute = routeAnnotation.regex.isNotBlank()
routeAnnotation.setupAnnotation(isRegexRoute, configureSpec, memberName)
generateHandleBody(isRegexRoute, routeAnnotation, resolver, qualifiedName, classKind, null)
}

else -> error("'$qualifiedName' should have been annotated with @Route or @TypeSafeRoute")
}

configureSpec
.addCode(codeBlock)
.endControlFlow()
}

private fun Route.setupAnnotation(
isRegexRoute: Boolean,
configureSpec: FunSpec.Builder,
memberName: MemberName
) {
check(isRegexRoute || path.isNotBlank()) {
"@Route requires a path or a regex"
}
check(!isRegexRoute || name.isBlank()) {
"@Route having regex can't be named"
}

if (isRegexRoute) {
if (routeAnnotation.method.isBlank()) {
if (method.isBlank()) {
configureSpec
.beginControlFlow(
"%M(path = %T(%S))",
memberName,
Regex::class,
routeAnnotation.regex
regex
)
} else {
val template =
"""%M(path = %T(%S), method = %M(value = "${routeAnnotation.method}"))"""
val template = """%M(path = %T(%S), method = %M(value = "$method"))"""
configureSpec
.beginControlFlow(
template,
memberName,
Regex::class,
routeAnnotation.regex,
regex,
routeMethod
)
}
} else {
val named = when {
routeAnnotation.name.isBlank() -> "name = null"
else -> """name = "${routeAnnotation.name}""""
name.isBlank() -> "name = null"
else -> """name = "$name""""
}
logger.info(">>>> transforming -> name: $named and member: $memberName")
if (routeAnnotation.method.isBlank()) {
if (method.isBlank()) {
configureSpec
.beginControlFlow("%M(path = %S, $named)", memberName, routeAnnotation.path)
.beginControlFlow("%M(path = %S, $named)", memberName, path)
} else {
val template =
"""%M(path = %S, $named, method = %M(value = "${routeAnnotation.method}"))"""
"""%M(path = %S, $named, method = %M(value = "$method"))"""
configureSpec
.beginControlFlow(template, memberName, routeAnnotation.path, routeMethod)
.beginControlFlow(template, memberName, path, routeMethod)
}
}

val codeBlock = generateHandleBody(isRegexRoute, routeAnnotation, resolver, qualifiedName, classKind)

configureSpec
.addCode(codeBlock)
.endControlFlow()
}

private fun KSFunctionDeclaration.generateHandleBody(
isRegexRoute: Boolean,
routeAnnotation: Route,
routeAnnotation: Any,
resolver: Resolver,
qualifiedName: String,
classKind: ClassKind?,
safeType: KSType?,
): CodeBlock {
val funcBuilder = CodeBlock.builder()
val hasZeroOrOneParameter = parameters.size < 2
Expand All @@ -230,31 +277,43 @@ private class RoutingProcessor(
.indent()
}

var bodyCount = 0
for (param in parameters) {
check(param.isVararg.not()) {
"Vararg is not supported as fun parameter"
}
check(bodyCount < 1) {
"Multiple parameters annotated with @Body are not supported"
}
var applied = param.tryApplyCallProperty(hasZeroOrOneParameter, funcBuilder)
if (!applied) {
applied = param.tryApplyBody(hasZeroOrOneParameter, funcBuilder)
if (applied) {
bodyCount++
}
}
if (!applied && !isRegexRoute) {
applied = param.tryApplyTailCard(
routePath = routeAnnotation.path,
resolver = resolver,
hasZeroOrOneParameter = hasZeroOrOneParameter,
builder = funcBuilder,
)
if (!applied && routeAnnotation is TypeSafeRoute) {
applied = param.tryApplySafeParams(hasZeroOrOneParameter, funcBuilder, safeType)
}
if (!applied) {
param.tryApplyPath(
isRegexRoute = isRegexRoute,
routeAnnotation = routeAnnotation,
qualifiedName = qualifiedName,
resolver = resolver,
hasZeroOrOneParameter = hasZeroOrOneParameter,
builder = funcBuilder,
)
if (!applied && routeAnnotation is Route) {
if (!isRegexRoute) {
applied = param.tryApplyTailCard(
routePath = routeAnnotation.path,
resolver = resolver,
hasZeroOrOneParameter = hasZeroOrOneParameter,
builder = funcBuilder,
)
}
if (!applied) {
param.tryApplyPath(
isRegexRoute = isRegexRoute,
routeAnnotation = routeAnnotation,
qualifiedName = qualifiedName,
resolver = resolver,
hasZeroOrOneParameter = hasZeroOrOneParameter,
builder = funcBuilder,
)
}
}
}

Expand Down Expand Up @@ -308,6 +367,25 @@ private class RoutingProcessor(
}
}

private fun KSValueParameter.tryApplySafeParams(
hasZeroOrOneParameter: Boolean,
builder: CodeBlock.Builder,
safeType: KSType?,
): Boolean {
val paramName = name?.asString()
val paramType = type.resolve()
check(paramType == safeType) {
"'$paramName' has a not supported type. It must be a " +
"'${safeType?.declaration?.qualifiedName?.asString()}', annotated with @Body or be " +
"an ApplicationCall or an ApplicationCall parameter"
}
when {
hasZeroOrOneParameter -> builder.add(TYPE_TEMPLATE, paramName, "it", "")
else -> builder.addStatement(TYPE_TEMPLATE, paramName, "it", ",")
}
return true
}

@OptIn(KspExperimental::class)
private fun KSValueParameter.tryApplyTailCard(
routePath: String,
Expand Down Expand Up @@ -454,6 +532,7 @@ private class RoutingProcessor(
private val receive = MemberName("dev.programadorthi.routing.core.application", "receive")
private val receiveNullable =
MemberName("dev.programadorthi.routing.core.application", "receiveNullable")
private val resourceHandle = MemberName("dev.programadorthi.routing.resources", "handle")

private const val CALL_TEMPLATE = """%L = %M%L"""
private const val CALL_PROPERTY_TEMPLATE = """%L = %M.%L%L"""
Expand All @@ -464,6 +543,7 @@ private class RoutingProcessor(
private const val FUN_TYPE_INVOKE_START = "$FUN_TYPE_INVOKE("
private const val PATH_TEMPLATE = """%L = %M.parameters["%L"]%L"""
private const val TAILCARD_TEMPLATE = """%L = %M.parameters.getAll("%L")%L"""
private const val TYPE_TEMPLATE = """%L = %L%L"""

private const val FLAG_ROUTING_MODULE_NAME = "Routing_Module_Name"

Expand Down
14 changes: 14 additions & 0 deletions resources/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
alias(libs.plugins.ksp)
id("org.jetbrains.kotlinx.kover")
alias(libs.plugins.maven.publish)
}
Expand All @@ -16,5 +17,18 @@ kotlin {
api(libs.serialization.core)
}
}
commonTest {
dependencies {
implementation(projects.ksp.coreAnnotations)
}
}
}
}

dependencies {
add("kspJvmTest", projects.ksp.coreProcessor)
}

ksp {
arg("Routing_Module_Name", "Resources")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package dev.programadorthi.routing.resources

import dev.programadorthi.routing.annotation.Body
import dev.programadorthi.routing.annotation.TypeSafeRoute
import dev.programadorthi.routing.resources.helper.Path

internal val invoked = mutableMapOf<String, List<Any?>>()

internal data class User(
val id: Int,
val name: String,
)

@TypeSafeRoute(Path::class)
fun execute() {
invoked += "/path" to emptyList()
}

@TypeSafeRoute(Path.Id::class)
fun execute(pathId: Path.Id) {
invoked += "/path/{id}" to listOf(pathId)
}

@TypeSafeRoute(Path::class, method = "PUSH")
fun executePush() {
invoked += "/path-push" to emptyList()
}

@TypeSafeRoute(Path::class, method = "POST")
internal fun executePost(
@Body user: User
) {
invoked += "/path-post" to listOf(user)
}

@TypeSafeRoute(Path::class, method = "custom")
internal fun executeCustom(
@Body user: User
) {
invoked += "/path-custom" to listOf(user)
}

@TypeSafeRoute(Path.Optional::class)
internal fun executeOptional(optional: Path.Optional) {
invoked += "/path-optional" to listOf(optional)
}
Loading

0 comments on commit f3be4ee

Please sign in to comment.