diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt index c3f91fc..e1bfa48 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt @@ -1,71 +1,96 @@ package com.angrypodo.wisp import com.angrypodo.wisp.annotations.Wisp +import com.angrypodo.wisp.generator.RouteFactoryGenerator +import com.angrypodo.wisp.mapper.toRouteInfo +import com.angrypodo.wisp.model.RouteInfo +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.processing.SymbolProcessorEnvironment import com.google.devtools.ksp.symbol.KSAnnotated -import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.validate +import com.squareup.kotlinpoet.ksp.writeTo /** * @Wisp 어노테이션이 붙은 클래스를 찾아 유효성을 검증하고 코드를 생성하는 메인 프로세서 클래스입니다. */ internal class WispProcessor( - environment: SymbolProcessorEnvironment + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger ) : SymbolProcessor { - private val logger = environment.logger - /** - * KSP가 코드를 분석할 때 호출되는 함수입니다. - */ + private val factoryGenerator = RouteFactoryGenerator(logger) + override fun process(resolver: Resolver): List { val symbols = resolver.getSymbolsWithAnnotation(WISP_ANNOTATION) .filterIsInstance() if (symbols.none()) return emptyList() - logger.info("Wisp: found ${symbols.count()} @Wisp Route Symbols.") - val (processableSymbols, deferredSymbols) = symbols.partition { it.validate() } - processableSymbols.forEach { routeClass -> - val routeInfo = routeClass.toRouteClassInfo() - val result = WispValidator.validate(routeInfo) + processableSymbols.forEach { processSymbol(it) } + + return deferredSymbols + } - when (result) { - is WispValidator.ValidationResult.Success -> - logger.info( - "Wisp: Route '${routeClass.simpleName.asString()}' validation successful." - ) + private fun processSymbol(routeClass: KSClassDeclaration) { + if (!validateSerializable(routeClass)) { + return + } - is WispValidator.ValidationResult.Failure -> - logger.error(result.message, routeClass) - } + val routeInfo = routeClass.toRouteInfo() ?: run { + logInvalidRouteError(routeClass) + return } - return deferredSymbols + generateRouteFactory(routeClass, routeInfo) } - /** - * KSP의 KSClassDeclaration을 RouteClassInfo로 변환하는 매퍼 함수입니다. - */ - private fun KSClassDeclaration.toRouteClassInfo(): RouteClassInfo = RouteClassInfo( - qualifiedName = qualifiedName?.asString(), - simpleName = simpleName.asString(), - annotations = annotations.map { it.toAnnotationInfo() }.toList() - ) - - /** - * KSP의 KSAnnotation을 AnnotationInfo로 변환하는 매퍼 함수입니다. - */ - private fun KSAnnotation.toAnnotationInfo(): AnnotationInfo = AnnotationInfo( - qualifiedName = annotationType.resolve().declaration.qualifiedName?.asString(), - shortName = shortName.asString() - ) + private fun validateSerializable(routeClass: KSClassDeclaration): Boolean { + if (routeClass.hasSerializableAnnotation()) return true + val routeName = routeClass.qualifiedName?.asString() + logger.error( + "Wisp Error: Route '$routeName' must be annotated with @Serializable.", + routeClass + ) + return false + } + + private fun logInvalidRouteError(routeClass: KSClassDeclaration) { + val routeName = routeClass.simpleName.asString() + logger.error( + "Wisp Error: Route '$routeName' is missing @Wisp path or has invalid parameters.", + routeClass + ) + } + + private fun generateRouteFactory( + routeClass: KSClassDeclaration, + routeInfo: RouteInfo + ) { + val fileSpec = factoryGenerator.generate(routeInfo) + val dependencies = Dependencies(false, routeClass.containingFile!!) + fileSpec.writeTo(codeGenerator, dependencies) + } + + private fun KSClassDeclaration.hasSerializableAnnotation(): Boolean { + return annotations.any { annotation -> + val shortName = annotation.shortName.asString() + val qualifiedName = annotation.annotationType.resolve() + .declaration.qualifiedName?.asString() + val isSerializable = shortName == SERIALIZABLE_SHORT_NAME && + qualifiedName == SERIALIZABLE_ANNOTATION + isSerializable + } + } companion object { private val WISP_ANNOTATION = requireNotNull(Wisp::class.qualifiedName) + private const val SERIALIZABLE_SHORT_NAME = "Serializable" + private const val SERIALIZABLE_ANNOTATION = "kotlinx.serialization.Serializable" } } diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessorProvider.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessorProvider.kt index 5f69cf5..93e1b43 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessorProvider.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessorProvider.kt @@ -9,6 +9,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider */ class WispProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = WispProcessor( - environment + codeGenerator = environment.codeGenerator, + logger = environment.logger ) } diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/RouteFactoryGenerator.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/RouteFactoryGenerator.kt new file mode 100644 index 0000000..45d803c --- /dev/null +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/RouteFactoryGenerator.kt @@ -0,0 +1,126 @@ +package com.angrypodo.wisp.generator + +import com.angrypodo.wisp.model.ClassRouteInfo +import com.angrypodo.wisp.model.ObjectRouteInfo +import com.angrypodo.wisp.model.ParameterInfo +import com.angrypodo.wisp.model.RouteInfo +import com.google.devtools.ksp.processing.KSPLogger +import com.squareup.kotlinpoet.ANY +import com.squareup.kotlinpoet.BOOLEAN +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.FLOAT +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.MAP +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeSpec + +internal class RouteFactoryGenerator( + private val logger: KSPLogger +) { + + private val routeFactoryInterface = ClassName("com.angrypodo.wisp.runtime", "RouteFactory") + private val missingParameterError = ClassName( + "com.angrypodo.wisp.runtime", + "WispError", + "MissingParameter" + ) + private val invalidParameterError = ClassName( + "com.angrypodo.wisp.runtime", + "WispError", + "InvalidParameter" + ) + + fun generate(routeInfo: RouteInfo): FileSpec { + val createFun = FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE) + .addParameter("params", MAP.parameterizedBy(STRING, STRING)) + .returns(ANY) + .addCode(buildCreateFunctionBody(routeInfo)) + .build() + + val factoryObject = TypeSpec.objectBuilder(routeInfo.factoryClassName) + .addModifiers(KModifier.INTERNAL) + .addSuperinterface(routeFactoryInterface) + .addFunction(createFun) + .build() + + return FileSpec.builder( + routeInfo.factoryClassName.packageName, + routeInfo.factoryClassName.simpleName + ) + .addType(factoryObject) + .build() + } + + private fun buildCreateFunctionBody(routeInfo: RouteInfo): CodeBlock { + return when (routeInfo) { + is ObjectRouteInfo -> CodeBlock.of("return %T", routeInfo.routeClassName) + is ClassRouteInfo -> { + val block = CodeBlock.builder() + routeInfo.parameters.forEach { parameter -> + val conversion = buildConversionCode(parameter, routeInfo.wispPath) + block.addStatement("val %L = %L", parameter.name, conversion) + } + val constructorArgs = routeInfo.parameters.joinToString(", ") { + "${it.name} = ${it.name}" + } + block.addStatement("return %T(%L)", routeInfo.routeClassName, constructorArgs) + block.build() + } + } + } + + private fun buildConversionCode(param: ParameterInfo, wispPath: String): CodeBlock { + val rawAccess = CodeBlock.of("params[%S]", param.name) + val conversionLogic = getConversionLogic(param, rawAccess) + + if (param.isNullable) { + return conversionLogic + } + + val nonNullableType = param.typeName.copy(nullable = false) + val errorType = when (nonNullableType) { + STRING -> missingParameterError + else -> invalidParameterError + } + + return CodeBlock.of( + "(%L ?: throw %T(%S, %S))", + conversionLogic, + errorType, + wispPath, + param.name + ) + } + + private fun getConversionLogic(param: ParameterInfo, rawAccess: CodeBlock): CodeBlock { + val nonNullableType = param.typeName.copy(nullable = false) + return when { + param.isEnum -> CodeBlock.of( + "runCatching { %T.valueOf(%L!!.uppercase()) }.getOrNull()", + nonNullableType, + rawAccess + ) + nonNullableType == STRING -> rawAccess + nonNullableType == INT -> CodeBlock.of("%L?.toIntOrNull()", rawAccess) + nonNullableType == LONG -> CodeBlock.of("%L?.toLongOrNull()", rawAccess) + nonNullableType == BOOLEAN -> CodeBlock.of("%L?.toBooleanStrictOrNull()", rawAccess) + nonNullableType == FLOAT -> CodeBlock.of("%L?.toFloatOrNull()", rawAccess) + nonNullableType == DOUBLE -> CodeBlock.of("%L?.toDoubleOrNull()", rawAccess) + else -> { + logger.error( + "Wisp Error: Unsupported type " + + "'${param.typeName}' for parameter '${param.name}'." + ) + CodeBlock.of("null") + } + } + } +} diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/mapper/RouteInfoMapper.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/mapper/RouteInfoMapper.kt new file mode 100644 index 0000000..88bca50 --- /dev/null +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/mapper/RouteInfoMapper.kt @@ -0,0 +1,61 @@ +package com.angrypodo.wisp.mapper + +import com.angrypodo.wisp.model.ClassRouteInfo +import com.angrypodo.wisp.model.ObjectRouteInfo +import com.angrypodo.wisp.model.ParameterInfo +import com.angrypodo.wisp.model.RouteInfo +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName + +private const val WISP_SIMPLE_NAME = "Wisp" +private const val WISP_PATH_ARGUMENT = "path" + +internal fun KSClassDeclaration.toRouteInfo(): RouteInfo? { + val wispPath = getWispPath() ?: return null + val routeClassName = toClassName() + val factoryClassName = ClassName(packageName.asString(), "${simpleName.asString()}RouteFactory") + + if (classKind == ClassKind.OBJECT) { + return ObjectRouteInfo( + routeClassName = routeClassName, + factoryClassName = factoryClassName, + wispPath = wispPath + ) + } + + return ClassRouteInfo( + routeClassName = routeClassName, + factoryClassName = factoryClassName, + wispPath = wispPath, + parameters = extractParameters() + ) +} + +private fun KSClassDeclaration.getWispPath(): String? { + val wispAnnotation = annotations.find { it.shortName.asString() == WISP_SIMPLE_NAME } + ?: return null + val pathArgument = + wispAnnotation.arguments.firstOrNull { it.name?.asString() == WISP_PATH_ARGUMENT } + ?: wispAnnotation.arguments.firstOrNull() + return pathArgument?.value as? String +} + +private fun KSClassDeclaration.extractParameters(): List { + return primaryConstructor?.parameters?.mapNotNull { parameter -> + val parameterName = parameter.name?.asString() ?: return@mapNotNull null + val resolvedType = parameter.type.resolve() + val declaration = resolvedType.declaration + val isEnum = + declaration is KSClassDeclaration && declaration.classKind == ClassKind.ENUM_CLASS + + ParameterInfo( + name = parameterName, + typeName = resolvedType.toTypeName(), + isNullable = resolvedType.isMarkedNullable, + isEnum = isEnum + ) + } ?: emptyList() +} diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/model/RouteInfo.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/model/RouteInfo.kt new file mode 100644 index 0000000..80cdce1 --- /dev/null +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/model/RouteInfo.kt @@ -0,0 +1,42 @@ +package com.angrypodo.wisp.model + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.TypeName + +/** + * 라우트 정보를 타입에 따라 분리하여 표현하는 Sealed Interface 입니다. + */ +sealed interface RouteInfo { + val routeClassName: ClassName + val factoryClassName: ClassName + val wispPath: String +} + +/** + * 파라미터가 없는 object/data object 타입 라우트의 정보입니다. + */ +internal data class ObjectRouteInfo( + override val routeClassName: ClassName, + override val factoryClassName: ClassName, + override val wispPath: String +) : RouteInfo + +/** + * 생성자 파라미터가 있는 class/data class 타입 라우트의 정보입니다. + */ +internal data class ClassRouteInfo( + override val routeClassName: ClassName, + override val factoryClassName: ClassName, + override val wispPath: String, + val parameters: List +) : RouteInfo + +/** + * 라우트 생성자 파라미터 정보를 표현하는 데이터 클래스입니다. + */ +internal data class ParameterInfo( + val name: String, + val typeName: TypeName, + val isNullable: Boolean, + val isEnum: Boolean +) diff --git a/wisp-processor/src/test/java/com/angrypodo/wisp/generator/RouteFactoryGeneratorTest.kt b/wisp-processor/src/test/java/com/angrypodo/wisp/generator/RouteFactoryGeneratorTest.kt new file mode 100644 index 0000000..4df3501 --- /dev/null +++ b/wisp-processor/src/test/java/com/angrypodo/wisp/generator/RouteFactoryGeneratorTest.kt @@ -0,0 +1,247 @@ +package com.angrypodo.wisp.generator + +import com.angrypodo.wisp.model.ClassRouteInfo +import com.angrypodo.wisp.model.ObjectRouteInfo +import com.angrypodo.wisp.model.ParameterInfo +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.KSNode +import com.squareup.kotlinpoet.BOOLEAN +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.FLOAT +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.STRING +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +internal class RouteFactoryGeneratorTest { + + private lateinit var logger: TestLogger + private lateinit var generator: RouteFactoryGenerator + + @BeforeEach + fun setUp() { + logger = TestLogger() + generator = RouteFactoryGenerator(logger) + } + + @Test + @DisplayName("파라미터가 없는 data object 라우트의 팩토리를 생성한다") + fun `generate_no_parameter_route`() { + // Given: 파라미터가 없는 data object 라우트 정보 + val routeInfo = ObjectRouteInfo( + routeClassName = ClassName("com.example", "Home"), + factoryClassName = ClassName("com.example", "HomeRouteFactory"), + wispPath = "home" + ) + + // When: 코드를 생성하면 + val fileSpec = generator.generate(routeInfo) + val generatedCode = fileSpec.toString() + + // Then: Expression body로 객체를 반환해야 한다 + assertTrue(generatedCode.contains("= Home")) + } + + @Test + @DisplayName("NonNull Int 파라미터가 있는 라우트의 팩토리를 생성한다") + fun `generate_route_with_non_nullable_int`() { + // Given: Non-nullable Int 파라미터가 있는 라우트 정보 + val routeInfo = ClassRouteInfo( + routeClassName = ClassName("com.example", "Profile"), + factoryClassName = ClassName("com.example", "ProfileRouteFactory"), + parameters = listOf( + ParameterInfo("userId", INT, isNullable = false, isEnum = false) + ), + wispPath = "profile/{userId}" + ) + + // When: 코드를 생성하면 + val fileSpec = generator.generate(routeInfo) + val generatedCode = fileSpec.toString() + + // Then: toIntOrNull()과 null 체크 및 예외 발생 코드가 포함되어야 한다 + assertTrue(generatedCode.contains("params[\"userId\"]?.toIntOrNull()")) + assertTrue( + generatedCode.contains( + "?: throw WispError.InvalidParameter(\"profile/{userId}\", \"userId\")" + ) + ) + } + + @Test + @DisplayName("NonNull Double 파라미터가 있는 라우트의 팩토리를 생성한다") + fun `generate_route_with_non_nullable_double`() { + // Given: Non-nullable Double 파라미터가 있는 라우트 정보 + val routeInfo = ClassRouteInfo( + routeClassName = ClassName("com.example", "Product"), + factoryClassName = ClassName("com.example", "ProductRouteFactory"), + parameters = listOf( + ParameterInfo("price", DOUBLE, isNullable = false, isEnum = false) + ), + wispPath = "product/{price}" + ) + + // When: 코드를 생성하면 + val fileSpec = generator.generate(routeInfo) + val generatedCode = fileSpec.toString() + + // Then: toDoubleOrNull()과 null 체크 및 예외 발생 코드가 포함되어야 한다 + assertTrue(generatedCode.contains("params[\"price\"]?.toDoubleOrNull()")) + assertTrue( + generatedCode.contains( + "?: throw WispError.InvalidParameter(\"product/{price}\", \"price\")" + ) + ) + } + + @Test + @DisplayName("Nullable String 파라미터가 있는 라우트의 팩토리를 생성한다") + fun `generate_route_with_nullable_string`() { + // Given: Nullable String 파라미터가 있는 라우트 정보 + val routeInfo = ClassRouteInfo( + routeClassName = ClassName("com.example", "Search"), + factoryClassName = ClassName("com.example", "SearchRouteFactory"), + parameters = listOf( + ParameterInfo( + "query", + STRING.copy(nullable = true), + isNullable = true, + isEnum = false + ) + ), + wispPath = "search" + ) + + // When: 코드를 생성하면 + val fileSpec = generator.generate(routeInfo) + val generatedCode = fileSpec.toString() + + // Then: 단순히 파라미터를 가져오는 코드가 생성되어야 한다 + assertTrue(generatedCode.contains("val query = params[\"query\"]")) + assertTrue(generatedCode.contains("return Search(query = query)")) + } + + @Test + @DisplayName("NonNull Enum 파라미터가 있는 라우트의 팩토리를 생성한다") + fun `generate_route_with_non_nullable_enum`() { + // Given: Non-nullable Enum 파라미터가 있는 라우트 정보 + val enumTypeName = ClassName("com.example", "ContentType") + val routeInfo = ClassRouteInfo( + routeClassName = ClassName("com.example", "Content"), + factoryClassName = ClassName("com.example", "ContentRouteFactory"), + parameters = listOf( + ParameterInfo("type", enumTypeName, isNullable = false, isEnum = true) + ), + wispPath = "content/{type}" + ) + + // When: 코드를 생성하면 + val fileSpec = generator.generate(routeInfo) + val generatedCode = fileSpec.toString() + + // Then: valueOf()와 uppercase()를 사용한 변환 코드가 포함되어야 한다 + assertTrue( + generatedCode.contains( + "runCatching { ContentType.valueOf(params[\"type\"]!!.uppercase()) }.getOrNull()" + ) + ) + assertTrue( + generatedCode.contains( + "?: throw WispError.InvalidParameter(\"content/{type}\", \"type\")" + ) + ) + } + + @Test + @DisplayName("여러 타입의 파라미터를 가진 라우트의 팩토리를 생성한다") + fun `generate_route_with_multiple_parameters`() { + // Given: 여러 타입의 파라미터가 있는 라우트 정보 + val routeInfo = ClassRouteInfo( + routeClassName = ClassName("com.example", "Article"), + factoryClassName = ClassName("com.example", "ArticleRouteFactory"), + parameters = listOf( + ParameterInfo("articleId", LONG, isNullable = false, isEnum = false), + ParameterInfo( + "isFeatured", + BOOLEAN.copy(nullable = true), + isNullable = true, + isEnum = false + ), + ParameterInfo("rating", FLOAT, isNullable = false, isEnum = false) + ), + wispPath = "article/{articleId}" + ) + + // When: 코드를 생성하면 + val fileSpec = generator.generate(routeInfo) + val generatedCode = fileSpec.toString() + + // Then: 각 파라미터에 대한 변환 코드가 모두 포함되어야 한다 + assertTrue(generatedCode.contains("params[\"articleId\"]?.toLongOrNull()")) + assertTrue( + generatedCode.contains( + "val isFeatured = params[\"isFeatured\"]?.toBooleanStrictOrNull()" + ) + ) + assertTrue(generatedCode.contains("params[\"rating\"]?.toFloatOrNull()")) + val expectedConstructor = "return Article(articleId = articleId, " + + "isFeatured = isFeatured, rating = rating)" + assertTrue(generatedCode.contains(expectedConstructor)) + } + + @Test + @DisplayName("지원하지 않는 타입의 파라미터가 있으면 KSPLogger로 에러를 기록한다") + fun `log_error_for_unsupported_type`() { + // Given: 지원하지 않는 타입의 파라미터 정보 + val unsupportedType = ClassName("java.util", "Date") + val routeInfo = ClassRouteInfo( + routeClassName = ClassName("com.example", "Event"), + factoryClassName = ClassName("com.example", "EventRouteFactory"), + parameters = listOf( + ParameterInfo("eventDate", unsupportedType, isNullable = false, isEnum = false) + ), + wispPath = "event/{eventDate}" + ) + + // When: 코드를 생성하면 + generator.generate(routeInfo) + + // Then: KSPLogger.error()가 호출되어야 한다 + assertEquals(1, logger.errorMessages.size) + assertEquals( + "Wisp Error: Unsupported type 'java.util.Date' for parameter 'eventDate'.", + logger.errorMessages.first() + ) + } +} + +class TestLogger : KSPLogger { + val errorMessages = mutableListOf() + val warningMessages = mutableListOf() + val infoMessages = mutableListOf() + + override fun error(message: String, symbol: KSNode?) { + errorMessages.add(message) + } + + override fun info(message: String, symbol: KSNode?) { + infoMessages.add(message) + } + + override fun logging(message: String, symbol: KSNode?) { + infoMessages.add(message) + } + + override fun warn(message: String, symbol: KSNode?) { + warningMessages.add(message) + } + + override fun exception(e: Throwable) { + // Not used + } +} diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/RouteFactory.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/RouteFactory.kt new file mode 100644 index 0000000..e5c1e50 --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/RouteFactory.kt @@ -0,0 +1,9 @@ +package com.angrypodo.wisp.runtime + +/** + * KSP가 생성하는 팩토리 클래스들이 구현할 인터페이스입니다. + * 다른 모듈에서 생성된 코드가 접근해야 하므로 public으로 선언합니다. + */ +interface RouteFactory { + fun create(params: Map): Any +} diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt new file mode 100644 index 0000000..34b9413 --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt @@ -0,0 +1,13 @@ +package com.angrypodo.wisp.runtime + +/** + * Wisp 라이브러리에서 발생하는 런타임 에러를 정의하는 Sealed Class 입니다. + * Issue #3에서 필요한 최소한의 에러 타입만 우선 정의합니다. + */ +sealed class WispError(override val message: String) : Exception(message) { + class MissingParameter(path: String, paramName: String) : + WispError("Required parameter \"$paramName\" is missing in path \"$path\".") + + class InvalidParameter(path: String, paramName: String) : + WispError("Parameter \"$paramName\" in path \"$path\" could not be converted.") +}