diff --git a/.scalafmt.conf b/.scalafmt.conf index b32145d..50e3533 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,5 +1,7 @@ -runner.dialect = "scala3" -version = 3.5.8 +version = 3.7.17 +runner.dialect = scala3 +runner.dialectOverride.allowSignificantIndentation = false + maxColumn = 100 align.preset = some diff --git a/build.sbt b/build.sbt index 7fde2f1..28b07c0 100644 --- a/build.sbt +++ b/build.sbt @@ -30,7 +30,8 @@ val commonSettings = Seq( ) ++ compilerPlugins, scalacOptions ++= Seq( - "-Wunused:all" + "-Wunused:all", + "-no-indent", ), ) diff --git a/core/src/main/scala/respectfully/API.scala b/core/src/main/scala/respectfully/API.scala index 9eb3602..3dc4b4c 100644 --- a/core/src/main/scala/respectfully/API.scala +++ b/core/src/main/scala/respectfully/API.scala @@ -40,47 +40,48 @@ import scala.quoted.Type import scala.quoted.quotes trait API[Alg] { - def toRoutes: Alg => HttpApp[IO] - def toClient: (Client[IO], Uri) => Alg + def toRoutes(impl: Alg): HttpApp[IO] + def toClient(client: Client[IO], uri: Uri): Alg } object API { - def apply[Alg](using api: API[Alg]): API[Alg] = api + def apply[Alg]( + using api: API[Alg] + ): API[Alg] = api inline def derived[Alg]: API[Alg] = ${ derivedImpl[Alg] } - private def derivedImpl[Alg: Type](using Quotes): Expr[API[Alg]] = { + private def derivedImpl[Alg: Type]( + using Quotes + ): Expr[API[Alg]] = { import quotes.reflect.{TypeRepr, report, DefDef, Position, asTerm} 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 inputCodec = - meth.paramSymss.head match { - case Nil => '{ Codec.from(Decoder[Unit], Encoder[Unit]) } - - case one :: Nil => /* ok */ - one.termRef.typeSymbol.typeRef.asType match { - case '[t] => - '{ - Codec.from( - summonInline[Decoder[t]], - summonInline[Encoder[t]], - ) - } - } + 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(", ")}" + ) - case _ => - report.errorAndAbort( - "Only methods with one parameter are supported", - meth.pos.getOrElse(Position.ofMacroExpansion), - ) + val inputCodec: Expr[Codec[List[List[Any]]]] = combineCodecs { + meth.paramSymss.map { + _.map { one => + val codec = + one.termRef.typeSymbol.typeRef.asType match { + case '[t] => + '{ + Codec.from( + summonInline[Decoder[t]], + summonInline[Encoder[t]], + ) + } + } + one.termRef.termSymbol.name -> codec + } } + } val outputCodec = meth.tree.asInstanceOf[DefDef].returnTpt.tpe.asType match { @@ -114,52 +115,56 @@ object API { } } - def functionsFor(algExpr: Expr[Alg]): Expr[List[(String, 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 => - meth.paramSymss.head match { - case Nil => - // special-case: nullary method - Expr(meth.name) -> '{ (_: Any) => - ${ algExpr.asTerm.select(meth).appliedToNone.asExprOf[IO[Any]] } - } + val selectMethod = algExpr.asTerm.select(meth) - 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 + 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.map(_.map(_.termRef.typeSymbol.typeRef.asType)) + + '{ (input: List[List[Any]]) => + ${ + selectMethod + .appliedToArgss { + types + .zipWithIndex + .map { (tpeList, idx0) => + tpeList.zipWithIndex.map { (tpe, idx1) => + tpe match { + case '[t] => + '{ input(${ Expr(idx0) })(${ Expr(idx1) }).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[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[List[Any]]]) + .asInstanceOf[IO[Out]] } } @@ -169,7 +174,44 @@ object API { '{ API.instance[Alg](${ Expr.ofList(endpoints) }, ${ asFunction }, ${ fromFunction }) } } - private def proxy[Trait: Type](using Quotes)(asf: Expr[AsFunction]) = { + private inline def combineCodecs( + codecss: List[List[(String, Expr[Codec[?]])]] + )( + using Quotes + ): Expr[Codec[List[List[Any]]]] = + '{ + combineCodecsRuntime( + ${ + Expr.ofList { + codecss.map { codecs => + Expr.ofList( + codecs.map { case (k, v) => Expr.ofTuple((Expr(k), v)) } + ) + } + } + } + ) + } + + private def combineCodecsRuntime( + codecss: List[List[(String, Codec[?])]] + ): Codec[List[List[Any]]] = Codec.from( + codecss.traverse(_.traverse { case (k, decoder) => decoder.at(k).widen }), + inputss => + Json.obj( + inputss.zip(codecss).flatMap { (inputs, codecs) => + inputs.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 +246,28 @@ object API { .asInstanceOf[Symbol] val body: List[DefDef] = cls.declaredMethods.map { sym => - def undefinedTerm(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] }) } + def impl(argss: List[List[Tree]]) = { + argss match { + case Nil :: Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, Nil) } case _ => - report.errorAndAbort( - "Only methods with one parameter are supported", - sym.pos.getOrElse(Position.ofMacroExpansion), - ) + '{ + ${ asf }.apply( + endpointName = ${ Expr(sym.name) }, + in = + ${ + Expr.ofList(argss.map { argList => + Expr.ofList( + argList.map(_.asExprOf[Any]) + ) + }) + }, + ) + } } }.asTerm - DefDef(sym, args => Some(undefinedTerm(args))) + DefDef(sym, argss => Some(impl(argss))) } // The definition is experimental and I didn't want to bother. @@ -253,51 +303,49 @@ object API { new API[Alg] { private val endpointsByName = endpoints.groupBy(_.name).fmap(_.head) - override val toClient: (Client[IO], Uri) => Alg = - (c, uri) => - fromFunction { - new AsFunction { - override def apply[In, Out](endpointName: String, in: In): IO[Out] = { - val e = endpointsByName(endpointName).asInstanceOf[Endpoint[In, Out]] + override def toClient(c: Client[IO], uri: Uri): Alg = fromFunction { + new AsFunction { + override def apply[In, Out](endpointName: String, in: In): IO[Out] = { + val e = endpointsByName(endpointName).asInstanceOf[Endpoint[In, Out]] - given Codec[e.Out] = e.output + given Codec[e.Out] = e.output - def write( - methodName: String, - input: Json, - ): Request[IO] = Request[IO](uri = uri, method = Method.POST) - .withHeaders(Header.Raw(CIString("X-Method"), methodName)) - .withEntity(input) + def write( + methodName: String, + input: Json, + ): Request[IO] = Request[IO](uri = uri, method = Method.POST) + .withHeaders(Header.Raw(CIString("X-Method"), methodName)) + .withEntity(input) - c.expect[e.Out](write(e.name, e.input.apply(in))) - } - } + c.expect[e.Out](write(e.name, e.input.apply(in))) } + } + } - override val toRoutes: Alg => HttpApp[IO] = - impl => - val implFunction = asFunction(impl) - - HttpApp { req => - val methodName: String = - req - .headers - .get(CIString("X-Method")) - .getOrElse(sys.error("missing X-Method header")) - .head - .value + override def toRoutes(impl: Alg): HttpApp[IO] = { + val implFunction = asFunction(impl) + + HttpApp { req => + val methodName: String = req - .as[Json] - .flatMap { input => - val e = endpointsByName(methodName) - - e.input - .decodeJson(input) - .liftTo[IO] - .flatMap(implFunction.apply[e.In, e.Out](e.name, _).map(e.output.apply(_))) - } - .map(Response[IO]().withEntity(_)) - } + .headers + .get(CIString("X-Method")) + .getOrElse(sys.error("missing X-Method header")) + .head + .value + req + .as[Json] + .flatMap { input => + val e = endpointsByName(methodName) + + e.input + .decodeJson(input) + .liftTo[IO] + .flatMap(implFunction.apply[e.In, e.Out](e.name, _).map(e.output.apply(_))) + } + .map(Response[IO]().withEntity(_)) + } + } } diff --git a/core/src/test/scala/respectfully/test/ClientTests.scala b/core/src/test/scala/respectfully/test/ClientTests.scala index 8ac3781..5658084 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 { @@ -84,6 +88,20 @@ object ClientTests extends SimpleIOSuite { } } + test("one op without parameter lists") { + trait SimpleApi derives API { + def op: IO[Int] + } + + fakeClient(42).flatMap { (client, uri, captured) => + API[SimpleApi].toClient(client, uri).op.map(assert.eql(42, _)) *> + captured.map { req => + assert.eql("op", methodHeader(req)) && + succeed(bodyJsonDecode[Unit](req)) + } + } + } + test("one op with param") { trait SimpleApi derives API { def operation(a: Int): IO[String] @@ -93,13 +111,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 +130,39 @@ 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"))) + } + } + } + + 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"))) } } } diff --git a/core/src/test/scala/respectfully/test/ServerTests.scala b/core/src/test/scala/respectfully/test/ServerTests.scala index 6253c91..6f42f01 100644 --- a/core/src/test/scala/respectfully/test/ServerTests.scala +++ b/core/src/test/scala/respectfully/test/ServerTests.scala @@ -25,12 +25,15 @@ 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 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 +41,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 +66,23 @@ object ServerTests extends SimpleIOSuite { API[SimpleApi] .toRoutes(impl) - .run(request("op")(())) + .run(request("op")(JsonObject.empty)) + .flatMap(assertSuccess(_, 42)) + } + + test("one op without parameter lists") { + trait SimpleApi derives API { + def op: IO[Int] + } + + val impl: SimpleApi = + new SimpleApi { + def op: IO[Int] = IO.pure(42) + } + + API[SimpleApi] + .toRoutes(impl) + .run(request("op")(JsonObject.empty)) .flatMap(assertSuccess(_, 42)) } @@ -76,12 +95,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 +110,38 @@ 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")) + } + + 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")) + } }