diff --git a/build.sbt b/build.sbt index 8ce2678..aaefce4 100644 --- a/build.sbt +++ b/build.sbt @@ -5,7 +5,7 @@ sonatypeTimeoutMillis := 60000 ThisBuild / sonatypeCredentialHost := sonatypeCentralHost -ThisBuild / version := "0.1.1" +ThisBuild / version := "0.1.2-SNAPSHOT" ThisBuild / scalaVersion := "3.6.4" // Using Scala 3 ThisBuild / versionScheme := Some("semver-spec") diff --git a/docs/jackson-converter-enhancements.md b/docs/jackson-converter-enhancements.md new file mode 100644 index 0000000..a1a4a6e --- /dev/null +++ b/docs/jackson-converter-enhancements.md @@ -0,0 +1,93 @@ +# JacksonConverter Enhancements + +This document describes the enhancements made to the `JacksonConverter` trait in fast-mcp-scala to better handle complex classes natively. + +## Enhanced Features + +### 1. Collection Support +The library now includes enhanced converters for common collection types: + +- **List/Seq**: Handles JSON strings, Java collections, arrays, and single elements +- **Map**: Supports both Scala and Java maps with flexible key-value conversion +- **Option**: Already supported, treats null/missing as None + +### 2. Additional Type Converters +- **Boolean**: Flexible parsing supporting "true"/"false", "yes"/"no", "1"/"0", "on"/"off" +- **Long, Double, Float**: Native numeric type support + +### 3. Custom Converter Creation +New helper methods for creating custom converters: + +```scala +// Create converter from partial function +val myConverter = JacksonConverter.fromPartialFunction[MyType] { + case str: String => MyType.parse(str) + case map: Map[String, Any] => MyType.fromMap(map) +} + +// Add custom Jackson module +val withModule = JacksonConverter.withCustomModule[MyType](myCustomModule) + +// Transform input before conversion +val transformingConverter = existingConverter.contramap[String](_.toLowerCase) +``` + +### 4. Automatic Derivation +The new `DeriveJacksonConverter` macro automatically generates converters for case classes: + +```scala +import com.tjclp.fastmcp.macros.DeriveJacksonConverter + +case class Person(name: String, age: Int) + +given JacksonConverter[Person] = DeriveJacksonConverter.derived[Person] +``` + +## Usage Examples + +### Complex Filter Class +```scala +case class Filter(column: String, op: String, value: String) + +// Automatic derivation +given JacksonConverter[Filter] = DeriveJacksonConverter.derived[Filter] + +// Custom converter with flexible input +given JacksonConverter[Filter] = JacksonConverter.fromPartialFunction[Filter] { + case map: Map[String, Any] => + Filter( + column = map("column").toString, + op = map.getOrElse("op", "=").toString, + value = map("value").toString + ) +} +``` + +### Nested Collections +```scala +case class QueryFilters(filters: Seq[Filter]) + +// Automatically uses Seq[Filter] converter +given JacksonConverter[QueryFilters] = DeriveJacksonConverter.derived[QueryFilters] +``` + +### Transform Support +```scala +case class User(name: String, age: Int) + +// Convert strings in "name:age" format +given JacksonConverter[User] = + DeriveJacksonConverter.derived[User].contramap[String] { str => + str.split(":") match + case Array(name, age) => Map("name" -> name, "age" -> age.toInt) + case _ => str // Let default converter handle it + } +``` + +## Benefits + +1. **Less Boilerplate**: No need to manually write converters for simple case classes +2. **Flexible Input Handling**: Support multiple input formats (JSON strings, Maps, etc.) +3. **Better Error Messages**: Include parameter names and types in error messages +4. **Composability**: Build complex converters from simple ones +5. **Type Safety**: Leverage Scala's type system while maintaining flexibility \ No newline at end of file diff --git a/src/main/scala/com/tjclp/fastmcp/examples/TaskManagerServer.scala b/src/main/scala/com/tjclp/fastmcp/examples/TaskManagerServer.scala new file mode 100644 index 0000000..3ee98d2 --- /dev/null +++ b/src/main/scala/com/tjclp/fastmcp/examples/TaskManagerServer.scala @@ -0,0 +1,222 @@ +package com.tjclp.fastmcp.examples + +import com.tjclp.fastmcp.core.* +import com.tjclp.fastmcp.macros.RegistrationMacro.* +import com.tjclp.fastmcp.server.* +import com.tjclp.fastmcp.macros.* +import zio.* +import java.time.LocalDateTime +import java.util.UUID +import scala.collection.mutable +import sttp.tapir.generic.auto.* +import sttp.tapir.* + +/** Example MCP server demonstrating complex task management with nested case classes and + * collections - showcasing the enhanced JacksonConverter capabilities. + */ +object TaskManagerServer extends ZIOAppDefault: + + // Domain models + case class Task( + id: String, + title: String, + description: String, + status: TaskStatus, + priority: Priority, + tags: List[String], + assignee: Option[String], + createdAt: LocalDateTime, + dueDate: Option[LocalDateTime] + ) + + enum TaskStatus: + case Todo, InProgress, Done, Cancelled + + enum Priority: + case Low, Medium, High, Critical + + case class TaskFilter( + status: Option[TaskStatus] = None, + priority: Option[Priority] = None, + assignee: Option[String] = None, + tags: List[String] = Nil + ) + + case class TaskUpdate( + title: Option[String] = None, + description: Option[String] = None, + status: Option[TaskStatus] = None, + priority: Option[Priority] = None, + tags: Option[List[String]] = None, + assignee: Option[String] = None, + dueDate: Option[LocalDateTime] = None + ) + + case class TaskStats( + total: Int, + byStatus: Map[String, Int], + byPriority: Map[String, Int], + overdue: Int + ) + + // Custom JacksonConverter for LocalDateTime + given JacksonConverter[LocalDateTime] = JacksonConverter.fromPartialFunction[LocalDateTime] { + case str: String => LocalDateTime.parse(str) + } + + // Derive converters for our domain models + given JacksonConverter[Task] = DeriveJacksonConverter.derived[Task] + given JacksonConverter[TaskFilter] = DeriveJacksonConverter.derived[TaskFilter] + given JacksonConverter[TaskUpdate] = DeriveJacksonConverter.derived[TaskUpdate] + given JacksonConverter[TaskStats] = DeriveJacksonConverter.derived[TaskStats] + // Enums use the default converter + + // In-memory task storage + private val tasks = mutable.Map[String, Task]() + + // Initialize with sample data + tasks ++= Map( + "1" -> Task( + "1", + "Implement user authentication", + "Add OAuth2 authentication to the API", + TaskStatus.InProgress, + Priority.High, + List("backend", "security"), + Some("alice"), + LocalDateTime.now().minusDays(3), + Some(LocalDateTime.now().plusDays(2)) + ), + "2" -> Task( + "2", + "Update documentation", + "Update API documentation with new endpoints", + TaskStatus.Todo, + Priority.Medium, + List("docs"), + Some("bob"), + LocalDateTime.now().minusDays(1), + Some(LocalDateTime.now().plusDays(5)) + ), + "3" -> Task( + "3", + "Fix production bug", + "Users report crash on mobile app", + TaskStatus.Done, + Priority.Critical, + List("bug", "mobile"), + Some("alice"), + LocalDateTime.now().minusDays(2), + Some(LocalDateTime.now().minusDays(1)) + ) + ) + + @Tool( + name = Some("createTask"), + description = Some("Create a new task with the specified details") + ) + def createTask( + @ToolParam("Task title") title: String, + @ToolParam("Task description") description: String, + @ToolParam("Priority level") priority: Priority, + @ToolParam("Tags for categorization") tags: List[String], + @ToolParam("Assignee username") assignee: Option[String] = None, + @ToolParam("Due date in ISO format") dueDate: Option[LocalDateTime] = None + ): Task = + val task = Task( + id = UUID.randomUUID().toString, + title = title, + description = description, + status = TaskStatus.Todo, + priority = priority, + tags = tags, + assignee = assignee, + createdAt = LocalDateTime.now(), + dueDate = dueDate + ) + tasks += (task.id -> task) + task + + @Tool( + name = Some("updateTask"), + description = Some("Update an existing task with new values") + ) + def updateTask( + @ToolParam("Task ID") taskId: String, + @ToolParam("Fields to update") update: TaskUpdate + ): String = + tasks.get(taskId) match + case None => s"Error: Task $taskId not found" + case Some(task) => + val updated = task.copy( + title = update.title.getOrElse(task.title), + description = update.description.getOrElse(task.description), + status = update.status.getOrElse(task.status), + priority = update.priority.getOrElse(task.priority), + tags = update.tags.getOrElse(task.tags), + assignee = update.assignee.orElse(task.assignee), + dueDate = update.dueDate.orElse(task.dueDate) + ) + tasks += (taskId -> updated) + s"Task $taskId updated successfully" + + @Tool( + name = Some("listTasks"), + description = Some("List tasks with optional filtering") + ) + def listTasks( + @ToolParam("Filter criteria") filter: TaskFilter + ): List[Task] = + tasks.values + .filter { task => + filter.status.forall(_ == task.status) && + filter.priority.forall(_ == task.priority) && + filter.assignee.forall(a => task.assignee.contains(a)) && + (filter.tags.isEmpty || filter.tags.forall(task.tags.contains)) + } + .toList + .sortBy(_.createdAt) + .reverse + + @Tool( + name = Some("getTaskStats"), + description = Some("Get statistics about all tasks") + ) + def getTaskStats(): TaskStats = + val allTasks = tasks.values.toList + val now = LocalDateTime.now() + + TaskStats( + total = allTasks.size, + byStatus = allTasks.groupBy(_.status.toString).view.mapValues(_.size).toMap, + byPriority = allTasks.groupBy(_.priority.toString).view.mapValues(_.size).toMap, + overdue = allTasks.count(task => + task.status != TaskStatus.Done && + task.dueDate.exists(_.isBefore(now)) + ) + ) + + @Tool( + name = Some("searchTasks"), + description = Some("Search tasks by text in title or description") + ) + def searchTasks( + @ToolParam("Search query") query: String + ): List[Task] = + val lowerQuery = query.toLowerCase + tasks.values + .filter { task => + task.title.toLowerCase.contains(lowerQuery) || + task.description.toLowerCase.contains(lowerQuery) + } + .toList + .sortBy(_.createdAt) + .reverse + + override def run: URIO[Any, ExitCode] = + (for + _ <- Console.printLine("Starting Task Manager MCP Server...") + server <- ZIO.succeed(FastMcpServer("TaskManagerServer")) + _ <- ZIO.attempt(server.scanAnnotations[TaskManagerServer.type]) + _ <- server.runStdio() + yield ()).exitCode diff --git a/src/main/scala/com/tjclp/fastmcp/macros/DeriveJacksonConverter.scala b/src/main/scala/com/tjclp/fastmcp/macros/DeriveJacksonConverter.scala new file mode 100644 index 0000000..bb821fb --- /dev/null +++ b/src/main/scala/com/tjclp/fastmcp/macros/DeriveJacksonConverter.scala @@ -0,0 +1,155 @@ +package com.tjclp.fastmcp.macros + +import scala.quoted.* +import scala.deriving.Mirror +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.scala.ClassTagExtensions +import scala.reflect.ClassTag + +/** Macro to automatically derive JacksonConverter instances for case classes */ +object DeriveJacksonConverter: + + /** Derives a JacksonConverter for a case class T */ + inline def derived[T](using Mirror.Of[T], ClassTag[T]): JacksonConverter[T] = + ${ derivedImpl[T] } + + private def derivedImpl[T: Type](using Quotes): Expr[JacksonConverter[T]] = + import quotes.reflect.* + + val tpe = TypeRepr.of[T] + val typeSymbol = tpe.typeSymbol + + // Check if it's a case class + if !typeSymbol.isClassDef || !typeSymbol.flags.is(Flags.Case) then + report.errorAndAbort( + s"Can only derive JacksonConverter for case classes, but ${typeSymbol.name} is not a case class" + ) + + // Get the mirror + val mirror = Expr.summon[Mirror.Of[T]].get + + mirror match + case '{ $m: Mirror.ProductOf[T] } => + derivedProduct[T](m) + case '{ $m: Mirror.SumOf[T] } => + derivedSum[T](m) + case _ => + report.errorAndAbort(s"Cannot derive JacksonConverter for ${typeSymbol.name}") + + private def derivedProduct[T: Type]( + mirror: Expr[Mirror.ProductOf[T]] + )(using Quotes): Expr[JacksonConverter[T]] = + import quotes.reflect.* + + val tpe = TypeRepr.of[T] + val typeName = Expr(tpe.typeSymbol.name) + val ct = Expr + .summon[ClassTag[T]] + .getOrElse( + report.errorAndAbort(s"No ClassTag available for ${tpe.typeSymbol.name}") + ) + + '{ + given ClassTag[T] = $ct + new JacksonConverter[T]: + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): T = + if rawValue == null then + throw new RuntimeException( + s"Null value provided for parameter '$name' of type ${$typeName}" + ) + + // Try different input formats + rawValue match + // If it's already the correct type, return it + case t: T => t + + // If it's a Map, convert field by field + case map: Map[String, Any] => + convertFromMap(name, map, mapper) + + case jMap: java.util.Map[String, Any] => + import scala.jdk.CollectionConverters.* + convertFromMap(name, jMap.asScala.toMap, mapper) + + // Otherwise use Jackson's default conversion + case _ => + try mapper.convertValue[T](rawValue) + catch + case e: Exception => + throw new RuntimeException( + s"Failed to convert value for parameter '$name' to type ${$typeName}. Value: $rawValue", + e + ) + + private def convertFromMap( + paramName: String, + map: Map[String, Any], + mapper: JsonMapper & ClassTagExtensions + ): T = + // Let Jackson handle the conversion directly + try mapper.convertValue[T](map) + catch + case e: Exception => + throw new RuntimeException( + s"Failed to convert map to ${$typeName} for parameter '$paramName'", + e + ) + } + + private def derivedSum[T: Type]( + mirror: Expr[Mirror.SumOf[T]] + )(using Quotes): Expr[JacksonConverter[T]] = + import quotes.reflect.* + + val tpe = TypeRepr.of[T] + val typeName = Expr(tpe.typeSymbol.name) + val ct = Expr + .summon[ClassTag[T]] + .getOrElse( + report.errorAndAbort(s"No ClassTag available for ${tpe.typeSymbol.name}") + ) + + '{ + given ClassTag[T] = $ct + new JacksonConverter[T]: + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): T = + if rawValue == null then + throw new RuntimeException( + s"Null value provided for parameter '$name' of type ${$typeName}" + ) + + // For sum types (sealed traits/enums), delegate to Jackson + try mapper.convertValue[T](rawValue) + catch + case e: Exception => + throw new RuntimeException( + s"Failed to convert value for parameter '$name' to type ${$typeName}. Value: $rawValue", + e + ) + } + + /** Derives JacksonConverter instances for common container types */ + object containers: + + inline def seq[A](using conv: JacksonConverter[A], ct: ClassTag[A]): JacksonConverter[Seq[A]] = + summon[JacksonConverter[Seq[A]]] + + inline def list[A](using + conv: JacksonConverter[A], + ct: ClassTag[A] + ): JacksonConverter[List[A]] = + summon[JacksonConverter[List[A]]] + + inline def option[A](using + conv: JacksonConverter[A], + ct: ClassTag[A] + ): JacksonConverter[Option[A]] = + summon[JacksonConverter[Option[A]]] + + inline def map[K, V](using + kConv: JacksonConverter[K], + vConv: JacksonConverter[V], + kCt: ClassTag[K], + vCt: ClassTag[V] + ): JacksonConverter[Map[K, V]] = + summon[JacksonConverter[Map[K, V]]] diff --git a/src/main/scala/com/tjclp/fastmcp/macros/JacksonConverter.scala b/src/main/scala/com/tjclp/fastmcp/macros/JacksonConverter.scala index e62a199..e86b5ae 100644 --- a/src/main/scala/com/tjclp/fastmcp/macros/JacksonConverter.scala +++ b/src/main/scala/com/tjclp/fastmcp/macros/JacksonConverter.scala @@ -1,15 +1,32 @@ package com.tjclp.fastmcp.macros import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.{DeserializationFeature, JsonDeserializer, JsonSerializer} import com.fasterxml.jackson.module.scala.ClassTagExtensions import com.tjclp.fastmcp.server.McpContext import scala.reflect.ClassTag +import scala.jdk.CollectionConverters.* /** Typeclass that converts a raw `Any` value (from a Map) to `T` using Jackson. */ trait JacksonConverter[T]: def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): T + /** Optional custom module to register with Jackson for this converter */ + def customModule: Option[SimpleModule] = None + + /** Create a new converter by transforming the input before conversion */ + def contramap[U](f: U => Any): JacksonConverter[T] = + val self = this + new JacksonConverter[T]: + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): T = + val transformed = + try f(rawValue.asInstanceOf[U]) + catch case _: ClassCastException => rawValue + self.convert(name, transformed, mapper) + override def customModule = self.customModule + object JacksonConverter: // Uniform null/missing handling @@ -63,6 +80,122 @@ object JacksonConverter: ): McpContext = raw.asInstanceOf[McpContext] + // Enhanced List/Seq converter that handles various input formats + given [A: ClassTag](using conv: JacksonConverter[A]): JacksonConverter[List[A]] with + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): List[A] = + if rawValue == null then failNull(name, "List") + + val elements = rawValue match + case str: String => + // Try to parse as JSON array + try + val parsed = mapper.readValue(str, classOf[java.util.List[Any]]) + parsed.asScala.toList + catch case _: Exception => List(str) // Single string element + case jList: java.util.List[?] => jList.asScala.toList + case arr: Array[?] => arr.toList + case seq: Seq[?] => seq.toList + case single => List(single) + + elements.zipWithIndex.map { case (elem, idx) => + conv.convert(s"$name[$idx]", elem, mapper) + } + + given [A: ClassTag](using conv: JacksonConverter[A]): JacksonConverter[Seq[A]] with + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): Seq[A] = + summon[JacksonConverter[List[A]]].convert(name, rawValue, mapper).toSeq + + // Enhanced Map converter + given [K: ClassTag, V: ClassTag](using + kConv: JacksonConverter[K], + vConv: JacksonConverter[V] + ): JacksonConverter[Map[K, V]] with + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): Map[K, V] = + if rawValue == null then failNull(name, "Map") + + rawValue match + case jMap: java.util.Map[?, ?] => + jMap.asScala.toMap.map { case (k, v) => + val key = kConv.convert(s"$name.key", k, mapper) + val value = vConv.convert(s"$name[$k]", v, mapper) + key -> value + } + case sMap: Map[?, ?] => + sMap.map { case (k, v) => + val key = kConv.convert(s"$name.key", k, mapper) + val value = vConv.convert(s"$name[$k]", v, mapper) + key -> value + } + case str: String => + // Try to parse as JSON object + val parsed = mapper.readValue(str, classOf[java.util.Map[Any, Any]]) + summon[JacksonConverter[Map[K, V]]].convert(name, parsed, mapper) + case _ => + throw new RuntimeException( + s"Cannot convert $rawValue to Map[${summon[ClassTag[K]].runtimeClass.getSimpleName}, ${summon[ClassTag[V]].runtimeClass.getSimpleName}]" + ) + + // Boolean converter with flexible parsing + given JacksonConverter[Boolean] with + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): Boolean = + if rawValue == null then failNull(name, "Boolean") + rawValue match + case b: Boolean => b + case s: String => + s.toLowerCase match + case "true" | "yes" | "1" | "on" => true + case "false" | "no" | "0" | "off" => false + case _ => + throw new RuntimeException(s"Cannot parse '$s' as Boolean for parameter '$name'") + case n: Number => n.intValue() != 0 + case _ => doConvert[Boolean](name, rawValue, "Boolean", mapper) + + // Long converter + given JacksonConverter[Long] with + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): Long = + if rawValue == null then failNull(name, "Long") + doConvert[Long](name, rawValue, "Long", mapper) + + // Double converter + given JacksonConverter[Double] with + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): Double = + if rawValue == null then failNull(name, "Double") + doConvert[Double](name, rawValue, "Double", mapper) + + // Float converter + given JacksonConverter[Float] with + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): Float = + if rawValue == null then failNull(name, "Float") + doConvert[Float](name, rawValue, "Float", mapper) + + // Helper methods for creating custom converters + def fromPartialFunction[T: ClassTag](pf: PartialFunction[Any, T]): JacksonConverter[T] = + new JacksonConverter[T]: + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): T = + if rawValue == null then failNull(name, summon[ClassTag[T]].runtimeClass.getSimpleName) + pf.lift(rawValue) match + case Some(value) => value + case None => + doConvert[T](name, rawValue, summon[ClassTag[T]].runtimeClass.getSimpleName, mapper) + + def withCustomModule[T: ClassTag]( + module: SimpleModule + )(using base: JacksonConverter[T]): JacksonConverter[T] = + new JacksonConverter[T]: + override def customModule = Some(module) + + def convert(name: String, rawValue: Any, mapper: JsonMapper & ClassTagExtensions): T = + val enhancedMapper = mapper.rebuild().addModule(module).build() :: ClassTagExtensions + base.convert(name, rawValue, enhancedMapper) + // Fallback for any other type T with a ClassTag: let Jackson handle it directly given [T: ClassTag]: JacksonConverter[T] with diff --git a/src/main/scala/com/tjclp/fastmcp/macros/MapToFunctionMacro.scala b/src/main/scala/com/tjclp/fastmcp/macros/MapToFunctionMacro.scala index 95b0c15..5d4b9d0 100644 --- a/src/main/scala/com/tjclp/fastmcp/macros/MapToFunctionMacro.scala +++ b/src/main/scala/com/tjclp/fastmcp/macros/MapToFunctionMacro.scala @@ -14,13 +14,23 @@ import scala.quoted.* object MapToFunctionMacro: // Shared Jackson mapper - private val mapperBuilder = JsonMapper + private val baseMapperBuilder = JsonMapper .builder() .addModule(DefaultScalaModule) .enable(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES) - private val mapper: JsonMapper & ClassTagExtensions = - mapperBuilder.build() :: ClassTagExtensions + private val baseMapper: JsonMapper & ClassTagExtensions = + baseMapperBuilder.build() :: ClassTagExtensions + + // Create mapper with custom modules if needed + private def getMapperWithModules(converters: Seq[JacksonConverter[?]]): JsonMapper & + ClassTagExtensions = + val modules = converters.flatMap(_.customModule).distinct + if modules.isEmpty then baseMapper + else + val builder = baseMapper.rebuild() + modules.foreach(builder.addModule) + builder.build() :: ClassTagExtensions /** Entry point: lifts f into a Map-based handler. */ transparent inline def callByMap[F](inline f: F): Any = @@ -106,7 +116,7 @@ object MapToFunctionMacro: val key = $nameExpr val rawOpt: Option[Any] = $mapExpr.get(key) val raw: Any = rawOpt.getOrElse(None) - $convExpr.convert(key, raw, MapToFunctionMacro.mapper) + $convExpr.convert(key, raw, MapToFunctionMacro.baseMapper) }.asExprOf[Any] else '{ @@ -115,7 +125,7 @@ object MapToFunctionMacro: key, throw new NoSuchElementException("Key not found in map: " + key) ) - $convExpr.convert(key, raw, MapToFunctionMacro.mapper) + $convExpr.convert(key, raw, MapToFunctionMacro.baseMapper) }.asExprOf[Any] }) diff --git a/src/test/scala/com/tjclp/fastmcp/macros/JacksonConverterExample.scala b/src/test/scala/com/tjclp/fastmcp/macros/JacksonConverterExample.scala new file mode 100644 index 0000000..47c2b70 --- /dev/null +++ b/src/test/scala/com/tjclp/fastmcp/macros/JacksonConverterExample.scala @@ -0,0 +1,96 @@ +package com.tjclp.fastmcp.macros.test + +import com.tjclp.fastmcp.macros.{JacksonConverter, DeriveJacksonConverter} +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.scala.{ClassTagExtensions, DefaultScalaModule} +import scala.deriving.Mirror +import scala.reflect.ClassTag + +// Example: Complex filter class similar to the one in the external app +case class Filter( + column: String, + op: String, // In real app this would be a union type + value: String +) + +object Filter: + // Using automatic derivation + given JacksonConverter[Filter] = DeriveJacksonConverter.derived[Filter] + +// Alternative implementation showing custom converter +object FilterAlternative: + + given alternativeConverter: JacksonConverter[Filter] = + JacksonConverter.fromPartialFunction[Filter] { + case map: Map[String, Any] => + Filter( + column = map("column").toString, + op = map.getOrElse("op", "=").toString, + value = map("value").toString + ) + case jMap: java.util.Map[String, Any] => + import scala.jdk.CollectionConverters.* + val map = jMap.asScala.toMap + Filter( + column = map("column").toString, + op = map.getOrElse("op", "=").toString, + value = map("value").toString + ) + } + +// Example: Using enhanced collection converters +case class QueryFilters(filters: Seq[Filter]) + +object QueryFilters: + // Automatically gets Seq[Filter] converter from the given Filter converter + given JacksonConverter[QueryFilters] = DeriveJacksonConverter.derived[QueryFilters] + +// Example: Custom converter with transform +case class User(name: String, age: Int) + +object User: + + // Convert strings in "name:age" format + given JacksonConverter[User] = + DeriveJacksonConverter.derived[User].contramap[String] { str => + str.split(":") match + case Array(name, age) => Map("name" -> name, "age" -> age.toInt) + case _ => str // Let default converter handle it + } + +// Example usage +object JacksonConverterExample: + + private val mapper = JsonMapper + .builder() + .addModule(DefaultScalaModule) + .build() :: ClassTagExtensions + + def main(args: Array[String]): Unit = + // Test Filter conversion + val filterMap = Map("column" -> "status", "op" -> "=", "value" -> "active") + val filter = summon[JacksonConverter[Filter]].convert("filter", filterMap, mapper) + println(s"Converted filter: $filter") + + // Test Seq[Filter] conversion + val filtersList = List( + Map("column" -> "status", "op" -> "=", "value" -> "active"), + Map("column" -> "count", "op" -> ">", "value" -> "10") + ) + val filters = summon[JacksonConverter[Seq[Filter]]].convert("filters", filtersList, mapper) + println(s"Converted filters: $filters") + + // Test QueryFilters + val queryFiltersMap = Map("filters" -> filtersList) + val queryFilters = + summon[JacksonConverter[QueryFilters]].convert("queryFilters", queryFiltersMap, mapper) + println(s"Converted queryFilters: $queryFilters") + + // Test User with transform + val userString = "John:30" + val user = summon[JacksonConverter[User]].convert("user", userString, mapper) + println(s"Converted user from string: $user") + + val userMap = Map("name" -> "Jane", "age" -> 25) + val user2 = summon[JacksonConverter[User]].convert("user", userMap, mapper) + println(s"Converted user from map: $user2")