Skip to content

Commit

Permalink
Support multiple params
Browse files Browse the repository at this point in the history
  • Loading branch information
kubukoz committed Feb 3, 2024
1 parent 62ac2cf commit 52e649c
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 57 deletions.
113 changes: 67 additions & 46 deletions core/src/main/scala/respectfully/API.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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] =>
'{
Expand All @@ -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 {
Expand Down Expand Up @@ -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]]

}
}
Expand All @@ -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])
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 25 additions & 5 deletions core/src/test/scala/respectfully/test/ClientTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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 {
Expand All @@ -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]
Expand All @@ -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")))
}
}
}
Expand Down
30 changes: 24 additions & 6 deletions core/src/test/scala/respectfully/test/ServerTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ 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") {
trait SimpleApi derives API
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)
Expand All @@ -63,7 +67,7 @@ object ServerTests extends SimpleIOSuite {

API[SimpleApi]
.toRoutes(impl)
.run(request("op")(()))
.run(request("op")(JsonObject.empty))
.flatMap(assertSuccess(_, 42))
}

Expand All @@ -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]
Expand All @@ -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"))
}
}

0 comments on commit 52e649c

Please sign in to comment.