From 52e649cf9e1a24cf6d21a6e5c444ff87522de5af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 3 Feb 2024 03:00:06 +0100 Subject: [PATCH] Support multiple params --- core/src/main/scala/respectfully/API.scala | 113 +++++++++++------- .../scala/respectfully/test/ClientTests.scala | 30 ++++- .../scala/respectfully/test/ServerTests.scala | 30 ++++- 3 files changed, 116 insertions(+), 57 deletions(-) diff --git a/core/src/main/scala/respectfully/API.scala b/core/src/main/scala/respectfully/API.scala index 9eb3602..85179c4 100644 --- a/core/src/main/scala/respectfully/API.scala +++ b/core/src/main/scala/respectfully/API.scala @@ -60,11 +60,9 @@ object API { "Only methods with one parameter list are supported, got: " + meth.paramSymss + " for " + meth.name, ) - val inputCodec = - meth.paramSymss.head match { - case Nil => '{ Codec.from(Decoder[Unit], Encoder[Unit]) } - - case one :: Nil => /* ok */ + val inputCodec = combineCodecs { + meth.paramSymss.head.map { one => + val codec = one.termRef.typeSymbol.typeRef.asType match { case '[t] => '{ @@ -74,13 +72,9 @@ object API { ) } } - - case _ => - report.errorAndAbort( - "Only methods with one parameter are supported", - meth.pos.getOrElse(Position.ofMacroExpansion), - ) + one.termRef.termSymbol.name -> codec } + } val outputCodec = meth.tree.asInstanceOf[DefDef].returnTpt.tpe.asType match { @@ -114,52 +108,51 @@ object API { } } - def functionsFor(algExpr: Expr[Alg]): Expr[List[(String, Any => IO[Any])]] = Expr.ofList { + def functionsFor(algExpr: Expr[Alg]): Expr[List[(String, List[Any] => IO[Any])]] = Expr.ofList { algTpe .typeSymbol .declaredMethods .map { meth => - meth.paramSymss.head match { + val selectMethod = algExpr.asTerm.select(meth) + + Expr(meth.name) -> meth.paramSymss.head.match { case Nil => // special-case: nullary method - Expr(meth.name) -> '{ (_: Any) => - ${ algExpr.asTerm.select(meth).appliedToNone.asExprOf[IO[Any]] } - } + '{ Function.const(${ selectMethod.appliedToNone.asExprOf[IO[Any]] }) } - case sym :: Nil => - sym.termRef.typeSymbol.typeRef.asType match { - case '[t] => - Expr(meth.name) -> '{ (input: Any) => - ${ - //format: off - algExpr - .asTerm - .select(meth) - .appliedTo('{ input.asInstanceOf[t] }.asTerm) - .asExprOf[IO[Any]] - //format: on + case _ => + val types = meth.paramSymss.head.map(_.termRef.typeSymbol.typeRef.asType) + + '{ (input: List[Any]) => + ${ + selectMethod + .appliedToArgs { + types + .zipWithIndex + .map { (tpe, idx) => + tpe match { + case '[t] => '{ input(${ Expr(idx) }).asInstanceOf[t] }.asTerm + } + } + .toList } - } + .asExprOf[IO[Any]] + } } - case _ => - report.errorAndAbort( - "Only methods with one parameter are supported", - meth.pos.getOrElse(Position.ofMacroExpansion), - ) } - } .map(Expr.ofTuple(_)) } val asFunction: Expr[Alg => AsFunction] = '{ (alg: Alg) => - val functionsByName: Map[String, Any => IO[Any]] = ${ functionsFor('alg) }.toMap + val functionsByName: Map[String, List[Any] => IO[Any]] = ${ functionsFor('alg) }.toMap new AsFunction { def apply[In, Out]( endpointName: String, in: In, - ): IO[Out] = functionsByName(endpointName)(in).asInstanceOf[IO[Out]] + ): IO[Out] = functionsByName(endpointName)(in.asInstanceOf[List[Any]]) + .asInstanceOf[IO[Out]] } } @@ -169,6 +162,33 @@ object API { '{ API.instance[Alg](${ Expr.ofList(endpoints) }, ${ asFunction }, ${ fromFunction }) } } + private inline def combineCodecs( + codecs: List[(String, Expr[Codec[?]])] + )( + using Quotes + ): Expr[Codec[List[Any]]] = + '{ + combineCodecsRuntime( + ${ + Expr.ofList { + codecs.map { case (k, v) => Expr.ofTuple((Expr(k), v)) } + } + } + ) + } + + private def combineCodecsRuntime( + codecs: List[(String, Codec[?])] + ): Codec[List[Any]] = Codec.from( + codecs.traverse { case (k, decoder) => decoder.at(k).widen }, + input => + Json.obj( + input.zip(codecs).map { case (param, (k, encoder)) => + k -> encoder.asInstanceOf[Encoder[Any]](param) + }: _* + ), + ) + private def proxy[Trait: Type](using Quotes)(asf: Expr[AsFunction]) = { import quotes.reflect.* val parents = List(TypeTree.of[Object], TypeTree.of[Trait]) @@ -204,20 +224,21 @@ object API { .asInstanceOf[Symbol] val body: List[DefDef] = cls.declaredMethods.map { sym => - def undefinedTerm(args: List[List[Tree]]) = { + def impl(args: List[List[Tree]]) = { args.head match { - case Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, ()) } - case one :: Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, ${ one.asExprOf[Any] }) } - case _ => - report.errorAndAbort( - "Only methods with one parameter are supported", - sym.pos.getOrElse(Position.ofMacroExpansion), - ) + case Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, Nil) } + case atLeastOne => + '{ + ${ asf }.apply( + ${ Expr(sym.name) }, + ${ Expr.ofList(atLeastOne.map(_.asExprOf[Any])) }, + ) + } } }.asTerm - DefDef(sym, args => Some(undefinedTerm(args))) + DefDef(sym, args => Some(impl(args))) } // The definition is experimental and I didn't want to bother. diff --git a/core/src/test/scala/respectfully/test/ClientTests.scala b/core/src/test/scala/respectfully/test/ClientTests.scala index 8ac3781..b7db503 100644 --- a/core/src/test/scala/respectfully/test/ClientTests.scala +++ b/core/src/test/scala/respectfully/test/ClientTests.scala @@ -35,6 +35,7 @@ import cats.effect.kernel.Ref import io.circe.Json import org.typelevel.vault.Key import cats.effect.SyncIO +import cats.Show object ClientTests extends SimpleIOSuite { @@ -67,8 +68,11 @@ object ClientTests extends SimpleIOSuite { private def methodHeader(req: Request[IO]): String = req.headers.get(CIString("X-Method")).get.head.value - private def bodyJsonDecode[A: Decoder](req: Request[IO]): A = - req.attributes.lookup(BodyJson).get.as[A].toTry.get + private def bodyJsonDecode[A: Decoder]( + req: Request[IO] + )( + modDecoder: Decoder[A] => Decoder[A] + ): A = req.attributes.lookup(BodyJson).get.as[A](modDecoder(summon[Decoder[A]])).toTry.get test("one op") { trait SimpleApi derives API { @@ -93,13 +97,13 @@ object ClientTests extends SimpleIOSuite { API[SimpleApi].toClient(client, uri).operation(42).map(assert.eql("output", _)) *> captured.map { req => assert.eql("operation", methodHeader(req)) && - assert.eql(42, bodyJsonDecode[Int](req)) + assert.eql(42, bodyJsonDecode[Int](req)(_.at("a"))) } } } test("one op with more complex param") { - case class Person(name: String, age: Int) derives Codec.AsObject, Eq + case class Person(name: String, age: Int) derives Codec.AsObject, Eq, Show trait SimpleApi derives API { def operation(a: Person): IO[Person] @@ -112,7 +116,23 @@ object ClientTests extends SimpleIOSuite { .map(assert.eql(Person("John", 43), _)) *> captured.map { req => assert.eql("operation", methodHeader(req)) && - assert.eql(Person("John", 42), bodyJsonDecode[Person](req)) + assert.eql(Person("John", 42), bodyJsonDecode[Person](req)(_.at("a"))) + } + } + } + + test("one op with two params") { + + trait SimpleApi derives API { + def operation(a: Int, b: String): IO[String] + } + + fakeClient("42 foo").flatMap { (client, uri, captured) => + API[SimpleApi].toClient(client, uri).operation(42, "foo").map(assert.eql("42 foo", _)) *> + captured.map { req => + assert.eql("operation", methodHeader(req)) && + assert.eql(42, bodyJsonDecode[Int](req)(_.at("a"))) && + assert.eql("foo", bodyJsonDecode[String](req)(_.at("b"))) } } } diff --git a/core/src/test/scala/respectfully/test/ServerTests.scala b/core/src/test/scala/respectfully/test/ServerTests.scala index 6253c91..65ccfac 100644 --- a/core/src/test/scala/respectfully/test/ServerTests.scala +++ b/core/src/test/scala/respectfully/test/ServerTests.scala @@ -31,6 +31,10 @@ import io.circe.Decoder import cats.kernel.Eq import cats.derived._ import io.circe.Codec +import io.circe.syntax._ +import io.circe.Json +import io.circe.JsonObject +import cats.Show object ServerTests extends SimpleIOSuite { pureTest("no ops") { @@ -38,10 +42,10 @@ object ServerTests extends SimpleIOSuite { success } - private def request[A: Encoder]( + private def request( method: String )( - body: A + body: JsonObject ) = Request[IO](Method.POST) .withEntity(body) .withHeaders("X-Method" -> method) @@ -63,7 +67,7 @@ object ServerTests extends SimpleIOSuite { API[SimpleApi] .toRoutes(impl) - .run(request("op")(())) + .run(request("op")(JsonObject.empty)) .flatMap(assertSuccess(_, 42)) } @@ -76,12 +80,12 @@ object ServerTests extends SimpleIOSuite { API[SimpleApi] .toRoutes(impl) - .run(request("operation")(42)) + .run(request("operation")(JsonObject("a" := 42))) .flatMap(assertSuccess(_, 43)) } test("one op with more complex param") { - case class Person(name: String, age: Int) derives Codec.AsObject, Eq + case class Person(name: String, age: Int) derives Codec.AsObject, Eq, Show trait SimpleApi derives API { def operation(a: Person): IO[Person] @@ -91,7 +95,21 @@ object ServerTests extends SimpleIOSuite { API[SimpleApi] .toRoutes(impl) - .run(request("operation")(Person("John", 42))) + .run(request("operation")(JsonObject("a" := Person("John", 42)))) .flatMap(assertSuccess(_, Person("John", 43))) } + + test("two params") { + + trait SimpleApi derives API { + def operation(a: Int, b: String): IO[String] + } + + val impl: SimpleApi = (a, b) => IO.pure(s"$a $b") + + API[SimpleApi] + .toRoutes(impl) + .run(request("operation")(JsonObject("a" := 42, "b" := "John"))) + .flatMap(assertSuccess(_, "42 John")) + } }