From 3212f1fa20f3f8ceb6aca2bcdbd4e884ac4f9ce7 Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Fri, 27 Mar 2020 19:22:32 +0100 Subject: [PATCH 01/14] Generate wrapper classes for POST bodies --- .../scala/io/buildo/tapiro/AkkaHttpMeta.scala | 30 +++-- .../scala/io/buildo/tapiro/Http4sMeta.scala | 26 ++-- .../main/scala/io/buildo/tapiro/Meta.scala | 22 ++-- .../scala/io/buildo/tapiro/TapirMeta.scala | 123 ++++++++++++------ .../main/scala/io/buildo/tapiro/Util.scala | 2 + 5 files changed, 137 insertions(+), 66 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala index d2e5ef92..a0a936b3 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala @@ -1,6 +1,6 @@ package io.buildo.tapiro -import io.buildo.metarpheus.core.intermediate.Route +import io.buildo.metarpheus.core.intermediate.{Route, Type => MetarpheusType} import scala.meta._ @@ -19,11 +19,12 @@ object AkkaHttpMeta { q""" package ${`package`} { ..${imports.toList.map(i => q"import $i._")} + import akka.http.scaladsl.server._ + import akka.http.scaladsl.server.Directives._ + import io.circe.{ Decoder, Encoder } import sttp.tapir.server.akkahttp._ import sttp.tapir.Codec.{ JsonCodec, PlainCodec } import sttp.model.StatusCode - import akka.http.scaladsl.server._ - import akka.http.scaladsl.server.Directives._ object $httpEndpointsName { def routes[AuthToken](controller: $controllerName[AuthToken], statusCodes: String => StatusCode = _ => StatusCode.UnprocessableEntity)(..$implicits): Route = { @@ -41,16 +42,25 @@ object AkkaHttpMeta { } val endpoints = (routes: List[Route]) => - routes.flatMap { route => + routes.map { route => val name = Term.Name(route.name.last) val endpointsName = Term.Select(Term.Name("endpoints"), name) val controllersName = Term.Select(Term.Name("controller"), name) val controllerContent = - if (route.params.length <= 1) Some(controllersName) - else Some(Term.Select(Term.Eta(controllersName), Term.Name("tupled"))) - controllerContent.map { content => - val toRoute = Term.Apply(Term.Select(endpointsName, Term.Name("toRoute")), List(content)) - q"val ${Pat.Var(name)} = $toRoute" - } + route.method match { + case "get" => + if (route.params.length <= 1) controllersName + else Term.Select(Term.Eta(controllersName), Term.Name("tupled")) + case "post" => + val fields = route.params + .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) + .map(p => Term.Name(p.name.getOrElse(Meta.typeNameString(p.tpe)))) + q"x => $controllersName(..${fields.map(f => q"x.$f")})" + case _ => + throw new Exception("method not supported") + } + val toRoute = + Term.Apply(Term.Select(endpointsName, Term.Name("toRoute")), List(controllerContent)) + q"val ${Pat.Var(name)} = $toRoute" } } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala index 105b7bcf..7948f90d 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala @@ -1,6 +1,6 @@ package io.buildo.tapiro -import io.buildo.metarpheus.core.intermediate.Route +import io.buildo.metarpheus.core.intermediate.{Route, Type => MetarpheusType} import scala.meta._ @@ -22,6 +22,7 @@ object Http4sMeta { import cats.effect._ import cats.implicits._ import cats.data.NonEmptyList + import io.circe.{ Decoder, Encoder } import org.http4s._ import org.http4s.server.Router import sttp.tapir.server.http4s._ @@ -45,16 +46,25 @@ object Http4sMeta { } val endpoints = (routes: List[Route]) => - routes.flatMap { route => + routes.map { route => val name = Term.Name(route.name.last) val endpointsName = Term.Select(Term.Name("endpoints"), name) val controllersName = Term.Select(Term.Name("controller"), name) val controllerContent = - if (route.params.length <= 1) Some(controllersName) - else Some(Term.Select(Term.Eta(controllersName), Term.Name("tupled"))) - controllerContent.map { content => - val toRoutes = Term.Apply(Term.Select(endpointsName, Term.Name("toRoutes")), List(content)) - q"val ${Pat.Var(name)} = $toRoutes" - } + route.method match { + case "get" => + if (route.params.length <= 1) controllersName + else Term.Select(Term.Eta(controllersName), Term.Name("tupled")) + case "post" => + val fields = route.params + .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) + .map(p => Term.Name(p.name.getOrElse(Meta.typeNameString(p.tpe)))) + q"x => $controllersName(..${fields.map(f => q"x.$f")})" + case _ => + throw new Exception("method not supported") + } + val toRoutes = + Term.Apply(Term.Select(endpointsName, Term.Name("toRoutes")), List(controllerContent)) + q"val ${Pat.Var(name)} = $toRoutes" } } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala index eae28e37..11eee7d9 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala @@ -10,17 +10,14 @@ object Meta { val codecsImplicits = (routes: List[TapiroRoute]) => { val jsonCodecs = (routes.flatMap { case TapiroRoute(route, error) => - val params: List[MetarpheusType] = route.params.map(_.tpe) - ((if (route.method == "post") params else Nil) ++ - (error match { - case TapiroRouteError.OtherError(t) => List(t) - case _ => Nil - }) :+ + ((error match { + case TapiroRouteError.OtherError(t) => List(t) + case _ => Nil + }) :+ route.returns) }.distinct .filter(t => typeNameString(t) != "Unit") //no json codec for Unit in tapir .filter(t => typeNameString(t) != "String") - .filter(t => typeNameString(t) != "AuthToken") .map(toScalametaType) ++ taggedUnionErrorMembers(routes)) .map(t => t"JsonCodec[$t]") @@ -29,10 +26,19 @@ object Meta { (if (route.method == "get") route.params.map(_.tpe) else Nil) ++ route.params.map(_.tpe).filter(typeNameString(_) == "AuthToken") }.distinct.map(t => t"PlainCodec[${toScalametaType(t)}]") - val codecs = jsonCodecs ++ plainCodecs + val circeCodecs = routes.flatMap { + case TapiroRoute(route, _) => + if (route.method == "post") route.params.map(_.tpe).filterNot(isAuthToken) + else Nil + }.distinct.flatMap { t => + List(t"Decoder[${toScalametaType(t)}]", t"Encoder[${toScalametaType(t)}]") + } + val codecs = jsonCodecs ++ plainCodecs ++ circeCodecs codecs.zipWithIndex.map(toImplicitParam.tupled) } + private[this] val isAuthToken = (t: MetarpheusType) => t == MetarpheusType.Name("AuthToken") + private[this] val taggedUnionErrorMembers = (routes: List[TapiroRoute]) => { val taggedUnions = routes.collect { case TapiroRoute(_, TapiroRouteError.TaggedUnionError(tu)) => tu diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala index deb04517..755f136a 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala @@ -1,6 +1,11 @@ package io.buildo.tapiro -import io.buildo.metarpheus.core.intermediate.{RouteParam, TaggedUnion, Type => MetarpheusType} +import io.buildo.metarpheus.core.intermediate.{ + Route, + RouteParam, + TaggedUnion, + Type => MetarpheusType, +} import scala.meta._ @@ -15,11 +20,16 @@ object TapirMeta { tapirEndpointsName: Term.Name, implicits: List[Term.Param], body: List[Defn.Val], + postInputClassDeclarations: List[Defn.Class], + postInputCodecDeclarations: List[Defn.Val], ) => q""" package ${`package`} { ..${imports.toList.map(i => q"import $i._")} + import io.circe.{ Decoder, Encoder } + import io.circe.generic.semiauto.{ deriveDecoder, deriveEncoder } import sttp.tapir._ + import sttp.tapir.json.circe._ import sttp.tapir.Codec.{ JsonCodec, PlainCodec } import sttp.model.StatusCode @@ -32,10 +42,13 @@ object TapirMeta { Type.Name(tapirEndpointsName.value), Name.Anonymous(), Nil, - )}[AuthToken] { ..${body.map( - d => d.copy(mods = mod"override" :: d.mods), - )} } + )}[AuthToken] { + ..${postInputCodecDeclarations} + ..${body.map(d => d.copy(mods = mod"override" :: d.mods))} + } } + + ..${postInputClassDeclarations} } """ @@ -44,11 +57,18 @@ object TapirMeta { private[this] val endpointType = (route: TapiroRoute) => { val returnType = toScalametaType(route.route.returns) - val argsList = route.route.params.map(p => toScalametaType(p.tpe)) - val argsType = argsList match { - case Nil => Type.Name("Unit") - case head :: Nil => head - case l => Type.Tuple(l) + val argsType = route.route.method match { + case "get" => + val argsList = route.route.params.map(p => toScalametaType(p.tpe)) + argsList match { + case Nil => Type.Name("Unit") + case head :: Nil => head + case l => Type.Tuple(l) + } + case "post" => + postInputType(route.route) + case _ => + throw new Exception("method not supported") } val error = toScalametaType(route.error match { case TapiroRouteError.TaggedUnionError(t) => MetarpheusType.Name(t.name) @@ -63,8 +83,8 @@ object TapirMeta { .Select(Term.Select(Term.Name("endpoint"), Term.Name(route.route.method)), Term.Name("in")), List(Lit.String(route.route.name.tail.mkString)), ) - val (auth, params) = route.route.params.partition(_.tpe == MetarpheusType.Name(authTokenName)) - val endpointsWithParams = withParams(basicEndpoint, route.route.method, params) + val (auth, _) = route.route.params.partition(_.tpe == MetarpheusType.Name(authTokenName)) + val endpointsWithParams = withParams(basicEndpoint, route.route) withOutput( withError( auth match { @@ -91,25 +111,22 @@ object TapirMeta { ), ) - private[this] val withParams = - (endpoint: meta.Term, method: String, params: List[RouteParam]) => { - method match { - case "get" => - params.foldLeft(endpoint) { (acc, param) => - withParam(acc, param) - } - case "post" => - params.foldLeft(endpoint) { (acc, param) => - withBody(acc, param.tpe) - } - case _ => throw new Exception("method not supported") - }, - } + private[this] val withParams = (endpoint: meta.Term, route: Route) => { + route.method match { + case "get" => + route.params.foldLeft(endpoint) { (acc, param) => + withParam(acc, param) + } + case "post" => + withBody(endpoint, route) + case _ => throw new Exception("method not supported") + }, + } - private[this] val withBody = (endpoint: meta.Term, tpe: MetarpheusType) => { + private[this] val withBody = (endpoint: meta.Term, route: Route) => { Term.Apply( Term.Select(endpoint, Term.Name("in")), - List(Term.ApplyType(Term.Name("jsonBody"), List(toScalametaType(tpe)))), + List(Term.ApplyType(Term.Name("jsonBody"), List(postInputType(route)))), ), } @@ -117,19 +134,20 @@ object TapirMeta { (endpoints: meta.Term, routeError: TapiroRouteError) => routeError match { case TapiroRouteError.OtherError(t) if typeNameString(t) == "Unit" => endpoints - case _ => Term.Apply( - Term.Select(endpoints, Term.Name("errorOut")), - List( - routeError match { - case TapiroRouteError.TaggedUnionError(taggedUnion) => - listErrors(taggedUnion) - case TapiroRouteError.OtherError(MetarpheusType.Name("String")) => - Term.Name("stringBody") - case TapiroRouteError.OtherError(t) => - Term.ApplyType(Term.Name("jsonBody"), List(toScalametaType(t))) - }, - ), - ) + case _ => + Term.Apply( + Term.Select(endpoints, Term.Name("errorOut")), + List( + routeError match { + case TapiroRouteError.TaggedUnionError(taggedUnion) => + listErrors(taggedUnion) + case TapiroRouteError.OtherError(MetarpheusType.Name("String")) => + Term.Name("stringBody") + case TapiroRouteError.OtherError(t) => + Term.ApplyType(Term.Name("jsonBody"), List(toScalametaType(t))) + }, + ), + ) } private[this] val listErrors = (taggedUnion: TaggedUnion) => @@ -185,4 +203,29 @@ object TapirMeta { Term.Apply(Term.Select(noDesc, Term.Name("description")), List(Lit.String(desc))) } } + + private[this] val postInputType = (route: Route) => + Type.Name(route.name.tail.mkString.capitalize + "RequestPayload") + + val routeClassDeclarations = (route: TapiroRoute) => + if (route.route.method == "post") { + val params = route.route.params + .filterNot(_.tpe == MetarpheusType.Name(authTokenName)) + .map { p => + param"${Term.Name(p.name.getOrElse(typeNameString(p.tpe)))}: ${toScalametaType(p.tpe)}" + } + List(q"case class ${postInputType(route.route)}(..$params)") + } else Nil + + val routeCodecDeclarations: TapiroRoute => List[Defn.Val] = (route: TapiroRoute) => { + val mkDeclaration = (s: String) => { + val name = Pat.Var(Term.Name(route.route.name.tail.mkString + "RequestPayload" + s)) + val tpe = postInputType(route.route) + val fun = Term.Name("derive" + s) + q"implicit val $name : ${Type.Name(s)}[$tpe] = $fun" + } + if (route.route.method == "post") { + List("Decoder", "Encoder").map(mkDeclaration) + } else Nil + } } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala index aceb3fd5..c88fdaa3 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala @@ -125,6 +125,8 @@ class Util() { Term.Name(tapirEndpointsName), Meta.codecsImplicits(routes), routes.map(TapirMeta.routeToTapirEndpoint), + routes.flatMap(TapirMeta.routeClassDeclarations), + routes.flatMap(TapirMeta.routeCodecDeclarations), ), ) } From 5e5510e88b8605c9fa27c7041e08a775c16d6bc8 Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Mon, 30 Mar 2020 10:30:54 +0200 Subject: [PATCH 02/14] Fix GET with no parameters --- tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala | 3 ++- tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala index a0a936b3..2e0f1991 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala @@ -49,7 +49,8 @@ object AkkaHttpMeta { val controllerContent = route.method match { case "get" => - if (route.params.length <= 1) controllersName + if (route.params.length == 0) q"_ => $controllersName()" + else if (route.params.length == 1) controllersName else Term.Select(Term.Eta(controllersName), Term.Name("tupled")) case "post" => val fields = route.params diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala index 7948f90d..e5050af9 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala @@ -53,7 +53,8 @@ object Http4sMeta { val controllerContent = route.method match { case "get" => - if (route.params.length <= 1) controllersName + if (route.params.length == 0) q"_ => $controllersName()" + else if (route.params.length == 1) controllersName else Term.Select(Term.Eta(controllersName), Term.Name("tupled")) case "post" => val fields = route.params From 411e96b9cbc0efd6beeac65767af7cd47c7226da Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Mon, 30 Mar 2020 10:34:43 +0200 Subject: [PATCH 03/14] Encode string output as JSON (reverting 0a856a8df6251fb0c9c98648f2f44b4a6db355a7) --- tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala | 1 - tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala | 5 ----- 2 files changed, 6 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala index 11eee7d9..74ed4691 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala @@ -17,7 +17,6 @@ object Meta { route.returns) }.distinct .filter(t => typeNameString(t) != "Unit") //no json codec for Unit in tapir - .filter(t => typeNameString(t) != "String") .map(toScalametaType) ++ taggedUnionErrorMembers(routes)) .map(t => t"JsonCodec[$t]") diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala index 755f136a..63ce98fb 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala @@ -168,11 +168,6 @@ object TapirMeta { typeNameString(returnType) match { case "Unit" => endpoint - case "String" => - Term.Apply( - Term.Select(endpoint, Term.Name("out")), - List(Term.Name("stringBody")), - ) case _ => Term.Apply( Term.Select(endpoint, Term.Name("out")), From a6ecd9f864575f28b14f58e43b09326c5bfbf81d Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Mon, 30 Mar 2020 10:55:13 +0200 Subject: [PATCH 04/14] Refactor: use more quasiquoting --- .../scala/io/buildo/tapiro/AkkaHttpMeta.scala | 15 ++++++++------- .../main/scala/io/buildo/tapiro/Http4sMeta.scala | 15 ++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala index 2e0f1991..91dbcb14 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala @@ -44,14 +44,16 @@ object AkkaHttpMeta { val endpoints = (routes: List[Route]) => routes.map { route => val name = Term.Name(route.name.last) - val endpointsName = Term.Select(Term.Name("endpoints"), name) - val controllersName = Term.Select(Term.Name("controller"), name) + val endpointsName = q"endpoints.$name" + val controllersName = q"controller.$name" val controllerContent = route.method match { case "get" => - if (route.params.length == 0) q"_ => $controllersName()" - else if (route.params.length == 1) controllersName - else Term.Select(Term.Eta(controllersName), Term.Name("tupled")) + route.params.length match { + case 0 => q"_ => $controllersName()" + case 1 => controllersName + case _ => q"($controllersName _).tupled" + } case "post" => val fields = route.params .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) @@ -60,8 +62,7 @@ object AkkaHttpMeta { case _ => throw new Exception("method not supported") } - val toRoute = - Term.Apply(Term.Select(endpointsName, Term.Name("toRoute")), List(controllerContent)) + val toRoute = q"$endpointsName.toRoute($controllerContent)" q"val ${Pat.Var(name)} = $toRoute" } } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala index e5050af9..761009e4 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala @@ -48,14 +48,16 @@ object Http4sMeta { val endpoints = (routes: List[Route]) => routes.map { route => val name = Term.Name(route.name.last) - val endpointsName = Term.Select(Term.Name("endpoints"), name) - val controllersName = Term.Select(Term.Name("controller"), name) + val endpointsName = q"endpoints.$name" + val controllersName = q"controller.$name" val controllerContent = route.method match { case "get" => - if (route.params.length == 0) q"_ => $controllersName()" - else if (route.params.length == 1) controllersName - else Term.Select(Term.Eta(controllersName), Term.Name("tupled")) + route.params.length match { + case 0 => q"_ => $controllersName()" + case 1 => controllersName + case _ => q"($controllersName _).tupled" + } case "post" => val fields = route.params .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) @@ -64,8 +66,7 @@ object Http4sMeta { case _ => throw new Exception("method not supported") } - val toRoutes = - Term.Apply(Term.Select(endpointsName, Term.Name("toRoutes")), List(controllerContent)) + val toRoutes = q"$endpointsName.toRoutes($controllerContent)" q"val ${Pat.Var(name)} = $toRoutes" } } From 76d76ca2b826f4583213cad7481e422ae4037d99 Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Mon, 30 Mar 2020 11:54:40 +0200 Subject: [PATCH 05/14] Introduce enumerated type for route methods --- .../scala/io/buildo/tapiro/AkkaHttpMeta.scala | 14 ++-- .../scala/io/buildo/tapiro/Http4sMeta.scala | 14 ++-- .../main/scala/io/buildo/tapiro/Meta.scala | 24 ++++--- .../io/buildo/tapiro/MetarpheusHelper.scala | 19 +++-- .../scala/io/buildo/tapiro/TapirMeta.scala | 71 ++++++++++--------- .../main/scala/io/buildo/tapiro/Util.scala | 25 ++++--- 6 files changed, 95 insertions(+), 72 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala index 91dbcb14..7ed95b2f 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala @@ -41,26 +41,24 @@ object AkkaHttpMeta { q"pathPrefix($pathName) { List(..$rest).foldLeft[Route]($first)(_ ~ _) }" } - val endpoints = (routes: List[Route]) => + val endpoints = (routes: List[TapiroRoute]) => routes.map { route => - val name = Term.Name(route.name.last) + val name = Term.Name(route.route.name.last) val endpointsName = q"endpoints.$name" val controllersName = q"controller.$name" val controllerContent = route.method match { - case "get" => - route.params.length match { + case RouteMethod.GET => + route.route.params.length match { case 0 => q"_ => $controllersName()" case 1 => controllersName case _ => q"($controllersName _).tupled" } - case "post" => - val fields = route.params + case RouteMethod.POST => + val fields = route.route.params .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) .map(p => Term.Name(p.name.getOrElse(Meta.typeNameString(p.tpe)))) q"x => $controllersName(..${fields.map(f => q"x.$f")})" - case _ => - throw new Exception("method not supported") } val toRoute = q"$endpointsName.toRoute($controllerContent)" q"val ${Pat.Var(name)} = $toRoute" diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala index 761009e4..66c5374d 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala @@ -45,26 +45,24 @@ object Http4sMeta { q"Router($route -> NonEmptyList($first, List(..$rest)).reduceK)" } - val endpoints = (routes: List[Route]) => + val endpoints = (routes: List[TapiroRoute]) => routes.map { route => - val name = Term.Name(route.name.last) + val name = Term.Name(route.route.name.last) val endpointsName = q"endpoints.$name" val controllersName = q"controller.$name" val controllerContent = route.method match { - case "get" => - route.params.length match { + case RouteMethod.GET => + route.route.params.length match { case 0 => q"_ => $controllersName()" case 1 => controllersName case _ => q"($controllersName _).tupled" } - case "post" => - val fields = route.params + case RouteMethod.POST => + val fields = route.route.params .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) .map(p => Term.Name(p.name.getOrElse(Meta.typeNameString(p.tpe)))) q"x => $controllersName(..${fields.map(f => q"x.$f")})" - case _ => - throw new Exception("method not supported") } val toRoutes = q"$endpointsName.toRoutes($controllerContent)" q"val ${Pat.Var(name)} = $toRoutes" diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala index 74ed4691..f07ccdb7 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala @@ -9,10 +9,10 @@ import cats.data.NonEmptyList object Meta { val codecsImplicits = (routes: List[TapiroRoute]) => { val jsonCodecs = (routes.flatMap { - case TapiroRoute(route, error) => + case TapiroRoute(route, _, error) => ((error match { - case TapiroRouteError.OtherError(t) => List(t) - case _ => Nil + case RouteError.OtherError(t) => List(t) + case _ => Nil }) :+ route.returns) }.distinct @@ -21,14 +21,18 @@ object Meta { ++ taggedUnionErrorMembers(routes)) .map(t => t"JsonCodec[$t]") val plainCodecs = routes.flatMap { - case TapiroRoute(route, _) => - (if (route.method == "get") route.params.map(_.tpe) else Nil) ++ - route.params.map(_.tpe).filter(typeNameString(_) == "AuthToken") + case TapiroRoute(route, method, _) => + (method match { + case RouteMethod.GET => route.params.map(_.tpe) + case _ => Nil + }) ++ route.params.map(_.tpe).filter(typeNameString(_) == "AuthToken") }.distinct.map(t => t"PlainCodec[${toScalametaType(t)}]") val circeCodecs = routes.flatMap { - case TapiroRoute(route, _) => - if (route.method == "post") route.params.map(_.tpe).filterNot(isAuthToken) - else Nil + case TapiroRoute(route, method, _) => + method match { + case RouteMethod.POST => route.params.map(_.tpe).filterNot(isAuthToken) + case _ => Nil + } }.distinct.flatMap { t => List(t"Decoder[${toScalametaType(t)}]", t"Encoder[${toScalametaType(t)}]") } @@ -40,7 +44,7 @@ object Meta { private[this] val taggedUnionErrorMembers = (routes: List[TapiroRoute]) => { val taggedUnions = routes.collect { - case TapiroRoute(_, TapiroRouteError.TaggedUnionError(tu)) => tu + case TapiroRoute(_, _, RouteError.TaggedUnionError(tu)) => tu }.distinct taggedUnions.flatMap { taggedUnion => taggedUnion.values.map(taggedUnionMemberType(taggedUnion)) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/MetarpheusHelper.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/MetarpheusHelper.scala index 8179f4fc..00141e2e 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/MetarpheusHelper.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/MetarpheusHelper.scala @@ -3,7 +3,18 @@ package io.buildo.tapiro import io.buildo.metarpheus.core.intermediate.{Type => MetarpheusType, Model, TaggedUnion, Route} object MetarpheusHelper { - def routeError(route: Route, models: List[Model]): TapiroRouteError = + def toTapiroRoute(models: List[Model])(route: Route): TapiroRoute = + TapiroRoute( + route = route, + method = route.method match { + case "get" => RouteMethod.GET + case "post" => RouteMethod.POST + case _ => throw new Exception("method not supported") + }, + error = routeError(route, models), + ) + + def routeError(route: Route, models: List[Model]): RouteError = route.error.map { error => val errorName = error match { case MetarpheusType.Name(name) => name @@ -16,7 +27,7 @@ object MetarpheusHelper { if (candidates.length > 1) throw new Exception(s"ambiguous error type name $errorName") else candidates.headOption - .map(TapiroRouteError.TaggedUnionError.apply) - .getOrElse(TapiroRouteError.OtherError(error)) - }.getOrElse(TapiroRouteError.OtherError(MetarpheusType.Name("String"))) + .map(RouteError.TaggedUnionError.apply) + .getOrElse(RouteError.OtherError(error)) + }.getOrElse(RouteError.OtherError(MetarpheusType.Name("String"))) } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala index 63ce98fb..d3a5611f 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala @@ -57,34 +57,36 @@ object TapirMeta { private[this] val endpointType = (route: TapiroRoute) => { val returnType = toScalametaType(route.route.returns) - val argsType = route.route.method match { - case "get" => + val argsType = route.method match { + case RouteMethod.GET => val argsList = route.route.params.map(p => toScalametaType(p.tpe)) argsList match { case Nil => Type.Name("Unit") case head :: Nil => head case l => Type.Tuple(l) } - case "post" => + case RouteMethod.POST => postInputType(route.route) - case _ => - throw new Exception("method not supported") } val error = toScalametaType(route.error match { - case TapiroRouteError.TaggedUnionError(t) => MetarpheusType.Name(t.name) - case TapiroRouteError.OtherError(t) => t + case RouteError.TaggedUnionError(t) => MetarpheusType.Name(t.name) + case RouteError.OtherError(t) => t }) t"Endpoint[$argsType, $error, $returnType, Nothing]" } private[this] val endpointImpl = (route: TapiroRoute) => { + val method = route.method match { + case RouteMethod.GET => "get" + case RouteMethod.POST => "post" + } val basicEndpoint = Term.Apply( Term - .Select(Term.Select(Term.Name("endpoint"), Term.Name(route.route.method)), Term.Name("in")), + .Select(Term.Select(Term.Name("endpoint"), Term.Name(method)), Term.Name("in")), List(Lit.String(route.route.name.tail.mkString)), ) val (auth, _) = route.route.params.partition(_.tpe == MetarpheusType.Name(authTokenName)) - val endpointsWithParams = withParams(basicEndpoint, route.route) + val endpointsWithParams = withParams(basicEndpoint, route) withOutput( withError( auth match { @@ -111,15 +113,14 @@ object TapirMeta { ), ) - private[this] val withParams = (endpoint: meta.Term, route: Route) => { + private[this] val withParams = (endpoint: meta.Term, route: TapiroRoute) => { route.method match { - case "get" => - route.params.foldLeft(endpoint) { (acc, param) => + case RouteMethod.GET => + route.route.params.foldLeft(endpoint) { (acc, param) => withParam(acc, param) } - case "post" => - withBody(endpoint, route) - case _ => throw new Exception("method not supported") + case RouteMethod.POST => + withBody(endpoint, route.route) }, } @@ -131,19 +132,19 @@ object TapirMeta { } private[this] val withError = - (endpoints: meta.Term, routeError: TapiroRouteError) => + (endpoints: meta.Term, routeError: RouteError) => routeError match { - case TapiroRouteError.OtherError(t) if typeNameString(t) == "Unit" => endpoints + case RouteError.OtherError(t) if typeNameString(t) == "Unit" => endpoints case _ => Term.Apply( Term.Select(endpoints, Term.Name("errorOut")), List( routeError match { - case TapiroRouteError.TaggedUnionError(taggedUnion) => + case RouteError.TaggedUnionError(taggedUnion) => listErrors(taggedUnion) - case TapiroRouteError.OtherError(MetarpheusType.Name("String")) => + case RouteError.OtherError(MetarpheusType.Name("String")) => Term.Name("stringBody") - case TapiroRouteError.OtherError(t) => + case RouteError.OtherError(t) => Term.ApplyType(Term.Name("jsonBody"), List(toScalametaType(t))) }, ), @@ -203,24 +204,30 @@ object TapirMeta { Type.Name(route.name.tail.mkString.capitalize + "RequestPayload") val routeClassDeclarations = (route: TapiroRoute) => - if (route.route.method == "post") { - val params = route.route.params - .filterNot(_.tpe == MetarpheusType.Name(authTokenName)) - .map { p => - param"${Term.Name(p.name.getOrElse(typeNameString(p.tpe)))}: ${toScalametaType(p.tpe)}" - } - List(q"case class ${postInputType(route.route)}(..$params)") - } else Nil + route.method match { + case RouteMethod.POST => + val params = route.route.params + .filterNot(_.tpe == MetarpheusType.Name(authTokenName)) + .map { p => + param"${Term.Name(p.name.getOrElse(typeNameString(p.tpe)))}: ${toScalametaType(p.tpe)}" + } + List(q"case class ${postInputType(route.route)}(..$params)") + case RouteMethod.GET => + Nil + } - val routeCodecDeclarations: TapiroRoute => List[Defn.Val] = (route: TapiroRoute) => { + val routeCodecDeclarations = (route: TapiroRoute) => { val mkDeclaration = (s: String) => { val name = Pat.Var(Term.Name(route.route.name.tail.mkString + "RequestPayload" + s)) val tpe = postInputType(route.route) val fun = Term.Name("derive" + s) q"implicit val $name : ${Type.Name(s)}[$tpe] = $fun" } - if (route.route.method == "post") { - List("Decoder", "Encoder").map(mkDeclaration) - } else Nil + route.method match { + case RouteMethod.POST => + List("Decoder", "Encoder").map(mkDeclaration) + case RouteMethod.GET => + Nil + } } } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala index c88fdaa3..57e4ad7e 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Util.scala @@ -25,13 +25,19 @@ object Server { case object NoServer extends Server } -sealed trait TapiroRouteError -object TapiroRouteError { - case class TaggedUnionError(taggedUnion: TaggedUnion) extends TapiroRouteError - case class OtherError(`type`: MetarpheusType) extends TapiroRouteError +sealed trait RouteError +object RouteError { + case class TaggedUnionError(taggedUnion: TaggedUnion) extends RouteError + case class OtherError(`type`: MetarpheusType) extends RouteError } -case class TapiroRoute(route: Route, error: TapiroRouteError) +sealed trait RouteMethod +object RouteMethod { + case object GET extends RouteMethod + case object POST extends RouteMethod +} + +case class TapiroRoute(route: Route, method: RouteMethod, error: RouteError) class Util() { import Formatter.format @@ -49,9 +55,8 @@ class Util() { case Some(nonEmptyPackage) => val config = Config(Set.empty) val models = Metarpheus.run(modelsPaths, config).models - val routes: List[TapiroRoute] = Metarpheus.run(routesPaths, config).routes.map { route => - TapiroRoute(route, routeError(route, models)) - } + val routes: List[TapiroRoute] = + Metarpheus.run(routesPaths, config).routes.map(toTapiroRoute(models)) val controllersRoutes = routes.groupBy( route => (route.route.controllerType, route.route.pathName), @@ -153,7 +158,7 @@ class Util() { Term.Name(tapirEndpointsName), Term.Name(httpEndpointsName), Meta.codecsImplicits(tapiroRoutes) :+ param"implicit cs: ContextShift[F]", - Http4sMeta.endpoints(routes), + Http4sMeta.endpoints(tapiroRoutes), Http4sMeta.routes(Lit.String(pathName), head, tail), ), ), @@ -183,7 +188,7 @@ class Util() { Term.Name(tapirEndpointsName), Term.Name(httpEndpointsName), Meta.codecsImplicits(tapiroRoutes), - AkkaHttpMeta.endpoints(routes), + AkkaHttpMeta.endpoints(tapiroRoutes), AkkaHttpMeta.routes(Lit.String(pathName), head, tail), ), ), From 2462381b70b12a425c866acd9b2db92726ba9c47 Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Tue, 31 Mar 2020 10:07:53 +0200 Subject: [PATCH 06/14] Fix test --- tapiro/sbt-tapiro/src/sbt-test/sbt-tapiro/simple/test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tapiro/sbt-tapiro/src/sbt-test/sbt-tapiro/simple/test b/tapiro/sbt-tapiro/src/sbt-test/sbt-tapiro/simple/test index 19af1bc7..afd16f56 100644 --- a/tapiro/sbt-tapiro/src/sbt-test/sbt-tapiro/simple/test +++ b/tapiro/sbt-tapiro/src/sbt-test/sbt-tapiro/simple/test @@ -10,4 +10,4 @@ $ exists "src/main/scala/endpoints/ExampleControllerHttpEndpoints.scala" # check that the endpoints respond as expected # NOTE(gabro): the single quotes surrounding the commands are a workaround for https://github.com/sbt/sbt/issues/4870 > 'curlExpect -s -X GET localhost:8080/ExampleController/queryExample?intParam=1&stringParam=abc {"name":"abc","double":1.0}' -> 'curlExpect -s -X POST localhost:8080/ExampleController/commandExample -d "{\"name\":\"abc\",\"double\":1.0}" abc' +> 'curlExpect -s -X POST localhost:8080/ExampleController/commandExample -d "{\"body\": {\"name\":\"abc\",\"double\":1.0}}" "\"abc\""' From 4e8ed12317a10c1da3258862d2c62568a731b11e Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Tue, 31 Mar 2020 10:23:33 +0200 Subject: [PATCH 07/14] Use `isAuthToken` consistently --- tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala index f07ccdb7..18354416 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala @@ -25,7 +25,7 @@ object Meta { (method match { case RouteMethod.GET => route.params.map(_.tpe) case _ => Nil - }) ++ route.params.map(_.tpe).filter(typeNameString(_) == "AuthToken") + }) ++ route.params.map(_.tpe).filter(isAuthToken) }.distinct.map(t => t"PlainCodec[${toScalametaType(t)}]") val circeCodecs = routes.flatMap { case TapiroRoute(route, method, _) => From 73d3e7955650347c7670b60233ace1ecd13cea7f Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Fri, 3 Apr 2020 11:16:35 +0200 Subject: [PATCH 08/14] Fix handling of AuthToken in Tapir endpoints --- .../scala/io/buildo/tapiro/TapirMeta.scala | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala index d3a5611f..5f1c6331 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala @@ -66,7 +66,15 @@ object TapirMeta { case l => Type.Tuple(l) } case RouteMethod.POST => - postInputType(route.route) + val authTokenType = route.route.params + .filter(_.tpe == MetarpheusType.Name(authTokenName)) + .map(t => toScalametaType(t.tpe)) + .headOption + val outputType = postInputType(route.route) + authTokenType match { + case Some(t) => Type.Tuple(List(outputType, t)) + case None => outputType + } } val error = toScalametaType(route.error match { case RouteError.TaggedUnionError(t) => MetarpheusType.Name(t.name) @@ -85,14 +93,9 @@ object TapirMeta { .Select(Term.Select(Term.Name("endpoint"), Term.Name(method)), Term.Name("in")), List(Lit.String(route.route.name.tail.mkString)), ) - val (auth, _) = route.route.params.partition(_.tpe == MetarpheusType.Name(authTokenName)) - val endpointsWithParams = withParams(basicEndpoint, route) withOutput( withError( - auth match { - case Nil => endpointsWithParams - case _ => withAuth(endpointsWithParams) - }, + withParams(basicEndpoint, route), route.error, ), route.route.returns, @@ -114,14 +117,19 @@ object TapirMeta { ) private[this] val withParams = (endpoint: meta.Term, route: TapiroRoute) => { - route.method match { + val (auth, params) = route.route.params.partition(_.tpe == MetarpheusType.Name(authTokenName)) + val endpointWithParams = route.method match { case RouteMethod.GET => - route.route.params.foldLeft(endpoint) { (acc, param) => + params.foldLeft(endpoint) { (acc, param) => withParam(acc, param) } case RouteMethod.POST => withBody(endpoint, route.route) - }, + } + auth match { + case Nil => endpointWithParams + case _ => withAuth(endpointWithParams) + } } private[this] val withBody = (endpoint: meta.Term, route: Route) => { From a1da5c41ba516310d9dcbccc5834ae8faa671a21 Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Fri, 3 Apr 2020 11:19:14 +0200 Subject: [PATCH 09/14] Remove duplication + fix token in HttpEndpoints --- .../scala/io/buildo/tapiro/AkkaHttpMeta.scala | 22 +++--------------- .../scala/io/buildo/tapiro/Http4sMeta.scala | 22 +++--------------- .../main/scala/io/buildo/tapiro/Meta.scala | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala index 7ed95b2f..62913377 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/AkkaHttpMeta.scala @@ -1,6 +1,6 @@ package io.buildo.tapiro -import io.buildo.metarpheus.core.intermediate.{Route, Type => MetarpheusType} +import io.buildo.metarpheus.core.intermediate.Route import scala.meta._ @@ -44,23 +44,7 @@ object AkkaHttpMeta { val endpoints = (routes: List[TapiroRoute]) => routes.map { route => val name = Term.Name(route.route.name.last) - val endpointsName = q"endpoints.$name" - val controllersName = q"controller.$name" - val controllerContent = - route.method match { - case RouteMethod.GET => - route.route.params.length match { - case 0 => q"_ => $controllersName()" - case 1 => controllersName - case _ => q"($controllersName _).tupled" - } - case RouteMethod.POST => - val fields = route.route.params - .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) - .map(p => Term.Name(p.name.getOrElse(Meta.typeNameString(p.tpe)))) - q"x => $controllersName(..${fields.map(f => q"x.$f")})" - } - val toRoute = q"$endpointsName.toRoute($controllerContent)" - q"val ${Pat.Var(name)} = $toRoute" + val endpointImpl = Meta.toEndpointImplementation(route) + q"val ${Pat.Var(name)} = endpoints.$name.toRoute($endpointImpl)" } } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala index 66c5374d..6e6d5356 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala @@ -1,6 +1,6 @@ package io.buildo.tapiro -import io.buildo.metarpheus.core.intermediate.{Route, Type => MetarpheusType} +import io.buildo.metarpheus.core.intermediate.Route import scala.meta._ @@ -48,23 +48,7 @@ object Http4sMeta { val endpoints = (routes: List[TapiroRoute]) => routes.map { route => val name = Term.Name(route.route.name.last) - val endpointsName = q"endpoints.$name" - val controllersName = q"controller.$name" - val controllerContent = - route.method match { - case RouteMethod.GET => - route.route.params.length match { - case 0 => q"_ => $controllersName()" - case 1 => controllersName - case _ => q"($controllersName _).tupled" - } - case RouteMethod.POST => - val fields = route.route.params - .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) - .map(p => Term.Name(p.name.getOrElse(Meta.typeNameString(p.tpe)))) - q"x => $controllersName(..${fields.map(f => q"x.$f")})" - } - val toRoutes = q"$endpointsName.toRoutes($controllerContent)" - q"val ${Pat.Var(name)} = $toRoutes" + val endpointImpl = Meta.toEndpointImplementation(route) + q"val ${Pat.Var(name)} = endpoints.$name.toRoutes($endpointImpl)" } } diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala index 18354416..2666bc3a 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala @@ -80,4 +80,27 @@ object Meta { def packageFromList(`package`: NonEmptyList[String]): Term.Ref = `package`.tail .foldLeft[Term.Ref](Term.Name(`package`.head))((acc, n) => Term.Select(acc, Term.Name(n))) + + val toEndpointImplementation = (route: TapiroRoute) => { + val name = Term.Name(route.route.name.last) + val controllersName = q"controller.$name" + route.method match { + case RouteMethod.GET => + route.route.params.length match { + case 0 => q"_ => $controllersName()" + case 1 => controllersName + case _ => q"($controllersName _).tupled" + } + case RouteMethod.POST => + val fields = route.route.params + .filterNot(_.tpe == MetarpheusType.Name("AuthToken")) + .map(p => Term.Name(p.name.getOrElse(Meta.typeNameString(p.tpe)))) + val hasAuth = route.route.params + .exists(_.tpe == MetarpheusType.Name("AuthToken")) + if (hasAuth) + q"{ case (x, token) => $controllersName(..${fields.map(f => q"x.$f")}, token) }" + else + q"x => $controllersName(..${fields.map(f => q"x.$f")})" + } + } } From fbabd8e83708173808f0f88e2ae0367fbd0eb1c7 Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Fri, 3 Apr 2020 15:15:08 +0200 Subject: [PATCH 10/14] Refactor codecsImplicits --- .../main/scala/io/buildo/tapiro/Meta.scala | 70 +++++++++---------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala index 2666bc3a..d5ce5340 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Meta.scala @@ -3,53 +3,49 @@ package io.buildo.tapiro import io.buildo.metarpheus.core.intermediate.{TaggedUnion, Type => MetarpheusType} import scala.meta._ +import scala.meta.contrib._ import cats.data.NonEmptyList object Meta { val codecsImplicits = (routes: List[TapiroRoute]) => { - val jsonCodecs = (routes.flatMap { - case TapiroRoute(route, _, error) => - ((error match { - case RouteError.OtherError(t) => List(t) - case _ => Nil - }) :+ - route.returns) - }.distinct - .filter(t => typeNameString(t) != "Unit") //no json codec for Unit in tapir - .map(toScalametaType) - ++ taggedUnionErrorMembers(routes)) - .map(t => t"JsonCodec[$t]") - val plainCodecs = routes.flatMap { - case TapiroRoute(route, method, _) => - (method match { - case RouteMethod.GET => route.params.map(_.tpe) - case _ => Nil - }) ++ route.params.map(_.tpe).filter(isAuthToken) - }.distinct.map(t => t"PlainCodec[${toScalametaType(t)}]") - val circeCodecs = routes.flatMap { - case TapiroRoute(route, method, _) => - method match { - case RouteMethod.POST => route.params.map(_.tpe).filterNot(isAuthToken) - case _ => Nil + val notUnit = (t: MetarpheusType) => t != MetarpheusType.Name("Unit") + val toDecoder = (t: Type) => t"Decoder[$t]" + val toEncoder = (t: Type) => t"Encoder[$t]" + val toJsonCodec = (t: Type) => t"JsonCodec[$t]" + val toPlainCodec = (t: Type) => t"PlainCodec[$t]" + val routeRequiredImplicits = (route: TapiroRoute) => { + val (authParamTypes, nonAuthParamTypes) = + route.route.params.map(_.tpe).partition(isAuthToken) + val inputImplicits = + route.method match { + case RouteMethod.GET => + nonAuthParamTypes.map(toScalametaType).map(toPlainCodec) + case RouteMethod.POST => + nonAuthParamTypes.map(toScalametaType).flatMap(t => List(toDecoder(t), toEncoder(t))) } - }.distinct.flatMap { t => - List(t"Decoder[${toScalametaType(t)}]", t"Encoder[${toScalametaType(t)}]") + val outputImplicits = + List(route.route.returns).filter(notUnit).map(toScalametaType).map(toJsonCodec) + val errorImplicits = + route.error match { + case RouteError.TaggedUnionError(tu) => + tu.values.map(taggedUnionMemberType(tu)).map(toJsonCodec) + case RouteError.OtherError(t) => + List(t).filter(notUnit).map(toScalametaType).map(toJsonCodec) + } + val authImplicits = authParamTypes.map(toScalametaType).map(toPlainCodec) + inputImplicits ++ outputImplicits ++ errorImplicits ++ authImplicits } - val codecs = jsonCodecs ++ plainCodecs ++ circeCodecs - codecs.zipWithIndex.map(toImplicitParam.tupled) + deduplicate(routes.flatMap(routeRequiredImplicits)).zipWithIndex.map(toImplicitParam.tupled) } - private[this] val isAuthToken = (t: MetarpheusType) => t == MetarpheusType.Name("AuthToken") - - private[this] val taggedUnionErrorMembers = (routes: List[TapiroRoute]) => { - val taggedUnions = routes.collect { - case TapiroRoute(_, _, RouteError.TaggedUnionError(tu)) => tu - }.distinct - taggedUnions.flatMap { taggedUnion => - taggedUnion.values.map(taggedUnionMemberType(taggedUnion)) + private[this] val deduplicate: List[Type] => List[Type] = (ts: List[Type]) => + ts match { + case Nil => Nil + case head :: tail => head :: deduplicate(tail.filter(!_.isEqual(head))) } - } + + private[this] val isAuthToken = (t: MetarpheusType) => t == MetarpheusType.Name("AuthToken") private[this] val toImplicitParam = (paramType: Type, index: Int) => { val paramName = Term.Name(s"codec$index") From 7235e8f180c18382a0595b62aa2534529b1e57bd Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Fri, 3 Apr 2020 15:40:42 +0200 Subject: [PATCH 11/14] Rename variable --- tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala index 5f1c6331..684e3190 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/TapirMeta.scala @@ -70,10 +70,10 @@ object TapirMeta { .filter(_.tpe == MetarpheusType.Name(authTokenName)) .map(t => toScalametaType(t.tpe)) .headOption - val outputType = postInputType(route.route) + val inputType = postInputType(route.route) authTokenType match { - case Some(t) => Type.Tuple(List(outputType, t)) - case None => outputType + case Some(t) => Type.Tuple(List(inputType, t)) + case None => inputType } } val error = toScalametaType(route.error match { From 49ac833738fa12555e50a677f5382042e8283722 Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Tue, 14 Apr 2020 19:49:01 +0200 Subject: [PATCH 12/14] Update and expand tests --- .../scala/io/buildo/tapiro/TapiroSuite.scala | 143 ++++++++++++++++-- 1 file changed, 134 insertions(+), 9 deletions(-) diff --git a/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala b/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala index 796a7b51..9b25b22d 100644 --- a/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala +++ b/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala @@ -5,7 +5,7 @@ import java.nio.file.Files class TapiroSuite extends munit.FunSuite { check( - "http4s", + "tapir and http4s endpoints", Server.Http4s, "src/main/scala/schools/endpoints", """ @@ -14,12 +14,19 @@ class TapiroSuite extends munit.FunSuite { | |case class School(id: Long, name: String) | + |sealed trait SchoolCreateError + |object SchoolCreateError { + | case object DuplicateId extends SchoolCreateError + |} + | |sealed trait SchoolReadError |object SchoolReadError { | case object NotFound extends SchoolReadError |} | - |trait SchoolController[F[_], T] { + |trait SchoolController[F[_], AuthToken] { + | @command + | def create(school: School, token: AuthToken): F[Either[SchoolCreateError, Unit]] | @query | def read(id: Long): F[Either[SchoolReadError, School]] |} @@ -34,21 +41,56 @@ class TapiroSuite extends munit.FunSuite { | |package endpoints |import schools._ + |import io.circe.{Decoder, Encoder} + |import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} |import sttp.tapir._ + |import sttp.tapir.json.circe._ |import sttp.tapir.Codec.{JsonCodec, PlainCodec} |import sttp.model.StatusCode | |trait SchoolControllerTapirEndpoints[AuthToken] { + | + | val create: Endpoint[ + | (CreateRequestPayload, AuthToken), + | SchoolCreateError, + | Unit, + | Nothing + | ] | val read: Endpoint[Long, SchoolReadError, School, Nothing] |} | |object SchoolControllerTapirEndpoints { | | def create[AuthToken](statusCodes: String => StatusCode)( - | implicit codec0: JsonCodec[School], - | codec1: JsonCodec[SchoolReadError.NotFound.type], - | codec2: PlainCodec[Long] + | implicit codec0: Decoder[School], + | codec1: Encoder[School], + | codec2: JsonCodec[SchoolCreateError.DuplicateId.type], + | codec3: PlainCodec[AuthToken], + | codec4: PlainCodec[Long], + | codec5: JsonCodec[School], + | codec6: JsonCodec[SchoolReadError.NotFound.type] | ) = new SchoolControllerTapirEndpoints[AuthToken] { + | implicit val createRequestPayloadDecoder: Decoder[CreateRequestPayload] = + | deriveDecoder + | implicit val createRequestPayloadEncoder: Encoder[CreateRequestPayload] = + | deriveEncoder + | override val create: Endpoint[ + | (CreateRequestPayload, AuthToken), + | SchoolCreateError, + | Unit, + | Nothing + | ] = endpoint.post + | .in("create") + | .in(jsonBody[CreateRequestPayload]) + | .in(header[AuthToken]("Authorization")) + | .errorOut( + | oneOf[SchoolCreateError]( + | statusMapping( + | statusCodes("DuplicateId"), + | jsonBody[SchoolCreateError.DuplicateId.type] + | ) + | ) + | ) | override val read: Endpoint[Long, SchoolReadError, School, Nothing] = | endpoint.get | .in("read") @@ -64,6 +106,7 @@ class TapiroSuite extends munit.FunSuite { | .out(jsonBody[School]) | } |} + |case class CreateRequestPayload(school: School) | |/src/main/scala/schools/endpoints/SchoolControllerHttpEndpoints.scala |//---------------------------------------------------------- @@ -77,6 +120,7 @@ class TapiroSuite extends munit.FunSuite { |import cats.effect._ |import cats.implicits._ |import cats.data.NonEmptyList + |import io.circe.{Decoder, Encoder} |import org.http4s._ |import org.http4s.server.Router |import sttp.tapir.server.http4s._ @@ -89,15 +133,96 @@ class TapiroSuite extends munit.FunSuite { | controller: SchoolController[F, AuthToken], | statusCodes: String => StatusCode = _ => StatusCode.UnprocessableEntity | )( - | implicit codec0: JsonCodec[School], - | codec1: JsonCodec[SchoolReadError.NotFound.type], - | codec2: PlainCodec[Long], + | implicit codec0: Decoder[School], + | codec1: Encoder[School], + | codec2: JsonCodec[SchoolCreateError.DuplicateId.type], + | codec3: PlainCodec[AuthToken], + | codec4: PlainCodec[Long], + | codec5: JsonCodec[School], + | codec6: JsonCodec[SchoolReadError.NotFound.type], | cs: ContextShift[F] | ): HttpRoutes[F] = { | val endpoints = | SchoolControllerTapirEndpoints.create[AuthToken](statusCodes) + | val create = endpoints.create.toRoutes({ + | case (x, token) => + | controller.create(x.school, token) + | }) | val read = endpoints.read.toRoutes(controller.read) - | Router("/SchoolController" -> NonEmptyList(read, List()).reduceK) + | Router("/SchoolController" -> NonEmptyList(create, List(read)).reduceK) + | } + |} + |""".stripMargin, + ) + + check( + "akkaHttp endpoints", + Server.AkkaHttp, + "src/main/scala/schools/endpoints", + """ + |/src/main/scala/schools/SchoolController.scala + |package schools + | + |case class School(id: Long, name: String) + | + |sealed trait SchoolCreateError + |object SchoolCreateError { + | case object DuplicateId extends SchoolCreateError + |} + | + |sealed trait SchoolReadError + |object SchoolReadError { + | case object NotFound extends SchoolReadError + |} + | + |trait SchoolController[AuthToken] { + | @command + | def create(school: School, token: AuthToken): Future[Either[SchoolCreateError, Unit]] + | @query + | def read(id: Long): Future[Either[SchoolReadError, School]] + |} + |""".stripMargin, + """ + |/src/main/scala/schools/endpoints/SchoolControllerHttpEndpoints.scala + |//---------------------------------------------------------- + |// This code was generated by tapiro. + |// Changes to this file may cause incorrect behavior + |// and will be lost if the code is regenerated. + |//---------------------------------------------------------- + | + |package endpoints + |import schools._ + |import akka.http.scaladsl.server._ + |import akka.http.scaladsl.server.Directives._ + |import io.circe.{Decoder, Encoder} + |import sttp.tapir.server.akkahttp._ + |import sttp.tapir.Codec.{JsonCodec, PlainCodec} + |import sttp.model.StatusCode + | + |object SchoolControllerHttpEndpoints { + | + | def routes[AuthToken]( + | controller: SchoolController[AuthToken], + | statusCodes: String => StatusCode = _ => StatusCode.UnprocessableEntity + | )( + | implicit codec0: Decoder[School], + | codec1: Encoder[School], + | codec2: JsonCodec[SchoolCreateError.DuplicateId.type], + | codec3: PlainCodec[AuthToken], + | codec4: PlainCodec[Long], + | codec5: JsonCodec[School], + | codec6: JsonCodec[SchoolReadError.NotFound.type] + | ): Route = { + | val endpoints = + | SchoolControllerTapirEndpoints.create[AuthToken](statusCodes) + | val create = endpoints.create.toRoute({ + | case (x, token) => + | controller.create(x.school, token) + | }) + | val read = endpoints.read.toRoute(controller.read) + | pathPrefix("SchoolController") { + | List(read).foldLeft[Route](create)(_ ~ _) + | } | } |} |""".stripMargin, From fad617ec49fbb54e8259a4fd68582ba96408122d Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Wed, 15 Apr 2020 10:09:40 +0200 Subject: [PATCH 13/14] Add test for query without parameters --- .../scala/io/buildo/tapiro/TapiroSuite.scala | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala b/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala index 9b25b22d..df29713b 100644 --- a/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala +++ b/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala @@ -29,6 +29,8 @@ class TapiroSuite extends munit.FunSuite { | def create(school: School, token: AuthToken): F[Either[SchoolCreateError, Unit]] | @query | def read(id: Long): F[Either[SchoolReadError, School]] + | @query + | def list(): F[Either[Unit, List[School]]] |} |""".stripMargin, """ @@ -57,6 +59,7 @@ class TapiroSuite extends munit.FunSuite { | Nothing | ] | val read: Endpoint[Long, SchoolReadError, School, Nothing] + | val list: Endpoint[Unit, Unit, List[School], Nothing] |} | |object SchoolControllerTapirEndpoints { @@ -68,7 +71,8 @@ class TapiroSuite extends munit.FunSuite { | codec3: PlainCodec[AuthToken], | codec4: PlainCodec[Long], | codec5: JsonCodec[School], - | codec6: JsonCodec[SchoolReadError.NotFound.type] + | codec6: JsonCodec[SchoolReadError.NotFound.type], + | codec7: JsonCodec[List[School]] | ) = new SchoolControllerTapirEndpoints[AuthToken] { | implicit val createRequestPayloadDecoder: Decoder[CreateRequestPayload] = | deriveDecoder @@ -104,6 +108,8 @@ class TapiroSuite extends munit.FunSuite { | ) | ) | .out(jsonBody[School]) + | override val list: Endpoint[Unit, Unit, List[School], Nothing] = + | endpoint.get.in("list").out(jsonBody[List[School]]) | } |} |case class CreateRequestPayload(school: School) @@ -140,6 +146,7 @@ class TapiroSuite extends munit.FunSuite { | codec4: PlainCodec[Long], | codec5: JsonCodec[School], | codec6: JsonCodec[SchoolReadError.NotFound.type], + | codec7: JsonCodec[List[School]], | cs: ContextShift[F] | ): HttpRoutes[F] = { | val endpoints = @@ -149,7 +156,10 @@ class TapiroSuite extends munit.FunSuite { | controller.create(x.school, token) | }) | val read = endpoints.read.toRoutes(controller.read) - | Router("/SchoolController" -> NonEmptyList(create, List(read)).reduceK) + | val list = endpoints.list.toRoutes(_ => controller.list()) + | Router( + | "/SchoolController" -> NonEmptyList(create, List(read, list)).reduceK + | ) | } |} |""".stripMargin, @@ -180,6 +190,8 @@ class TapiroSuite extends munit.FunSuite { | def create(school: School, token: AuthToken): Future[Either[SchoolCreateError, Unit]] | @query | def read(id: Long): Future[Either[SchoolReadError, School]] + | @query + | def list(): F[Either[Unit, List[School]]] |} |""".stripMargin, """ @@ -211,7 +223,8 @@ class TapiroSuite extends munit.FunSuite { | codec3: PlainCodec[AuthToken], | codec4: PlainCodec[Long], | codec5: JsonCodec[School], - | codec6: JsonCodec[SchoolReadError.NotFound.type] + | codec6: JsonCodec[SchoolReadError.NotFound.type], + | codec7: JsonCodec[List[School]] | ): Route = { | val endpoints = | SchoolControllerTapirEndpoints.create[AuthToken](statusCodes) @@ -220,8 +233,9 @@ class TapiroSuite extends munit.FunSuite { | controller.create(x.school, token) | }) | val read = endpoints.read.toRoute(controller.read) + | val list = endpoints.list.toRoute(_ => controller.list()) | pathPrefix("SchoolController") { - | List(read).foldLeft[Route](create)(_ ~ _) + | List(read, list).foldLeft[Route](create)(_ ~ _) | } | } |} From 80eec9a1facd803510bbb82dcfa4763929e503fc Mon Sep 17 00:00:00 2001 From: Tommaso Petrucciani Date: Thu, 16 Apr 2020 19:21:32 +0200 Subject: [PATCH 14/14] Handle review comments --- .../core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala | 2 +- .../src/test/scala/io/buildo/tapiro/TapiroSuite.scala | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala index 6e6d5356..fd7eba30 100644 --- a/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala +++ b/tapiro/core/src/main/scala/io/buildo/tapiro/Http4sMeta.scala @@ -42,7 +42,7 @@ object Http4sMeta { val first = Term.Name(head.name.last) val rest = tail.map(a => Term.Name(a.name.last)) val route: Lit.String = Lit.String("/" + pathName.value) - q"Router($route -> NonEmptyList($first, List(..$rest)).reduceK)" + q"Router($route -> NonEmptyList.of($first, ..$rest).reduceK)" } val endpoints = (routes: List[TapiroRoute]) => diff --git a/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala b/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala index df29713b..e61dceb7 100644 --- a/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala +++ b/tapiro/core/src/test/scala/io/buildo/tapiro/TapiroSuite.scala @@ -5,7 +5,7 @@ import java.nio.file.Files class TapiroSuite extends munit.FunSuite { check( - "tapir and http4s endpoints", + "tapir-http4s-endpoints", Server.Http4s, "src/main/scala/schools/endpoints", """ @@ -157,16 +157,14 @@ class TapiroSuite extends munit.FunSuite { | }) | val read = endpoints.read.toRoutes(controller.read) | val list = endpoints.list.toRoutes(_ => controller.list()) - | Router( - | "/SchoolController" -> NonEmptyList(create, List(read, list)).reduceK - | ) + | Router("/SchoolController" -> NonEmptyList.of(create, read, list).reduceK) | } |} |""".stripMargin, ) check( - "akkaHttp endpoints", + "akkaHttp-endpoints", Server.AkkaHttp, "src/main/scala/schools/endpoints", """