Skip to content

Commit f3be4ee

Browse files
Type safe routing annotation
1 parent 267c196 commit f3be4ee

File tree

5 files changed

+205
-49
lines changed

5 files changed

+205
-49
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package dev.programadorthi.routing.annotation
2+
3+
import kotlin.reflect.KClass
4+
5+
@Target(
6+
AnnotationTarget.CLASS,
7+
AnnotationTarget.CONSTRUCTOR,
8+
AnnotationTarget.FUNCTION,
9+
)
10+
public annotation class TypeSafeRoute(
11+
val type: KClass<*>,
12+
val method: String = "",
13+
)

ksp/core-processor/jvm/src/dev/programadorthi/routing/ksp/RoutingProcessor.kt

Lines changed: 129 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ import com.squareup.kotlinpoet.FileSpec
2828
import com.squareup.kotlinpoet.FunSpec
2929
import com.squareup.kotlinpoet.KModifier
3030
import com.squareup.kotlinpoet.MemberName
31+
import com.squareup.kotlinpoet.ksp.toClassName
3132
import com.squareup.kotlinpoet.ksp.toTypeName
3233
import com.squareup.kotlinpoet.ksp.writeTo
3334
import dev.programadorthi.routing.annotation.Body
3435
import dev.programadorthi.routing.annotation.Path
3536
import dev.programadorthi.routing.annotation.Route
37+
import dev.programadorthi.routing.annotation.TypeSafeRoute
3638

