Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add ConsulDiscoverable typeclass and Scala 2 macro implementation #217

Merged
merged 3 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ lazy val `smithy4s-consul-middleware-tests` = crossProject(JSPlatform, JVMPlatfo
"com.comcast" %%% "ip4s-test-kit" % "3.6.0" % Test,
)
},
libraryDependencies ++= {
(scalaBinaryVersion.value) match {
case "2.12" | "2.13" =>
Seq("org.scala-lang" % "scala-compiler" % scalaVersion.value % Test)
case _ =>
Nil
}
},
Compile / smithy4sInputDirs := List(
baseDirectory.value.getParentFile / "src" / "main" / "smithy",
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.dwolla.consul.smithy4s

import com.dwolla.test.HelloService
import munit.FunSuite
import org.http4s.Uri
import org.http4s.Uri.Host

trait ConsulDiscoverableSpecPerScalaVersion { self: FunSuite =>
val serviceUri: Uri = UriFromService(HelloService)
private val serviceHost: Host = HostFromService(HelloService)
private val serviceUriAuthority: Uri.Authority = UriAuthorityFromService(HelloService)

test("ConsulDiscoverable typeclass macro constructs a working instance of the typeclass") {
assertEquals(ConsulDiscoverable[HelloService].host, serviceHost)
assertEquals(ConsulDiscoverable[HelloService].uriAuthority, serviceUriAuthority)
assertEquals(ConsulDiscoverable[HelloService].uri, serviceUri)
}

test("ConsulDiscoverable typeclass macro returns no instance when the type parameter isn't a Smithy Service") {
assertEquals(
compileErrors("""ConsulDiscoverable[NotASmithy4sService]"""),
"""error: Instances are only available for Smithy4s Services annotated with @discoverable
|ConsulDiscoverable[NotASmithy4sService]
| ^""".stripMargin
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,3 @@ class UriFromServiceSpec extends FunSuite {
}
}
}

trait NotASmithy4sService[F[_]]
object NotASmithy4sService
trait NotASmithy4sServiceAndHasNoCompanionObject[F[_]]
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.dwolla.consul.smithy4s

import munit._

// TODO implement tests with macro-derived implementation once Scala 3 macro is available
trait ConsulDiscoverableSpecPerScalaVersion { self: FunSuite =>
test("ConsulDiscoverable typeclass macro returns no instance when the type parameter isn't a Smithy Service") {
assertEquals(
compileErrors("""ConsulDiscoverable[NotASmithy4sService]"""),
"""error: Instances are only available for Smithy4s Services annotated with @discoverable
|ConsulDiscoverable[NotASmithy4sService]
| ^""".stripMargin
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dwolla.consul.smithy4s

import munit._

class ConsulDiscoverableSpec
extends FunSuite
with ConsulDiscoverableSpecPerScalaVersion
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dwolla.consul.smithy4s

trait NotASmithy4sService[F[_]]
object NotASmithy4sService

trait NotASmithy4sServiceAndHasNoCompanionObject[F[_]]
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.dwolla.consul.smithy4s

import cats.syntax.all._
import com.dwolla.consul.smithy._
import org.http4s.Uri.Host
import smithy4s.Hints

import scala.reflect.macros.whitebox
import scala.util.Try

trait ConsulDiscoverablePlatform {
implicit def derivedInstance[Alg[_[_]]]: ConsulDiscoverable[Alg] =
macro ConsulDiscoverableMacros.makeInstance[Alg]
}

object ConsulDiscoverableMacros {
def makeInstance[Alg[_[_]]](c: whitebox.Context): c.Expr[ConsulDiscoverable[Alg]] = {
import c.universe.{Try => _, _}

def findHintsInTree(tpe: Tree): Either[String, Hints] =
Try {
tpe.collect {
case x: TypTree =>
c.eval(c.Expr[Hints](q"${x.symbol.companion}.hints"))
}
.headOption
.toRight(s"could not find hints for $tpe")
}
.toEither
.leftMap(_.toString ++ s" (is $tpe a Smithy4s Service?)")
.flatten

def getDiscoverableFromHints(tpe: Tree): Hints => Either[String, Discoverable] =
_.get(Discoverable.tagInstance)
.toRight(s"could not find Discoverable hint for $tpe")

val getHostFromDiscoverable: PartialFunction[Discoverable, Either[String, Host]] = {
case Discoverable(ServiceName(serviceName)) =>
Host.fromString(serviceName)
.leftMap(_.message)
}

def hostToConsulDiscoverableExpr(tpe: Tree): Host => c.Expr[ConsulDiscoverable[Alg]] = host =>
c.Expr[ConsulDiscoverable[Alg]](
q"""
new _root_.com.dwolla.consul.smithy4s.ConsulDiscoverable[$tpe] {
Copy link
Member Author

@bpholt bpholt Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My mental model of how macros work is that it's like the code block is copy/pasted into the spot where the macro is invoked. The imports, etc., that are in effect in that spot are what controls how types are resolved. So e.g. a prior version used the .some enhancement method from Cats (uriAuthority.some) instead of wrapping the value in an Option as below, which worked in places where the Cats syntax had been imported, but failed otherwise.

To avoid that kind of issue or other issues with ambiguous imports, I fully qualified everything in the quasiquote so that it should work regardless of context.

override def host: _root_.org.http4s.Uri.Host = _root_.org.http4s.Uri.Host.unsafeFromString(${host.value})
override def uriAuthority: _root_.org.http4s.Uri.Authority = _root_.org.http4s.Uri.Authority(host = host)
override def uri: _root_.org.http4s.Uri = _root_.org.http4s.Uri(scheme = _root_.scala.Option(_root_.com.dwolla.consul.smithy4s.DiscoveryMacros.consulScheme), authority = _root_.scala.Option(uriAuthority))
}
""")

c.macroApplication match {
case TypeApply(_, List(tpe)) if tpe.symbol.companion != NoSymbol =>
val maybeExpr = findHintsInTree(tpe)
.flatMap(getDiscoverableFromHints(tpe))
.flatMap(getHostFromDiscoverable)
.map(hostToConsulDiscoverableExpr(tpe))

maybeExpr.fold(c.abort(c.enclosingPosition, _), identity)
case TypeApply(_, List(tpe)) if tpe.symbol.companion == NoSymbol =>
c.abort(c.enclosingPosition, s"$tpe is not a Smithy4s Service")
case other => c.abort(c.enclosingPosition, s"found $other, which is not a Smithy4s Service")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import org.http4s.syntax.all._
import scala.reflect.macros.blackbox

object DiscoveryMacros {
private val consulScheme: Scheme = scheme"consul"
val consulScheme: Scheme = scheme"consul"

def makeUri[Alg[_[_, _, _, _, _]]](c: blackbox.Context)
(service: c.Expr[smithy4s.Service[Alg]]): c.Expr[Uri] = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.dwolla.consul.smithy4s

trait ConsulDiscoverablePlatform
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.dwolla.consul.smithy4s

import org.http4s.Uri
import org.http4s.Uri.Host

import scala.annotation.implicitNotFound

@implicitNotFound("Instances are only available for Smithy4s Services annotated with @discoverable")
trait ConsulDiscoverable[Alg[_[_]]] {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to bikeshedding on the names here. This typeclass will have macro-instances for Smithy4s services annotated with the @discoverable trait. The host, uriAuthority, and uri values will be derived from the serviceName value on the @discoverable trait, e.g. uri will return consul://${serviceName}.

This will typically be used like this:

def smithyClient[F[_], Alg[_[_, _, _, _, _]]](client: Client[F],  // http4s client
                                              consulMiddleware: ConsulMiddleware[F], // middleware from this library
                                              service: Service[Alg], // generated by smithy4s
                                             )
                                             (implicit C: ConsulDiscoverable[service.Impl]): Resource[F, service.Impl[F]] =
  SimpleRestJsonBuilder.apply(service)
    .client(NatchezMiddleware.clientWithAttributes(client)(
      "rpc.system" -> "smithy",
      "peer.service" -> ConsulDiscoverable[service.Impl].host,
    ))
    .uri(ConsulDiscoverable[service.Impl].uri)
    .middleware(consulMiddleware)
    .resource

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And just to be clear, this does not work, because the macro can't resolve what service is at compile time:

def smithyClient[F[_], Alg[_[_, _, _, _, _]]](client: Client[F],  // http4s client
                                              consulMiddleware: ConsulMiddleware[F], // middleware from this library
                                              service: Service[Alg], // generated by smithy4s
                                             ): Resource[F, service.Impl[F]] =
  SimpleRestJsonBuilder.apply(service)
    .client(NatchezMiddleware.clientWithAttributes(client)(
      "rpc.system" -> "smithy",
      "peer.service"  HostFromService(service),
    ))
    .uri(UriFromService(service))
    .middleware(consulMiddleware)
    .resource

Implementing it as a typeclass does work, because there is a stable location (outside the function above) where the service is known and the ConsulDiscoverable instance can be generated and then passed implicitly into the function.

def host: Host
def uriAuthority: Uri.Authority
def uri: Uri
}

object ConsulDiscoverable extends ConsulDiscoverablePlatform {
def apply[Alg[_[_]] : ConsulDiscoverable]: ConsulDiscoverable[Alg] = implicitly
}