extraction
package site.daydream.colleen
import java.io.InputStream
import java.lang.reflect.Method
import java.lang.reflect.Parameter
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
import kotlin.reflect.KFunction
import kotlin.reflect.jvm.javaMethod
// ===== Exception Definitions =====
/**
* Base exception for parameter extraction errors
*/
sealed class ExtractionError(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
class MissingParameter(name: String, type: String) :
ExtractionError("Required parameter '$name' of type $type not found")
class InvalidType(type: String, reason: String) :
ExtractionError("Invalid type $type: $reason")
class ConversionFailed(value: String, from: String, to: String, cause: Throwable? = null) :
ExtractionError("Cannot convert '$value' from $from to $to", cause)
}
/**
* Exception thrown when handler invocation fails
*/
class InvocationError(handler: String, method: String, cause: Throwable) :
RuntimeException("Failed to invoke $handler.$method", cause)
// ===== Parameter Extractor Interface =====
/**
* Base interface for parameter extractors that wrap extracted values
*/
interface ParamExtractor<T> {
val value: T
}
// ===== Parameter Wrapper Types =====
/** Path parameter extracted from URL segments */
data class Path<T>(override val value: T) : ParamExtractor<T>
/** HTTP header value */
data class Header(override val value: String) : ParamExtractor<String>
/** HTTP cookie value */
data class Cookie(override val value: String) : ParamExtractor<String>
/** Query parameter from URL query string */
data class Query<T>(override val value: T) : ParamExtractor<T>
/** Request body as text */
data class Text<T>(override val value: T) : ParamExtractor<T>
/** JSON request body deserialized to type T */
data class Json<T>(override val value: T) : ParamExtractor<T>
/** Form data parameter */
data class Form<T>(override val value: T) : ParamExtractor<T>
/** Raw request body stream */
data class Stream(override val value: InputStream) : ParamExtractor<InputStream>
/** Uploaded file from multipart request */
data class UploadedFile(override val value: Request.UploadedFile) : ParamExtractor<Request.UploadedFile>
// ===== Annotations =====
/**
* Specifies the parameter name for extraction.
* Required for Path, Header, Cookie, and File parameters.
*/
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Param(val value: String = "")
// ===== cx Function Implementation =====
/**
* Converts a Kotlin function reference into a Handler.
*
* Supports top-level functions, member functions (bound or unbound), and extension functions.
* Parameters are automatically extracted from the HTTP context based on their types.
*
* Note: Local functions may not work due to Kotlin reflection limitations.
*
* @param fn The Kotlin function to wrap
* @return A Handler that invokes the function with extracted parameters
* @throws ExtractionError if parameter extraction fails
* @throws InvocationError if function invocation fails
*/
fun cx(fn: KFunction<*>): Handler {
val method = fn.javaMethod ?: return createReflectiveHandler(fn)
return createMethodHandler(method, fn)
}
/**
* Converts a Java Method into a Handler.
*
* Supports both static and instance methods. For instance methods,
* the instance must be retrieved from the Context via getService().
*
* @param method The Java method to wrap
* @return A Handler that invokes the method with extracted parameters
* @throws ExtractionError if parameter extraction fails
* @throws InvocationError if method invocation fails
*/
fun cx(method: Method): Handler {
return createMethodHandler(method, null)
}
// ===== Handler Creation =====
/**
* Creates a handler using KFunction.callBy for cases where javaMethod is unavailable
*/
private fun createReflectiveHandler(fn: KFunction<*>): Handler {
val handlerName = fn.name
return Handler { ctx ->
try {
// Build arguments dynamically using parameter names
val args = fn.parameters.associateWith { param ->
when {
param.type.classifier == Context::class -> ctx
else -> null // Will use default values if available
}
}
fn.callBy(args)
} catch (e: Exception) {
throw InvocationError(handlerName, handlerName, e.cause ?: e)
}
}
}
/**
* Creates a handler using Java Method invocation (more efficient, supports full extraction)
*/
private fun createMethodHandler(method: Method, fn: KFunction<*>?): Handler {
val handlerName = method.declaringClass.simpleName ?: "Handler"
val isStatic = java.lang.reflect.Modifier.isStatic(method.modifiers)
// Pre-build extractors for all parameters (closure caching, zero runtime reflection)
val extractors = method.parameters.map { param ->
buildExtractor(param, handlerName, method.name)
}
return Handler { ctx ->
try {
val args = extractors.map { it(ctx) }
val instance = if (isStatic) {
null
} else {
// For instance methods, retrieve the instance from Context
ctx.getService(method.declaringClass)
?: throw ExtractionError.MissingParameter(
"instance",
"Cannot retrieve ${method.declaringClass.simpleName} from context"
)
}
if (fn != null) {
// Use KFunction.callBy for proper default parameter support
val argMap = fn.parameters.zip(args).toMap()
fn.callBy(argMap)
} else {
method.invoke(instance, *args.toTypedArray())
}
} catch (e: ExtractionError) {
throw e
} catch (e: InvocationError) {
throw e
} catch (e: Exception) {
throw InvocationError(handlerName, method.name, e.cause ?: e)
}
}
}
// ===== Extractor Context =====
/**
* Context information for building extractors, used for error messages
*/
private data class ExtractorContext(
val handler: String,
val method: String,
val paramName: String,
val paramType: String
) {
override fun toString() = "$handler.$method($paramType $paramName)"
}
// ===== Parameter Extractor Builder =====
/**
* Builds a parameter extractor function for a given Java parameter.
* The extractor is a closure that captures type information and parameter names,
* eliminating runtime reflection overhead.
*/
private fun buildExtractor(param: Parameter, handler: String, method: String): (Context) -> Any? {
val rawType = param.type
// Context is passed directly without extraction
if (rawType == Context::class.java) {
return { it }
}
val typeInfo = extractTypeInfo(param.parameterizedType)
val paramName = getParameterName(param)
val ctx = ExtractorContext(handler, method, paramName, rawType.simpleName)
return when (rawType) {
Path::class.java -> buildPathExtractor(ctx, typeInfo)
Header::class.java -> buildHeaderExtractor(ctx)
Cookie::class.java -> buildCookieExtractor(ctx)
Query::class.java -> buildQueryExtractor(ctx, typeInfo)
Text::class.java -> buildTextExtractor()
Json::class.java -> buildJsonExtractor(ctx, typeInfo)
Form::class.java -> buildFormExtractor(ctx, typeInfo)
Stream::class.java -> buildStreamExtractor(ctx)
UploadedFile::class.java -> buildFileExtractor(ctx)
else -> { context ->
// Try to resolve as a service from the Context
context.getService(rawType)
}
}
}
private fun buildTextExtractor(): (Context) -> Any? {
// Note: Returns empty string for missing body.
// Consider Text<String?> if null distinction is needed.
return { Text(it.text() ?: "") }
}
// ===== Parameter Name Extraction =====
/**
* Extracts parameter name from @Param annotation or parameter reflection.
* Falls back to empty string if name is synthetic (arg0, arg1, etc.)
*/
private fun getParameterName(param: Parameter): String {
return param.getAnnotation(Param::class.java)?.value?.takeIf { it.isNotEmpty() }
?: param.name.takeUnless { it.matches(ARG_PATTERN) }
?: ""
}
// ===== Type Information Extraction =====
/**
* Extracted generic type information from a parameterized type.
*
* Limitations:
* - Only extracts first-level generic arguments (e.g., Query<List<String>>)
* - Nested generics beyond two levels are not fully supported (e.g., Query<List<Map<String, Foo>>>)
* - Complex wildcard types are not handled
*
* For most REST API use cases, this covers common patterns like:
* - Query<String>, Query<Int>
* - Query<List<String>>
* - Query<Map<String, String>>
* - Form<CustomClass>
*/
private data class TypeInfo(
val rawType: Class<*>?,
val elementType: Class<*>?
)
private fun extractTypeInfo(type: Type): TypeInfo {
if (type !is ParameterizedType) {
return TypeInfo(null, null)
}
return when (val firstArg = type.actualTypeArguments.firstOrNull()) {
is Class<*> -> TypeInfo(firstArg, null)
is ParameterizedType -> {
val raw = firstArg.rawType as? Class<*>
val element = if (raw?.isList() == true) {
firstArg.actualTypeArguments.firstOrNull() as? Class<*>
} else null
TypeInfo(raw, element)
}
else -> TypeInfo(null, null)
}
}
// ===== Type Checking Extensions =====
private fun Class<*>.isSimple() = this in SIMPLE_TYPES
private fun Class<*>.isMap() = this == Map::class.java || this == java.util.Map::class.java
private fun Class<*>.isList() = this == List::class.java || this == java.util.List::class.java
// ===== Type-specific Extractors =====
/**
* Builds extractor for path parameters (e.g., /users/:id)
* Requires @Param annotation to specify the parameter name
*/
private fun buildPathExtractor(ctx: ExtractorContext, typeInfo: TypeInfo): (Context) -> Any? {
require(ctx.paramName.isNotEmpty()) {
"Path parameter requires @Param annotation at $ctx"
}
val valueType = typeInfo.rawType
?: throw ExtractionError.InvalidType(ctx.paramType, "Path requires generic type at $ctx")
return { context ->
val value = context.param(ctx.paramName)
?: throw ExtractionError.MissingParameter(ctx.paramName, ctx.paramType)
Path(value.convert(valueType, ctx))
}
}
/**
* Builds extractor for HTTP headers
* Requires @Param annotation to specify the header name
*/
private fun buildHeaderExtractor(ctx: ExtractorContext): (Context) -> Any? {
require(ctx.paramName.isNotEmpty()) {
"Header parameter requires @Param annotation at $ctx"
}
return { Header(it.header(ctx.paramName) ?: "") }
}
/**
* Builds extractor for HTTP cookies
* Requires @Param annotation to specify the cookie name
*/
private fun buildCookieExtractor(ctx: ExtractorContext): (Context) -> Any? {
require(ctx.paramName.isNotEmpty()) {
"Cookie parameter requires @Param annotation at $ctx"
}
return { Cookie(it.cookie(ctx.paramName) ?: "") }
}
/**
* Builds extractor for query parameters
* Supports: Map, List<T>, simple types, and custom objects
*/
private fun buildQueryExtractor(ctx: ExtractorContext, typeInfo: TypeInfo): (Context) -> Any? {
val raw = typeInfo.rawType
val element = typeInfo.elementType
return { context ->
val value = when {
// Query<Map<String, String>> - all query parameters
raw?.isMap() == true -> context.queries()
// Query<List<T>> - multiple values for same parameter
raw?.isList() == true && element != null -> {
val values = context.request.queryAll(ctx.paramName)
if (values.isEmpty()) emptyList()
else values.map { it.convert(element, ctx) }
}
// Query<Int>, Query<String>, etc. - single value with type conversion
raw?.isSimple() == true -> {
val queryValue = context.query(ctx.paramName)
queryValue?.convert(raw, ctx)
?: try {
getDefaultValue(raw)
} catch (e: ExtractionError.InvalidType) {
// Cannot provide default - parameter is required
throw ExtractionError.MissingParameter(ctx.paramName, ctx.paramType)
}
}
// Query<CustomClass> - deserialize all queries to object
raw != null -> context.queriesAs(raw)
else -> throw ExtractionError.InvalidType(ctx.paramType, "Invalid Query type at $ctx")
}
Query(value)
}
}
/**
* Builds extractor for JSON request body
* Deserializes the body to the specified type
*/
private fun buildJsonExtractor(ctx: ExtractorContext, typeInfo: TypeInfo): (Context) -> Any? {
val valueType = typeInfo.rawType
?: throw ExtractionError.InvalidType(ctx.paramType, "Json requires generic type at $ctx")
return { context ->
try {
Json(context.jsonAs(valueType))
} catch (e: ExtractionError) {
throw e
} catch (e: Exception) {
// Wrap deserialization errors with context
throw ExtractionError.ConversionFailed(
"request body",
"JSON",
valueType.simpleName,
e
)
}
}
}
/**
* Builds extractor for form data parameters
* Supports: Map, List<T>, simple types, and custom objects
*/
private fun buildFormExtractor(ctx: ExtractorContext, typeInfo: TypeInfo): (Context) -> Any? {
val raw = typeInfo.rawType
val element = typeInfo.elementType
return { context ->
val value = when {
// Form<Map<String, String>> - all form parameters
raw?.isMap() == true -> context.forms()
// Form<List<T>> - multiple values for same field
raw?.isList() == true && element != null -> {
val values = context.request.formAll(ctx.paramName)
if (values.isEmpty()) emptyList()
else values.map { it.convert(element, ctx) }
}
// Form<Int>, Form<String>, etc. - single value with type conversion
raw?.isSimple() == true -> {
val formValue = context.form(ctx.paramName)
formValue?.convert(raw, ctx)
?: try {
getDefaultValue(raw)
} catch (e: ExtractionError.InvalidType) {
// Cannot provide default - parameter is required
throw ExtractionError.MissingParameter(ctx.paramName, ctx.paramType)
}
}
// Form<CustomClass> - deserialize all form data to object
raw != null -> {
try {
context.formsAs(raw)
} catch (e: ExtractionError) {
throw e
} catch (e: Exception) {
// Wrap deserialization errors with context
throw ExtractionError.ConversionFailed(
"form data",
"Form",
raw.simpleName,
e
)
}
}
else -> throw ExtractionError.InvalidType(ctx.paramType, "Invalid Form type at $ctx")
}
Form(value)
}
}
/**
* Builds extractor for raw request body stream
*/
private fun buildStreamExtractor(ctx: ExtractorContext): (Context) -> Any? {
return { context ->
Stream(
context.request.stream
?: throw ExtractionError.MissingParameter(
"stream",
"Request body stream is not available at $ctx"
)
)
}
}
/**
* Builds extractor for uploaded files
* Requires @Param annotation to specify the field name
*/
private fun buildFileExtractor(ctx: ExtractorContext): (Context) -> Any? {
require(ctx.paramName.isNotEmpty()) {
"File parameter requires @Param annotation at $ctx"
}
return { context ->
val file = context.file(ctx.paramName)
?: throw ExtractionError.MissingParameter(ctx.paramName, ctx.paramType)
UploadedFile(file)
}
}
// ===== Type Conversion =====
/**
* Converts a string value to the target type.
* Supports: String, Int, Long, Double, Float, Boolean
*/
private fun String.convert(targetType: Class<*>, ctx: ExtractorContext): Any {
return try {
when (targetType) {
String::class.java -> this
Int::class.java, Integer::class.java ->
toIntOrNull() ?: throw ExtractionError.ConversionFailed(this, "String", "Int")
Long::class.java, java.lang.Long::class.java ->
toLongOrNull() ?: throw ExtractionError.ConversionFailed(this, "String", "Long")
Double::class.java, java.lang.Double::class.java ->
toDoubleOrNull() ?: throw ExtractionError.ConversionFailed(this, "String", "Double")
Float::class.java, java.lang.Float::class.java ->
toFloatOrNull() ?: throw ExtractionError.ConversionFailed(this, "String", "Float")
Boolean::class.java, java.lang.Boolean::class.java ->
toBoolean() // Lenient: accepts "true", "TRUE", "1", etc.
else -> throw ExtractionError.InvalidType(
targetType.simpleName,
"Unsupported conversion type at $ctx"
)
}
} catch (e: ExtractionError) {
throw e
} catch (e: Exception) {
throw ExtractionError.ConversionFailed(this, "String", targetType.simpleName, e)
}
}
/**
* Lenient boolean parsing that accepts common representations:
* - true: "true", "TRUE", "True", "1", "yes", "YES", "y", "Y"
* - false: everything else
*/
private fun String.toBoolean(): Boolean {
return when (this.lowercase()) {
"true", "1", "yes", "y" -> true
else -> false
}
}
/**
* Gets default value for primitive types.
* Throws InvalidType for types without a default.
*/
private fun getDefaultValue(clazz: Class<*>): Any = when (clazz) {
Int::class.java, Integer::class.java -> 0
Long::class.java, java.lang.Long::class.java -> 0L
Double::class.java, java.lang.Double::class.java -> 0.0
Float::class.java, java.lang.Float::class.java -> 0.0f
Boolean::class.java, java.lang.Boolean::class.java -> false
String::class.java -> ""
else -> throw ExtractionError.InvalidType(clazz.simpleName, "No default value available")
}
// ===== Constants =====
/** Set of simple types that can be converted from strings */
private val SIMPLE_TYPES = setOf(
String::class.java,
Int::class.java, Integer::class.java,
Long::class.java, java.lang.Long::class.java,
Double::class.java, java.lang.Double::class.java,
Float::class.java, java.lang.Float::class.java,
Boolean::class.java, java.lang.Boolean::class.java
)
/** Pre-compiled regex for detecting synthetic parameter names */
private val ARG_PATTERN = Regex("arg\\d+")