3739
public class RoutingProcessorProvider : SymbolProcessorProvider {
3840
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
@@ -66,8 +68,9 @@ private class RoutingProcessor(
6668
.addModifiers(KModifier.INTERNAL)
6769
.receiver(route)
6870

69-
resolver
70-
.getSymbolsWithAnnotation(Route::class.java.name)
71+
val routes = resolver.getSymbolsWithAnnotation(Route::class.java.name)
72+
val typedRoutes = resolver.getSymbolsWithAnnotation(TypeSafeRoute::class.java.name)
73+
(routes + typedRoutes)
7174
.filterIsInstance<KSDeclaration>()
7275
.forEach { symbol ->
7376
symbol.transform(ksFiles, configureSpec, resolver)
@@ -92,16 +95,18 @@ private class RoutingProcessor(
9295
"$qualifiedName must not be private"
9396
}
9497
containingFile?.let(ksFiles::add)
95-
val routeAnnotation = checkNotNull(getAnnotationsByType(Route::class).firstOrNull()) {
96-
"Invalid state because is missing @Route to '$qualifiedName'"
98+
val routeAnnotation = getAnnotationsByType(Route::class).firstOrNull()
99+
val typedAnnotation = getAnnotationsByType(TypeSafeRoute::class).firstOrNull()
100+
check(routeAnnotation == null || typedAnnotation == null) {
101+
"@Route and @TypeSafeRoute can't be used together. Choose one or other"
97102
}
98103
when (this) {
99104
is KSFunctionDeclaration -> {
100105
check(functionKind == FunctionKind.TOP_LEVEL) {
101106
"$qualifiedName must be a top level fun"
102107
}
103108
logger.info(">>>> transforming fun: $qualifiedName")
104-
wrapFunctionWithHandle(routeAnnotation, qualifiedName, configureSpec, resolver, null)
109+
wrapFunctionWithHandle(routeAnnotation ?: typedAnnotation, qualifiedName, configureSpec, resolver, null)
105110
}
106111

107112
is KSClassDeclaration -> {
@@ -120,9 +125,12 @@ private class RoutingProcessor(
120125
.filterIsInstance<KSFunctionDeclaration>()
121126
.filter { func -> func.simpleName.asString() == CONSTRUCTOR_NAME }
122127
.forEach { constructor ->
123-
val annotation = constructor.getAnnotationsByType(Route::class).firstOrNull() ?: routeAnnotation
128+
val rAnnotation =
129+
constructor.getAnnotationsByType(Route::class).firstOrNull() ?: routeAnnotation
130+
val tAnnotation =
131+
constructor.getAnnotationsByType(TypeSafeRoute::class).firstOrNull() ?: typedAnnotation
124132
constructor.wrapFunctionWithHandle(
125-
annotation,
133+
rAnnotation ?: tAnnotation,
126134
qualifiedName,
127135
configureSpec,
128136
resolver,
@@ -136,77 +144,116 @@ private class RoutingProcessor(
136144
}
137145

138146
private fun KSFunctionDeclaration.wrapFunctionWithHandle(
139-
routeAnnotation: Route,
147+
annotation: Any?,
140148
qualifiedName: String,
141149
configureSpec: FunSpec.Builder,
142150
resolver: Resolver,
143151
classKind: ClassKind?,
144152
) {
145-
val isRegexRoute = routeAnnotation.regex.isNotBlank()
146-
check(isRegexRoute || routeAnnotation.path.isNotBlank()) {
147-
"@Route requires a path or a regex"
148-
}
149-
check(!isRegexRoute || routeAnnotation.name.isBlank()) {
150-
"@Route having regex can't be named"
151-
}
153+
val routeAnnotation = annotation as? Route
154+
val typedAnnotation = annotation as? TypeSafeRoute
152155

153156
val memberName = when {
154157
annotations.any { it.shortName.asString() == "Composable" } -> composable
155158
classKind != null -> screen
159+
typedAnnotation != null -> resourceHandle
156160
else -> handle
157161
}
158162

163+
val codeBlock = when {
164+
typedAnnotation != null -> {
165+
val type = annotations
166+
.filter { it.shortName.asString() == "TypeSafeRoute" }
167+
.mapNotNull { it.arguments.find { it.name?.asString() == "type" }?.value as? KSType }
168+
.firstOrNull() ?: error("'$qualifiedName' should be annotated with @TypeSafeRoute")
169+
when {
170+
typedAnnotation.method.isBlank() ->
171+
configureSpec
172+
.beginControlFlow("%M<%T>", memberName, type.toClassName())
173+
174+
else ->
175+
configureSpec
176+
.beginControlFlow(
177+
"%M<%T>(method = %M(value = \"${typedAnnotation.method}\"))",
178+
memberName,
179+
type.toClassName(),
180+
routeMethod
181+
)
182+
}
183+
generateHandleBody(false, typedAnnotation, resolver, qualifiedName, classKind, type)
184+
}
185+
186+
routeAnnotation != null -> {
187+
val isRegexRoute = routeAnnotation.regex.isNotBlank()
188+
routeAnnotation.setupAnnotation(isRegexRoute, configureSpec, memberName)
189+
generateHandleBody(isRegexRoute, routeAnnotation, resolver, qualifiedName, classKind, null)
190+
}
191+
192+
else -> error("'$qualifiedName' should have been annotated with @Route or @TypeSafeRoute")
193+
}
194+
195+
configureSpec
196+
.addCode(codeBlock)
197+
.endControlFlow()
198+
}
199+
200+
private fun Route.setupAnnotation(
201+
isRegexRoute: Boolean,
202+
configureSpec: FunSpec.Builder,
203+
memberName: MemberName
204+
) {
205+
check(isRegexRoute || path.isNotBlank()) {
206+
"@Route requires a path or a regex"
207+
}
208+
check(!isRegexRoute || name.isBlank()) {
209+
"@Route having regex can't be named"
210+
}
211+
159212
if (isRegexRoute) {
160-
if (routeAnnotation.method.isBlank()) {
213+
if (method.isBlank()) {
161214
configureSpec
162215
.beginControlFlow(
163216
"%M(path = %T(%S))",
164217
memberName,
165218
Regex::class,
166-
routeAnnotation.regex
219+
regex
167220
)
168221
} else {
169-
val template =
170-
"""%M(path = %T(%S), method = %M(value = "${routeAnnotation.method}"))"""
222+
val template = """%M(path = %T(%S), method = %M(value = "$method"))"""
171223
configureSpec
172224
.beginControlFlow(
173225
template,
174226
memberName,
175227
Regex::class,
176-
routeAnnotation.regex,
228+
regex,
177229
routeMethod
178230
)
179231
}
180232
} else {
181233
val named = when {
182-
routeAnnotation.name.isBlank() -> "name = null"
183-
else -> """name = "${routeAnnotation.name}""""
234+
name.isBlank() -> "name = null"
235+
else -> """name = "$name""""
184236
}
185237
logger.info(">>>> transforming -> name: $named and member: $memberName")
186-
if (routeAnnotation.method.isBlank()) {
238+
if (method.isBlank()) {
187239
configureSpec
188-
.beginControlFlow("%M(path = %S, $named)", memberName, routeAnnotation.path)
240+
.beginControlFlow("%M(path = %S, $named)", memberName, path)
189241
} else {
190242
val template =
191-
"""%M(path = %S, $named, method = %M(value = "${routeAnnotation.method}"))"""
243+
"""%M(path = %S, $named, method = %M(value = "$method"))"""
192244
configureSpec
193-
.beginControlFlow(template, memberName, routeAnnotation.path, routeMethod)
245+
.beginControlFlow(template, memberName, path, routeMethod)
194246
}
195247
}
196-
197-
val codeBlock = generateHandleBody(isRegexRoute, routeAnnotation, resolver, qualifiedName, classKind)
198-
199-
configureSpec
200-
.addCode(codeBlock)
201-
.endControlFlow()
202248
}
203249

204250
private fun KSFunctionDeclaration.generateHandleBody(
205251
isRegexRoute: Boolean,
206-
routeAnnotation: Route,
252+
routeAnnotation: Any,
207253
resolver: Resolver,
208254
qualifiedName: String,
209255
classKind: ClassKind?,
256+
safeType: KSType?,
210257
): CodeBlock {
211258
val funcBuilder = CodeBlock.builder()
212259
val hasZeroOrOneParameter = parameters.size < 2
@@ -230,31 +277,43 @@ private class RoutingProcessor(
230277
.indent()
231278
}
232279

280+
var bodyCount = 0
233281
for (param in parameters) {
234282
check(param.isVararg.not()) {
235283
"Vararg is not supported as fun parameter"
236284
}
285+
check(bodyCount < 1) {
286+
"Multiple parameters annotated with @Body are not supported"
287+
}
237288
var applied = param.tryApplyCallProperty(hasZeroOrOneParameter, funcBuilder)
238289
if (!applied) {
239290
applied = param.tryApplyBody(hasZeroOrOneParameter, funcBuilder)
291+
if (applied) {
292+
bodyCount++
293+
}
240294
}
241-
if (!applied && !isRegexRoute) {
242-
applied = param.tryApplyTailCard(
243-
routePath = routeAnnotation.path,
244-
resolver = resolver,
245-
hasZeroOrOneParameter = hasZeroOrOneParameter,
246-
builder = funcBuilder,
247-
)
295+
if (!applied && routeAnnotation is TypeSafeRoute) {
296+
applied = param.tryApplySafeParams(hasZeroOrOneParameter, funcBuilder, safeType)
248297
}
249-
if (!applied) {
250-
param.tryApplyPath(
251-
isRegexRoute = isRegexRoute,
252-
routeAnnotation = routeAnnotation,
253-
qualifiedName = qualifiedName,
254-
resolver = resolver,
255-
hasZeroOrOneParameter = hasZeroOrOneParameter,
256-
builder = funcBuilder,
257-
)
298+
if (!applied && routeAnnotation is Route) {
299+
if (!isRegexRoute) {
300+
applied = param.tryApplyTailCard(
301+
routePath = routeAnnotation.path,
302+
resolver = resolver,
303+
hasZeroOrOneParameter = hasZeroOrOneParameter,
304+
builder = funcBuilder,
305+
)
306+
}
307+
if (!applied) {
308+
param.tryApplyPath(
309+
isRegexRoute = isRegexRoute,
310+
routeAnnotation = routeAnnotation,
311+
qualifiedName = qualifiedName,
312+
resolver = resolver,
313+
hasZeroOrOneParameter = hasZeroOrOneParameter,
314+
builder = funcBuilder,
315+
)
316+
}
258317
}
259318
}
260319

@@ -308,6 +367,25 @@ private class RoutingProcessor(
308367
}
309368
}
310369

370+
private fun KSValueParameter.tryApplySafeParams(
371+
hasZeroOrOneParameter: Boolean,
372+
builder: CodeBlock.Builder,
373+
safeType: KSType?,
374+
): Boolean {
375+
val paramName = name?.asString()
376+
val paramType = type.resolve()
377+
check(paramType == safeType) {
378+
"'$paramName' has a not supported type. It must be a " +
379+
"'${safeType?.declaration?.qualifiedName?.asString()}', annotated with @Body or be " +
380+
"an ApplicationCall or an ApplicationCall parameter"
381+
}
382+
when {
383+
hasZeroOrOneParameter -> builder.add(TYPE_TEMPLATE, paramName, "it", "")
384+
else -> builder.addStatement(TYPE_TEMPLATE, paramName, "it", ",")
385+
}
386+
return true
387+
}
388+
311389
@OptIn(KspExperimental::class)
312390
private fun KSValueParameter.tryApplyTailCard(
313391
routePath: String,
@@ -454,6 +532,7 @@ private class RoutingProcessor(
454532
private val receive = MemberName("dev.programadorthi.routing.core.application", "receive")
455533
private val receiveNullable =
456534
MemberName("dev.programadorthi.routing.core.application", "receiveNullable")
535+
private val resourceHandle = MemberName("dev.programadorthi.routing.resources", "handle")
457536

458537
private const val CALL_TEMPLATE = """%L = %M%L"""
459538
private const val CALL_PROPERTY_TEMPLATE = """%L = %M.%L%L"""
@@ -464,6 +543,7 @@ private class RoutingProcessor(
464543
private const val FUN_TYPE_INVOKE_START = "$FUN_TYPE_INVOKE("
465544
private const val PATH_TEMPLATE = """%L = %M.parameters["%L"]%L"""
466545
private const val TAILCARD_TEMPLATE = """%L = %M.parameters.getAll("%L")%L"""
546+
private const val TYPE_TEMPLATE = """%L = %L%L"""
467547

468548
private const val FLAG_ROUTING_MODULE_NAME = "Routing_Module_Name"
469549

resources/build.gradle.kts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
plugins {
22
kotlin("multiplatform")
33
kotlin("plugin.serialization")
4+
alias(libs.plugins.ksp)
45
id("org.jetbrains.kotlinx.kover")
56
alias(libs.plugins.maven.publish)
67
}
@@ -16,5 +17,18 @@ kotlin {
1617
api(libs.serialization.core)
1718
}
1819
}
20+
commonTest {
21+
dependencies {
22+
implementation(projects.ksp.coreAnnotations)
23+
}
24+
}
1925
}
2026
}
27+
28+
dependencies {
29+
add("kspJvmTest", projects.ksp.coreProcessor)
30+
}
31+
32+
ksp {
33+
arg("Routing_Module_Name", "Resources")
34+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package dev.programadorthi.routing.resources
2+
3+
import dev.programadorthi.routing.annotation.Body
4+
import dev.programadorthi.routing.annotation.TypeSafeRoute
5+
import dev.programadorthi.routing.resources.helper.Path
6+
7+
internal val invoked = mutableMapOf<String, List<Any?>>()
8+
9+
internal data class User(
10+
val id: Int,
11+
val name: String,
12+
)
13+
14+
@TypeSafeRoute(Path::class)
15+
fun execute() {
16+
invoked += "/path" to emptyList()
17+
}
18+
19+
@TypeSafeRoute(Path.Id::class)
20+
fun execute(pathId: Path.Id) {
21+
invoked += "/path/{id}" to listOf(pathId)
22+
}
23+
24+
@TypeSafeRoute(Path::class, method = "PUSH")
25+
fun executePush() {
26+
invoked += "/path-push" to emptyList()
27+
}
28+
29+
@TypeSafeRoute(Path::class, method = "POST")
30+
internal fun executePost(
31+
@Body user: User
32+
) {
33+
invoked += "/path-post" to listOf(user)
34+
}
35+
36+
@TypeSafeRoute(Path::class, method = "custom")
37+
internal fun executeCustom(
38+
@Body user: User
39+
) {
40+
invoked += "/path-custom" to listOf(user)
41+
}
42+
43+
@TypeSafeRoute(Path.Optional::class)
44+
internal fun executeOptional(optional: Path.Optional) {
45+
invoked += "/path-optional" to listOf(optional)
46+
}

0 commit comments

Comments
 (0)