Skip to content

Commit

Permalink
Support multiple parameter lists
Browse files Browse the repository at this point in the history
  • Loading branch information
kubukoz committed Feb 3, 2024
1 parent 52e649c commit 76701c9
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 62 deletions.
143 changes: 82 additions & 61 deletions core/src/main/scala/respectfully/API.scala
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ object API {

val algTpe = TypeRepr.of[Alg]
val endpoints = algTpe.typeSymbol.declaredMethods.map { meth =>
require(
meth.paramSymss.size == 1,
"Only methods with one parameter list are supported, got: " + meth.paramSymss + " for " + meth.name,
)
val typeParameters = meth.paramSymss.flatten.filter(_.isTypeParam)
if (typeParameters.nonEmpty)
report.errorAndAbort(
s"Methods with type parameters are not supported. `${meth.name}` has type parameters: ${typeParameters.map(_.name).mkString(", ")}"
)

val inputCodec = combineCodecs {
meth.paramSymss.head.map { one =>
val inputCodec: Expr[Codec[List[List[Any]]]] = combineCodecs {
meth.paramSymss.map {
_.map { one =>
val codec =
one.termRef.typeSymbol.typeRef.asType match {
case '[t] =>
Expand All @@ -75,63 +77,69 @@ object API {
one.termRef.termSymbol.name -> codec
}
}
}

val outputCodec =
meth.tree.asInstanceOf[DefDef].returnTpt.tpe.asType match {
case '[IO[t]] =>
'{
Codec.from(
summonInline[Decoder[t]],
summonInline[Encoder[t]],
)
}
case other =>
val typeStr =
TypeRepr
.of(
using other
)
.show

report.errorAndAbort(
s"Only methods returning IO are supported. Found: $typeStr",
meth.pos.getOrElse(Position.ofMacroExpansion),
val outputCodec =
meth.tree.asInstanceOf[DefDef].returnTpt.tpe.asType match {
case '[IO[t]] =>
'{
Codec.from(
summonInline[Decoder[t]],
summonInline[Encoder[t]],
)
}
}
case other =>
val typeStr =
TypeRepr
.of(
using other
)
.show

'{
Endpoint[Any, Any](
${ Expr(meth.name) },
${ inputCodec }.asInstanceOf[Codec[Any]],
${ outputCodec }.asInstanceOf[Codec[Any]],
)
report.errorAndAbort(
s"Only methods returning IO are supported. Found: $typeStr",
meth.pos.getOrElse(Position.ofMacroExpansion),
)
}

'{
Endpoint[Any, Any](
${ Expr(meth.name) },
${ inputCodec }.asInstanceOf[Codec[Any]],
${ outputCodec }.asInstanceOf[Codec[Any]],
)
}
}

def functionsFor(algExpr: Expr[Alg]): Expr[List[(String, List[Any] => IO[Any])]] = Expr.ofList {
def functionsFor(
algExpr: Expr[Alg]
): Expr[List[(String, List[List[Any]] => IO[Any])]] = Expr.ofList {
algTpe
.typeSymbol
.declaredMethods
.map { meth =>
val selectMethod = algExpr.asTerm.select(meth)

Expr(meth.name) -> meth.paramSymss.head.match {
case Nil =>
// special-case: nullary method
Expr(meth.name) -> meth.paramSymss.match {
case Nil :: Nil =>
// special-case: nullary method (one, zero-parameter list)
'{ Function.const(${ selectMethod.appliedToNone.asExprOf[IO[Any]] }) }

case _ =>
val types = meth.paramSymss.head.map(_.termRef.typeSymbol.typeRef.asType)
val types = meth.paramSymss.map(_.map(_.termRef.typeSymbol.typeRef.asType))

'{ (input: List[Any]) =>
'{ (input: List[List[Any]]) =>
${
selectMethod
.appliedToArgs {
.appliedToArgss {
types
.zipWithIndex
.map { (tpe, idx) =>
tpe match {
case '[t] => '{ input(${ Expr(idx) }).asInstanceOf[t] }.asTerm
.map { (tpeList, idx0) =>
tpeList.zipWithIndex.map { (tpe, idx1) =>
tpe match {
case '[t] =>
'{ input(${ Expr(idx0) })(${ Expr(idx1) }).asInstanceOf[t] }.asTerm
}
}
}
.toList
Expand All @@ -146,12 +154,12 @@ object API {

val asFunction: Expr[Alg => AsFunction] =
'{ (alg: Alg) =>
val functionsByName: Map[String, List[Any] => IO[Any]] = ${ functionsFor('alg) }.toMap
val functionsByName: Map[String, List[List[Any]] => IO[Any]] = ${ functionsFor('alg) }.toMap
new AsFunction {
def apply[In, Out](
endpointName: String,
in: In,
): IO[Out] = functionsByName(endpointName)(in.asInstanceOf[List[Any]])
): IO[Out] = functionsByName(endpointName)(in.asInstanceOf[List[List[Any]]])
.asInstanceOf[IO[Out]]

}
Expand All @@ -163,28 +171,34 @@ object API {
}

private inline def combineCodecs(
codecs: List[(String, Expr[Codec[?]])]
codecss: List[List[(String, Expr[Codec[?]])]]
)(
using Quotes
): Expr[Codec[List[Any]]] =
): Expr[Codec[List[List[Any]]]] =
'{
combineCodecsRuntime(
${
Expr.ofList {
codecs.map { case (k, v) => Expr.ofTuple((Expr(k), v)) }
codecss.map { codecs =>
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 =>
codecss: List[List[(String, Codec[?])]]
): Codec[List[List[Any]]] = Codec.from(
codecss.traverse(_.traverse { case (k, decoder) => decoder.at(k).widen }),
inputss =>
Json.obj(
input.zip(codecs).map { case (param, (k, encoder)) =>
k -> encoder.asInstanceOf[Encoder[Any]](param)
inputss.zip(codecss).flatMap { (inputs, codecs) =>
inputs.zip(codecs).map { case (param, (k, encoder)) =>
k -> encoder.asInstanceOf[Encoder[Any]](param)
}
}: _*
),
)
Expand Down Expand Up @@ -224,21 +238,28 @@ object API {
.asInstanceOf[Symbol]

val body: List[DefDef] = cls.declaredMethods.map { sym =>
def impl(args: List[List[Tree]]) = {
args.head match {
case Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, Nil) }
case atLeastOne =>
def impl(argss: List[List[Tree]]) = {
argss match {
case Nil :: Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, Nil) }
case _ =>
'{
${ asf }.apply(
${ Expr(sym.name) },
${ Expr.ofList(atLeastOne.map(_.asExprOf[Any])) },
endpointName = ${ Expr(sym.name) },
in =
${
Expr.ofList(argss.map { argList =>
Expr.ofList(
argList.map(_.asExprOf[Any])
)
})
},
)
}
}

}.asTerm

DefDef(sym, args => Some(impl(args)))
DefDef(sym, argss => Some(impl(argss)))
}

// The definition is experimental and I didn't want to bother.
Expand Down
16 changes: 16 additions & 0 deletions core/src/test/scala/respectfully/test/ClientTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,20 @@ object ClientTests extends SimpleIOSuite {
}
}

test("one op with two parameter lists") {

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")))
}
}
}

}
18 changes: 17 additions & 1 deletion core/src/test/scala/respectfully/test/ServerTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import org.http4s.circe.CirceEntityCodec._
import respectfully.API
import weaver._
import org.http4s.Status
import cats.syntax.all.*
import org.http4s.Response
import io.circe.Decoder
import cats.kernel.Eq
Expand Down Expand Up @@ -112,4 +111,21 @@ object ServerTests extends SimpleIOSuite {
.run(request("operation")(JsonObject("a" := 42, "b" := "John")))
.flatMap(assertSuccess(_, "42 John"))
}

test("two parameter lists") {

trait SimpleApi derives API {
def operation(a: Int)(b: String): IO[String]
}

val impl: SimpleApi =
new SimpleApi {
def operation(a: Int)(b: String): IO[String] = 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 76701c9

Please sign in to comment.