diff --git a/.travis.yml b/.travis.yml index 5fadbc5b8..1379a9930 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: scala scala: - - 2.11.11 + - 2.11.12 - 2.12.6 jdk: diff --git a/README.md b/README.md index 712e7a90d..81d70bbd5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ derivation for case classes and sealed hierarchies * [RPC framework](docs/RPCFramework.md): **typesafe** RPC/proxy framework used in particular by [Udash Framework](http://guide.udash.io/#/rpc) for client-server communication + * [REST framework](docs/REST.md) based on RPC framework * Better enumeration support for Scala - [`ValueEnum`](http://avsystem.github.io/scala-commons/api/com/avsystem/commons/misc/ValueEnum.html), [`SealedEnumCompanion`](http://avsystem.github.io/scala-commons/api/com/avsystem/commons/misc/SealedEnumCompanion.html), diff --git a/build.sbt b/build.sbt index 70a55d101..53ff33127 100644 --- a/build.sbt +++ b/build.sbt @@ -9,7 +9,7 @@ cancelable in Global := true val forIdeaImport = System.getProperty("idea.managed", "false").toBoolean && System.getProperty("idea.runid") == null // for binary compatibility checking -val previousVersion = "1.27.3" +val previousCompatibleVersions = Set.empty[String] val silencerVersion = "1.1" val guavaVersion = "23.0" @@ -100,8 +100,8 @@ val jvmCommonSettings = commonSettings ++ Seq( libraryDependencies ++= Seq( "org.apache.commons" % "commons-io" % commonsIoVersion % Test, ), - mimaPreviousArtifacts := { - Set(organization.value % s"${name.value}_${scalaBinaryVersion.value}" % previousVersion) + mimaPreviousArtifacts := previousCompatibleVersions.map { previousVersion => + organization.value % s"${name.value}_${scalaBinaryVersion.value}" % previousVersion }, ) @@ -192,7 +192,7 @@ lazy val `commons-macros` = project.settings( ) lazy val `commons-core` = project - .dependsOn(`commons-macros`, `commons-annotations`) + .dependsOn(`commons-macros`, `commons-annotations` % CompileAndTest) .settings( jvmCommonSettings, sourceDirsSettings(_ / "jvm"), @@ -206,7 +206,7 @@ lazy val `commons-core` = project lazy val `commons-core-js` = project.in(`commons-core`.base / "js") .enablePlugins(ScalaJSPlugin) .configure(p => if (forIdeaImport) p.dependsOn(`commons-core`) else p) - .dependsOn(`commons-macros`, `commons-annotations-js`) + .dependsOn(`commons-macros`, `commons-annotations-js` % CompileAndTest) .settings( jsCommonSettings, name := (name in `commons-core`).value, @@ -229,13 +229,16 @@ lazy val `commons-analyzer` = project ) lazy val `commons-jetty` = project - .dependsOn(`commons-core`) + .dependsOn(`commons-core` % CompileAndTest) .settings( jvmCommonSettings, libraryDependencies ++= Seq( "org.eclipse.jetty" % "jetty-client" % jettyVersion, "org.eclipse.jetty" % "jetty-server" % jettyVersion, "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion, + + "org.eclipse.jetty" % "jetty-servlet" % jettyVersion % Test, + "org.slf4j" % "slf4j-simple" % "1.7.25" % Test, ), ) @@ -283,7 +286,7 @@ lazy val `commons-benchmark-js` = project.in(`commons-benchmark`.base / "js") ) lazy val `commons-mongo` = project - .dependsOn(`commons-core`) + .dependsOn(`commons-core` % CompileAndTest) .settings( jvmCommonSettings, libraryDependencies ++= Seq( @@ -297,7 +300,7 @@ lazy val `commons-mongo` = project ) lazy val `commons-kafka` = project - .dependsOn(`commons-core`) + .dependsOn(`commons-core` % CompileAndTest) .settings( jvmCommonSettings, libraryDependencies ++= Seq( @@ -306,7 +309,7 @@ lazy val `commons-kafka` = project ) lazy val `commons-redis` = project - .dependsOn(`commons-core`) + .dependsOn(`commons-core` % CompileAndTest) .settings( jvmCommonSettings, libraryDependencies ++= Seq( @@ -318,7 +321,7 @@ lazy val `commons-redis` = project ) lazy val `commons-spring` = project - .dependsOn(`commons-core`) + .dependsOn(`commons-core` % CompileAndTest) .settings( jvmCommonSettings, libraryDependencies ++= Seq( @@ -328,7 +331,7 @@ lazy val `commons-spring` = project ) lazy val `commons-akka` = project - .dependsOn(`commons-core`) + .dependsOn(`commons-core` % CompileAndTest) .settings( jvmCommonSettings, libraryDependencies ++= Seq( diff --git a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/MonixRPCFramework.scala b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/MonixRPCFramework.scala index 2a6089957..bb402e0c4 100644 --- a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/MonixRPCFramework.scala +++ b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/MonixRPCFramework.scala @@ -8,7 +8,7 @@ trait MonixRPCFramework extends RPCFramework { override type RawRPC <: MonixRawRPC trait MonixRawRPC { this: RawRPC => - @multi def observe(rpcName: String)(@multi args: List[RawValue]): Observable[RawValue] + @multi def observe(@composite invocation: RawInvocation): Observable[RawValue] } implicit def readerBasedObservableAsReal[T: Reader]: AsReal[Observable[RawValue], Observable[T]] = diff --git a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/RemoteMessage.scala b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/RemoteMessage.scala index e55224c51..589b56ee0 100644 --- a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/RemoteMessage.scala +++ b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/RemoteMessage.scala @@ -2,7 +2,7 @@ package com.avsystem.commons package rpc.akka import akka.util.ByteString -import com.avsystem.commons.rpc.akka.AkkaRPCFramework.RawValue +import com.avsystem.commons.rpc.akka.AkkaRPCFramework._ import com.avsystem.commons.serialization.GenCodec /** @@ -11,8 +11,8 @@ import com.avsystem.commons.serialization.GenCodec private sealed trait RemoteMessage extends Serializable private object RemoteMessage { - implicit val byteStringCodec = GenCodec.create[ByteString](input => ByteString(input.readBinary()), (output, byteString) => output.writeBinary(byteString.toArray)) - implicit val rawInvocationCodec = GenCodec.materialize[RawInvocation] + implicit val byteStringCodec: GenCodec[ByteString] = + GenCodec.create[ByteString](input => ByteString(input.readBinary()), (output, byteString) => output.writeBinary(byteString.toArray)) implicit val procedureInvocationMessageCodec: GenCodec[ProcedureInvocationMessage] = GenCodec.materialize[ProcedureInvocationMessage] implicit val functionInvocationMessageCodec: GenCodec[FunctionInvocationMessage] = GenCodec.materialize[FunctionInvocationMessage] @@ -28,16 +28,13 @@ private object RemoteMessage { implicit val heatBeatCodec: GenCodec[MonixProtocol.Heartbeat.type] = GenCodec.materialize[MonixProtocol.Heartbeat.type] } -private final case class RawInvocation(rpcName: String, args: List[RawValue]) extends RemoteMessage - private sealed trait InvocationMessage extends RemoteMessage { def getterChain: Seq[RawInvocation] - def name: String - def args: List[RawValue] + def invocation: RawInvocation } -private final case class ProcedureInvocationMessage(name: String, args: List[RawValue], getterChain: Seq[RawInvocation]) extends InvocationMessage -private final case class FunctionInvocationMessage(name: String, args: List[RawValue], getterChain: Seq[RawInvocation]) extends InvocationMessage -private final case class ObservableInvocationMessage(name: String, args: List[RawValue], getterChain: Seq[RawInvocation]) extends InvocationMessage +private final case class ProcedureInvocationMessage(invocation: RawInvocation, getterChain: Seq[RawInvocation]) extends InvocationMessage +private final case class FunctionInvocationMessage(invocation: RawInvocation, getterChain: Seq[RawInvocation]) extends InvocationMessage +private final case class ObservableInvocationMessage(invocation: RawInvocation, getterChain: Seq[RawInvocation]) extends InvocationMessage private sealed trait InvocationResult extends RemoteMessage private final case class InvocationSuccess(value: RawValue) extends InvocationResult @@ -52,4 +49,4 @@ private object MonixProtocol { case object StreamCompleted extends RemoteMessage case object Heartbeat extends RemoteMessage -} \ No newline at end of file +} diff --git a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/client/ClientRawRPC.scala b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/client/ClientRawRPC.scala index ea4078827..2d760978e 100644 --- a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/client/ClientRawRPC.scala +++ b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/client/ClientRawRPC.scala @@ -4,7 +4,7 @@ package rpc.akka.client import akka.actor.ActorSystem import akka.pattern.ask import akka.util.Timeout -import com.avsystem.commons.rpc.akka.AkkaRPCFramework.{RawRPC, RawValue} +import com.avsystem.commons.rpc.akka.AkkaRPCFramework._ import com.avsystem.commons.rpc.akka._ import monix.execution.Cancelable import monix.reactive.{Observable, OverflowStrategy} @@ -14,12 +14,12 @@ import monix.reactive.{Observable, OverflowStrategy} */ private[akka] final class ClientRawRPC(config: AkkaRPCClientConfig, getterChain: Seq[RawInvocation] = Nil)(implicit system: ActorSystem) extends AkkaRPCFramework.RawRPC { - override def fire(rpcName: String)(args: List[RawValue]): Unit = { - system.actorSelection(config.serverPath) ! ProcedureInvocationMessage(rpcName, args, getterChain) + override def fire(invocation: RawInvocation): Unit = { + system.actorSelection(config.serverPath) ! ProcedureInvocationMessage(invocation, getterChain) } - override def call(rpcName: String)(args: List[RawValue]): Future[RawValue] = { + override def call(invocation: RawInvocation): Future[RawValue] = { implicit val timeout: Timeout = Timeout(config.functionCallTimeout) - val future = system.actorSelection(config.serverPath) ? FunctionInvocationMessage(rpcName, args, getterChain) + val future = system.actorSelection(config.serverPath) ? FunctionInvocationMessage(invocation, getterChain) import com.avsystem.commons.concurrent.RunNowEC.Implicits.executionContext @@ -29,13 +29,13 @@ private[akka] final class ClientRawRPC(config: AkkaRPCClientConfig, getterChain: case value => Future.failed(new IllegalStateException(s"Illegal message type. Should be InvocationResult, but received value was: $value")) } } - override def get(rpcName: String)(args: List[RawValue]): RawRPC = - new ClientRawRPC(config, getterChain :+ RawInvocation(rpcName, args)) + override def get(invocation: RawInvocation): RawRPC = + new ClientRawRPC(config, getterChain :+ invocation) - override def observe(rpcName: String)(args: List[RawValue]): Observable[RawValue] = { + override def observe(invocation: RawInvocation): Observable[RawValue] = { Observable.create(OverflowStrategy.Unbounded) { s => val actor = system.actorOf(MonixClientActor.props(s, config)) - actor ! ObservableInvocationMessage(rpcName, args, getterChain) + actor ! ObservableInvocationMessage(invocation, getterChain) Cancelable.empty // TODO implement proper canceling } diff --git a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/serialization/RemoteMessageSerializer.scala b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/serialization/RemoteMessageSerializer.scala index 2bcf10449..e09fbee2c 100644 --- a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/serialization/RemoteMessageSerializer.scala +++ b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/serialization/RemoteMessageSerializer.scala @@ -26,7 +26,6 @@ final class RemoteMessageSerializer extends Serializer { case c if c == classOf[ProcedureInvocationMessage] => GenCodec.read[ProcedureInvocationMessage](input) case c if c == classOf[FunctionInvocationMessage] => GenCodec.read[FunctionInvocationMessage](input) case c if c == classOf[ObservableInvocationMessage] => GenCodec.read[ObservableInvocationMessage](input) - case c if c == classOf[RawInvocation] => GenCodec.read[RawInvocation](input) case c if c == classOf[InvocationSuccess] => GenCodec.read[InvocationSuccess](input) case c if c == classOf[InvocationFailure] => GenCodec.read[InvocationFailure](input) case c if c == MonixProtocol.Continue.getClass => GenCodec.read[MonixProtocol.Continue.type](input) @@ -46,7 +45,6 @@ final class RemoteMessageSerializer extends Serializer { case m: ProcedureInvocationMessage => GenCodec.write(output, m) case m: FunctionInvocationMessage => GenCodec.write(output, m) case m: ObservableInvocationMessage => GenCodec.write(output, m) - case m: RawInvocation => GenCodec.write(output, m) case m: InvocationSuccess => GenCodec.write(output, m) case m: InvocationFailure => GenCodec.write(output, m) case MonixProtocol.Continue => GenCodec.write(output, MonixProtocol.Continue) diff --git a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/server/ServerActor.scala b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/server/ServerActor.scala index cc1116bb7..03edee5ad 100644 --- a/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/server/ServerActor.scala +++ b/commons-akka/src/main/scala/com/avsystem/commons/rpc/akka/server/ServerActor.scala @@ -5,6 +5,7 @@ import akka.actor.{Actor, ActorLogging, Props} import akka.pattern.{AskTimeoutException, ask} import akka.util.Timeout import com.avsystem.commons.concurrent.RunNowEC +import com.avsystem.commons.rpc.akka.AkkaRPCFramework._ import com.avsystem.commons.rpc.akka._ import monix.execution.{Ack, Scheduler} import monix.reactive.Observable @@ -12,20 +13,20 @@ import monix.reactive.Observable /** * @author Wojciech Milewski */ -private final class ServerActor(rawRPC: AkkaRPCFramework.RawRPC, config: AkkaRPCServerConfig) extends Actor with ActorLogging { +private final class ServerActor(rawRPC: RawRPC, config: AkkaRPCServerConfig) extends Actor with ActorLogging { override def receive: Receive = { - case msg@ProcedureInvocationMessage(name, argLists, getterChain) => - resolveRpc(msg).fire(name)(argLists) - case msg@FunctionInvocationMessage(name, argLists, getterChain) => + case ProcedureInvocationMessage(invocation, getterChain) => + resolveRpc(getterChain).fire(invocation) + case FunctionInvocationMessage(invocation, getterChain) => val s = sender() - resolveRpc(msg).call(name)(argLists).onCompleteNow { + resolveRpc(getterChain).call(invocation).onCompleteNow { case Success(value) => s ! InvocationSuccess(value) case Failure(e) => - logError(e, name) + logError(e, invocation.rpcName) s ! InvocationFailure(e.getClass.getCanonicalName, e.getMessage) } - case msg@ObservableInvocationMessage(name, argLists, getterChain) => + case ObservableInvocationMessage(invocation, getterChain) => implicit val scheduler: Scheduler = Scheduler(RunNowEC) implicit val timeout: Timeout = Timeout(config.observableAckTimeout) val s = sender() @@ -36,7 +37,7 @@ private final class ServerActor(rawRPC: AkkaRPCFramework.RawRPC, config: AkkaRPC Ack.Continue } - resolveRpc(msg).observe(name)(argLists).subscribe( + resolveRpc(getterChain).observe(invocation).subscribe( value => { val result = s ? InvocationSuccess(value) result.mapTo[MonixProtocol.RemoteAck].map { @@ -53,7 +54,7 @@ private final class ServerActor(rawRPC: AkkaRPCFramework.RawRPC, config: AkkaRPC }, e => { heartbeat.cancel() - logError(e, name) + logError(e, invocation.rpcName) s ! InvocationFailure(e.getClass.getCanonicalName, e.getMessage) }, () => { @@ -63,8 +64,8 @@ private final class ServerActor(rawRPC: AkkaRPCFramework.RawRPC, config: AkkaRPC ) } - private def resolveRpc(msg: InvocationMessage) = - rawRPC.resolveGetterChain(msg.getterChain.map(r => AkkaRPCFramework.RawInvocation(r.rpcName, r.args)).toList) + private def resolveRpc(getterChain: Seq[RawInvocation]): RawRPC = + rawRPC.resolveGetterChain(getterChain) private def logError(e: Throwable, methodName: String): Unit = { log.error(e, @@ -76,5 +77,5 @@ private final class ServerActor(rawRPC: AkkaRPCFramework.RawRPC, config: AkkaRPC } private[akka] object ServerActor { - def props(rawRPC: AkkaRPCFramework.RawRPC, config: AkkaRPCServerConfig): Props = Props(new ServerActor(rawRPC, config)) + def props(rawRPC: RawRPC, config: AkkaRPCServerConfig): Props = Props(new ServerActor(rawRPC, config)) } diff --git a/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/AkkaRPCFrameworkTest.scala b/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/AkkaRPCFrameworkTest.scala index a58a43377..b818d8bd7 100644 --- a/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/AkkaRPCFrameworkTest.scala +++ b/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/AkkaRPCFrameworkTest.scala @@ -14,10 +14,9 @@ import scala.concurrent.duration._ * @author Wojciech Milewski */ abstract class AkkaRPCFrameworkTest( - serverSystem: ActorSystem, - clientSystem: ActorSystem, - serverSystemPath: Option[String] = None) - extends FlatSpec with RPCFrameworkTest with ProcedureRPCTest with FunctionRPCTest with GetterRPCTest with ObservableRPCTest with BeforeAndAfterAll { + serverSystem: ActorSystem, clientSystem: ActorSystem, serverSystemPath: Option[String] = None) + extends FlatSpec with RPCFrameworkTest with ProcedureRPCTest with FunctionRPCTest with GetterRPCTest with ObservableRPCTest + with BeforeAndAfterAll { /** * Servers as identifier supplier for each test case to allow tests parallelization. diff --git a/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/RPCFrameworkTest.scala b/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/RPCFrameworkTest.scala index f37bb41fc..8be0b5988 100644 --- a/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/RPCFrameworkTest.scala +++ b/commons-akka/src/test/scala/com/avsystem/commons/rpc/akka/RPCFrameworkTest.scala @@ -21,9 +21,10 @@ trait RPCFrameworkTest extends FlatSpecLike with Matchers with MockitoSugar with import RPCFrameworkTest._ - val callTimeout = 200.millis + val callTimeout: FiniteDuration = 200.millis - override implicit val patienceConfig: PatienceConfig = PatienceConfig(timeout = Span.convertDurationToSpan(3.seconds)) + override implicit val patienceConfig: PatienceConfig = + PatienceConfig(timeout = Span.convertDurationToSpan(10.seconds)) /** * Run tests with connection between client and server. diff --git a/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerPlugin.scala b/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerPlugin.scala index b2a160840..9d41fc7ed 100644 --- a/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerPlugin.scala +++ b/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerPlugin.scala @@ -6,34 +6,45 @@ import scala.tools.nsc.{Global, Phase} final class AnalyzerPlugin(val global: Global) extends Plugin { plugin => - val rules = List[AnalyzerRule[global.type]]( - new ImportJavaUtil[global.type](global), - new VarargsAtLeast[global.type](global), - new CheckMacroPrivate[global.type](global), - new ExplicitGenerics[global.type](global), - new ValueEnumExhaustiveMatch[global.type](global), - new ShowAst[global.type](global) - ) - val rulesByName = rules.map(r => (r.name, r)).toMap - override def init(options: List[String], error: String => Unit): Boolean = { options.foreach { option => val level = option.charAt(0) match { case '-' => Level.Off + case '*' => Level.Info case '+' => Level.Error case _ => Level.Warn } - val name = if (level != Level.Warn) option.drop(1) else option - if (name == "_") { + val nameArg = if (level != Level.Warn) option.drop(1) else option + if (nameArg == "_") { rules.foreach(_.level = level) - } else rulesByName.get(name) match { - case Some(rule) => rule.level = level - case None => error(s"Unrecognized AVS analyzer rule: $name") + } else { + val (name, arg) = nameArg.split(":", 2) match { + case Array(n, a) => (n, a) + case Array(n) => (n, null) + } + rulesByName.get(name) match { + case Some(rule) => + rule.level = level + rule.argument = arg + case None => + error(s"Unrecognized AVS analyzer rule: $name") + } } } true } + private lazy val rules = List[AnalyzerRule[global.type]]( + new ImportJavaUtil[global.type](global), + new VarargsAtLeast[global.type](global), + new CheckMacroPrivate[global.type](global), + new ExplicitGenerics[global.type](global), + new ValueEnumExhaustiveMatch[global.type](global), + new ShowAst[global.type](global), + new FindUsages[global.type](global) + ) + private lazy val rulesByName = rules.map(r => (r.name, r)).toMap + val name = "AVSystemAnalyzer" val description = "AVSystem custom Scala static analyzer" val components: List[PluginComponent] = List(component) diff --git a/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerRule.scala b/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerRule.scala index 366720bf8..fb85e24a5 100644 --- a/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerRule.scala +++ b/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/AnalyzerRule.scala @@ -12,8 +12,9 @@ abstract class AnalyzerRule[C <: Global with Singleton]( import global._ var level: Level = defaultLevel + var argument: String = _ - protected def classType(fullName: String) = + protected def classType(fullName: String): Type = try rootMirror.staticClass(fullName).asType.toType.erasure catch { case _: ScalaReflectionException => NoType } @@ -29,6 +30,7 @@ abstract class AnalyzerRule[C <: Global with Singleton]( protected def report(pos: Position, message: String): Unit = level match { case Level.Off => + case Level.Info => reporter.info(pos, message, force = true) case Level.Warn => reporter.warning(pos, message) case Level.Error => reporter.error(pos, message) } @@ -41,6 +43,7 @@ abstract class AnalyzerRule[C <: Global with Singleton]( sealed trait Level object Level { case object Off extends Level + case object Info extends Level case object Warn extends Level case object Error extends Level } diff --git a/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/FindUsages.scala b/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/FindUsages.scala new file mode 100644 index 000000000..7d708b1d6 --- /dev/null +++ b/commons-analyzer/src/main/scala/com/avsystem/commons/analyzer/FindUsages.scala @@ -0,0 +1,20 @@ +package com.avsystem.commons +package analyzer + +import scala.tools.nsc.Global + +class FindUsages[C <: Global with Singleton](g: C) extends AnalyzerRule(g, "findUsages") { + + import global._ + + lazy val rejectedSymbols: Set[String] = + if (argument == null) Set.empty else argument.split(";").toSet + + override def analyze(unit: CompilationUnit): Unit = if (rejectedSymbols.nonEmpty) { + unit.body.foreach { tree => + if (tree.symbol != null && rejectedSymbols.contains(tree.symbol.fullName)) { + report(tree.pos, s"found usage of ${tree.symbol.fullName}") + } + } + } +} diff --git a/commons-analyzer/src/test/scala/com/avsystem/commons/analyzer/AnalyzerTest.scala b/commons-analyzer/src/test/scala/com/avsystem/commons/analyzer/AnalyzerTest.scala index 9e1d1aa8d..07204306d 100644 --- a/commons-analyzer/src/test/scala/com/avsystem/commons/analyzer/AnalyzerTest.scala +++ b/commons-analyzer/src/test/scala/com/avsystem/commons/analyzer/AnalyzerTest.scala @@ -1,6 +1,7 @@ package com.avsystem.commons package analyzer +import org.scalactic.source.Position import org.scalatest.Assertions import scala.reflect.internal.util.BatchSourceFile @@ -11,7 +12,8 @@ trait AnalyzerTest { this: Assertions => val settings = new Settings settings.usejavacp.value = true settings.pluginOptions.value ++= List("AVSystemAnalyzer:+_") - val compiler = new Global(settings) { global => + + val compiler: Global = new Global(settings) { global => override protected def loadRoughPluginsList(): List[Plugin] = new AnalyzerPlugin(global) :: super.loadRoughPluginsList() } @@ -22,12 +24,17 @@ trait AnalyzerTest { this: Assertions => run.compileSources(List(new BatchSourceFile("test.scala", source))) } - def assertErrors(source: String): Unit = { + def assertErrors(source: String)(implicit pos: Position): Unit = { compile(source) assert(compiler.reporter.hasErrors) } - def assertNoErrors(source: String): Unit = { + def assertErrors(errors: Int, source: String)(implicit pos: Position): Unit = { + compile(source) + assert(compiler.reporter.errorCount == errors) + } + + def assertNoErrors(source: String)(implicit pos: Position): Unit = { compile(source) assert(!compiler.reporter.hasErrors) } diff --git a/commons-analyzer/src/test/scala/com/avsystem/commons/analyzer/FindUsagesTest.scala b/commons-analyzer/src/test/scala/com/avsystem/commons/analyzer/FindUsagesTest.scala new file mode 100644 index 000000000..57ef23aff --- /dev/null +++ b/commons-analyzer/src/test/scala/com/avsystem/commons/analyzer/FindUsagesTest.scala @@ -0,0 +1,18 @@ +package com.avsystem.commons +package analyzer + +import org.scalatest.FunSuite + +class FindUsagesTest extends FunSuite with AnalyzerTest { + settings.pluginOptions.value ++= List("AVSystemAnalyzer:+findUsages:java.lang.String") + + test("java.lang.String usages should be found") { + assertErrors(2, + """ + |object whatever { + | val x: String = String.valueOf(123) + |} + """.stripMargin + ) + } +} diff --git a/commons-annotations/src/main/scala/com/avsystem/commons/annotation/defaultsToName.scala b/commons-annotations/src/main/scala/com/avsystem/commons/annotation/defaultsToName.scala new file mode 100644 index 000000000..62ccf4bf2 --- /dev/null +++ b/commons-annotations/src/main/scala/com/avsystem/commons/annotation/defaultsToName.scala @@ -0,0 +1,22 @@ +package com.avsystem.commons +package annotation + +import scala.annotation.StaticAnnotation + +/** + * Meta annotation that may be used on `String` constructor parameter of an annotation. This constructor parameter + * must take a default `null` value. [[defaultsToName]] makes annotation processing macro engines insert the name + * of annotated symbol instead of `null`. + * + * @example + * + * {{{ + * class SomeMethodAnnotation(@defaultsToName val name: String = null) + * + * @SomeMethodAnnotation def someMethod: String + * }}} + * + * Now, when some macro engine has to inspect `SomeMethodAnnotation` of `someMethod`, it will automatically insert + * the string "someMethod" as the argument of `SomeMethodAnnotation`. + */ +class defaultsToName extends StaticAnnotation diff --git a/commons-annotations/src/main/scala/com/avsystem/commons/rpc/rpcAnnotations.scala b/commons-annotations/src/main/scala/com/avsystem/commons/rpc/rpcAnnotations.scala index c18905af6..d3ccd49db 100644 --- a/commons-annotations/src/main/scala/com/avsystem/commons/rpc/rpcAnnotations.scala +++ b/commons-annotations/src/main/scala/com/avsystem/commons/rpc/rpcAnnotations.scala @@ -23,6 +23,19 @@ sealed trait RawParamAnnotation extends RawRpcAnnotation */ class rpcName(val name: String) extends RpcAnnotation +/** + * You can use this annotation on real RPC methods to instruct macro engine to prepend method name (or [[rpcName]] if + * specified) with given prefix. This annotation is mostly useful when aggregated by another annotation e.g. + * + * {{{ + * sealed trait RestMethod extends RpcTag + * final class GET extends RestMethod with AnnotationAggregate { + * @rpcNamePrefix("GET_") type Implied + * } + * }}} + */ +class rpcNamePrefix(val prefix: String) extends RpcAnnotation + /** * Base trait for RPC tag annotations. Tagging gives more direct control over how real methods * and their parameters are matched against raw methods and their parameters. @@ -31,6 +44,25 @@ class rpcName(val name: String) extends RpcAnnotation */ trait RpcTag extends RpcAnnotation +/** + * May be applied on raw method parameter of type `String` to indicate that macro generated implementation of + * `AsReal` should pass real method's RPC name as this parameter and that macro generated implementation of + * `AsRaw` should expect real method's RPC name to be passed there. + * + * Macro generation of `AsRaw` implementations require that raw methods annotated as [[multi]] must take at least + * one raw parameter annotated as [[methodName]] (it may also be aggregated into some [[composite]] parameter). + * This is necessary to properly identify which real method should be called. + */ +final class methodName extends RawParamAnnotation + +/** + * Can be applied on raw method parameters or metadata parameters. When a parameter is annotated as `@composite`, + * the macro engine expects its type to be a class with public primary constructor. Then, it recursively inspects its + * constructor parameters and treats them as if they were direct parameters. This effectively groups multiple + * raw parameters or multiple metadata parameters into a single class. + */ +final class composite extends RawParamAnnotation + /** * Base trait for RPC arity annotations, [[single]], [[optional]] and [[multi]]. * Arity annotations may be used in multiple contexts: @@ -98,8 +130,8 @@ final class optional extends RpcArity /** * When applied on raw method, specifies that this raw method may be matched by many, arbitrarily named real methods. - * In order to distinguish between real methods, multi raw method must take real method's RPC name - * (a `String`) as its first parameter and the only parameter in its first parameter list. + * In order to distinguish between real methods when translating raw call into real call, + * multi raw method must take real method's RPC name (a `String`) as one of its parameters (see [[methodName]]). * By default, result type of multi raw method is [[encoded]] and the macro engine searches for * appropriate `AsRaw` or `AsReal` conversion between real method result type and raw method result type. * @@ -194,7 +226,7 @@ sealed trait RpcEncoding extends RawMethodAnnotation with RawParamAnnotation * } * * trait AsyncRawRpc { - * def call(rpcName: String, @multi args: Map[String,Json]): Future[Json] + * def call(@methodName rpcName: String, @multi args: Map[String,Json]): Future[Json] * } * }}} * @@ -204,7 +236,7 @@ sealed trait RpcEncoding extends RawMethodAnnotation with RawParamAnnotation * * {{{ * trait AsyncRawRpc { - * def call(rpcName: String, @multi args: Map[String,String]): Future[String] + * def call(@methodName rpcName: String, @multi args: Map[String,String]): Future[String] * } * object AsyncRawRpc extends RawRpcCompanion[AsyncRawRpc] { * private def readJson[T: GenCodec](json: String): T = @@ -233,55 +265,68 @@ final class encoded extends RpcEncoding * * {{{ * trait VerbatimRawRpc { - * @verbatim def call(rpcName: String, @multi @verbatim args: Map[String,Int]): Double + * @verbatim def call(@methodName rpcName: String, @multi @verbatim args: Map[String,Int]): Double * } * }}} */ final class verbatim extends RpcEncoding +/** + * When raw method is annotated as `@tried`, invocations of real methods matching that raw method will be + * automatically wrapped into `Try`. Consequently, all real methods will be treated as if their result + * type was `Try[Result]` instead of actual `Result`. For example, if raw method is [[encoded]] and its + * (raw) result is `Raw` then macro engine will search for implicit `AsRaw/Real[Raw,Try[Result]]` instead of just + * `AsRaw/Real[Raw,Result]` + */ +final class tried extends RawMethodAnnotation + /** * Method tagging lets you have more explicit control over which raw methods can match which real methods. * Example: * * {{{ - * sealed trait RestMethod extends RpcTag + * sealed trait MethodType extends RpcTag * class GET extends RestMethod * class POST extends RestMethod * - * @methodTag[RestMethod,GET] - * trait RestRawRpc { - * @tagged[GET] def get(name: String, @multi args: Map[String,Json]): Future[Json] - * @tagged[POST] def post(name: String, @multi args: Map[String,Json]): Future[Json] + * @methodTag[MethodType](new GET) + * trait ExampleRawRpc { + * @tagged[GET] def get(@methodName name: String, @multi args: Map[String,Json]): Future[Json] + * @tagged[POST] def post(@methodName name: String, @multi args: Map[String,Json]): Future[Json] * } * }}} * - * In the example above, we created a hierarchy of annotations rooted at `RestMethod` which can be used + * In the example above, we created a hierarchy of annotations rooted at `MethodType` which can be used * on real methods in order to explicitly tell the RPC macro which raw methods can match it. - * We also specify `GET` as the default tag that will be assumed for real methods without any tag annotation. - * Then, using `@tagged` we specify that the raw `get` method may only match real methods annotated as `GET` + * We also specify `new GET` as the default tag that will be assumed for real methods without any tag annotation. + * Then, using [[tagged]] we specify that the raw `get` method may only match real methods annotated as `GET` * while `post` raw method may only match real methods annotated as `POST`. - * Raw methods not annotated with `@tagged` have no limitations and may still match any real methods. + * Raw methods not annotated with [[tagged]] have no limitations and may still match any real methods. * - * NOTE: The example above assumes there is a `Json` type defined with appropriate encodings - + * Also, instead of specifying `defaultTag` in `@methodTag` annotation, you may provide the `whenUntagged` + * parameter to [[tagged]] annotation. Raw method annotated as `@tagged[MethodType](whenUntagged = new GET)` + * will match real methods either explicitly tagged with `GET` or untagged. If untagged, `new GET` will be assumed + * as the tag. This is useful when you want to have multiple raw methods with different `whenUntagged` setting. + * + * NOTE: The example above assumes there is a Json` type defined with appropriate encodings - * see [[encoded]] for more details on parameter and method result encoding. * - * An example of real RPC for `RestRawRpc`: + * An example of real RPC for `ExampleRawRpc`: * * {{{ - * trait SomeRestApi { + * trait ExampleApi { * def getUser(id: UserId): Future[User] * @POST def saveUser(user: User): Future[Unit] * } - * object SomeRestApi { - * implicit val AsRawReal: AsRawReal[RestRawRpc,SomeRestApi] = AsRawReal.materializeForRpc + * object ExampleApi { + * implicit val AsRawReal: AsRawReal[ExampleRawRpc,ExampleApi] = AsRawReal.materializeForRpc * } * }}} * - * @tparam BaseTag base type for tags that can be used on real RPC methods - * @tparam DefaultTag the default tag type used for real methods not explicitly tagged - if you don't want to - * introduce any specific default tag, just use the same type as for `BaseTag` + * @tparam BaseTag base type for tags that can be used on real RPC methods + * @param defaultTag default tag value assumed for untagged methods */ -final class methodTag[BaseTag <: RpcTag, DefaultTag <: BaseTag] extends RawRpcAnnotation +final class methodTag[BaseTag <: RpcTag](val defaultTag: BaseTag = null) extends RawRpcAnnotation /** * Parameter tagging lets you have more explicit control over which raw parameters can match which real @@ -296,9 +341,10 @@ final class methodTag[BaseTag <: RpcTag, DefaultTag <: BaseTag] extends RawRpcAn * class Url extends RestParam * class Path extends RestParam * - * @paramTag[RestParam,Body] + * @paramTag[RestParam](new Body) * trait RestRawRpc { - * def get(name: String, + * def get( + * @methodName name: String, * @multi @verbatim @tagged[Path] pathParams: List[String], * @multi @verbatim @tagged[Url] urlParams: Map[String,String], * @multi @tagged[Body] bodyParams: Map[String,Json] @@ -314,16 +360,15 @@ final class methodTag[BaseTag <: RpcTag, DefaultTag <: BaseTag] extends RawRpcAn * * {{{ * trait RestRawRpc { - * @paramTag[RestParam,Body] + * @paramTag[RestParam](new Body) * def get(...) * } * }}} * - * @tparam BaseTag base type for tags that can be used on real RPC parameters - * @tparam DefaultTag the default tag type used for real parameters not explicitly tagged - if you don't want to - * introduce any specific default tag, just use the same type as for `BaseTag` + * @tparam BaseTag base type for tags that can be used on real RPC parameters + * @param defaultTag default tag value assumed for untagged real parameters */ -final class paramTag[BaseTag <: RpcTag, DefaultTag <: BaseTag] extends RawMethodAnnotation +final class paramTag[BaseTag <: RpcTag](val defaultTag: BaseTag = null) extends RawMethodAnnotation /** * Annotation applied on raw methods or raw parameters that limits matching real methods or real parameters to @@ -331,8 +376,12 @@ final class paramTag[BaseTag <: RpcTag, DefaultTag <: BaseTag] extends RawMethod * also be some common supertype of multiple tags which are accepted by this raw method or param. * * @tparam Tag annotation type required to be present on real method or parameter + * @param whenUntagged default tag value assumed for untagged methods/parameters - if specified, this effectively + * means that raw method/parameter will also match untagged real methods/parameters and assume + * the default tag value for them */ -final class tagged[Tag <: RpcTag] extends RawMethodAnnotation with RawParamAnnotation +final class tagged[Tag <: RpcTag](val whenUntagged: Tag = null) + extends RawMethodAnnotation with RawParamAnnotation /** * Raw parameters annotated as `@auxiliary` match real parameters without "consuming" them. This means that @@ -387,11 +436,11 @@ final class infer extends MetadataParamStrategy final class reifyAnnot extends MetadataParamStrategy /** - * Metadata parameter typed as `Boolean` can be annotated with `@hasAnnot[SomeAnnotation]`. Boolean value will then + * Metadata parameter typed as `Boolean` can be annotated with `@isAnnotated[SomeAnnotation]`. Boolean value will then * hold information about whether RPC trait, method or parameter for which metadata is materialized is annotated with * `SomeAnnotation` (or any subtype) or not. */ -final class hasAnnot[T <: StaticAnnotation] extends MetadataParamStrategy +final class isAnnotated[T <: StaticAnnotation] extends MetadataParamStrategy /** * This annotation may only be applied on metadata parameters of type `String` and instructs the macro engine diff --git a/commons-annotations/src/main/scala/com/avsystem/commons/serialization/transientDefault.scala b/commons-annotations/src/main/scala/com/avsystem/commons/serialization/transientDefault.scala index e1b73231a..1933fd824 100644 --- a/commons-annotations/src/main/scala/com/avsystem/commons/serialization/transientDefault.scala +++ b/commons-annotations/src/main/scala/com/avsystem/commons/serialization/transientDefault.scala @@ -19,5 +19,7 @@ import scala.annotation.StaticAnnotation * `GenCodec.write(someOutput, Something("lol", 10))` would yield object `{"str": "lol", "int": 10}` but * `GenCodec.write(someOutput, Something("lol", 42))` would yield object `{"str": "lol"}` because the value of `int` * is the same as the default value. + * + * NOTE: [[transientDefault]] also works for method parameters in RPC framework. */ class transientDefault extends StaticAnnotation diff --git a/commons-annotations/src/main/scala/com/avsystem/commons/serialization/whenAbsent.scala b/commons-annotations/src/main/scala/com/avsystem/commons/serialization/whenAbsent.scala index 0da4eb2e8..88e28c5f1 100644 --- a/commons-annotations/src/main/scala/com/avsystem/commons/serialization/whenAbsent.scala +++ b/commons-annotations/src/main/scala/com/avsystem/commons/serialization/whenAbsent.scala @@ -22,6 +22,8 @@ import scala.annotation.StaticAnnotation * case class HasNoDefault(@whenAbsent(throw new Exception) str: String = "default") * object HasDefault extends HasGenCodec[HasDefault] * }}} + * + * NOTE: [[whenAbsent]] also works for method parameters in RPC framework. */ class whenAbsent[+T](v: => T) extends StaticAnnotation { def value: T = v diff --git a/commons-benchmark/jvm/src/main/scala/com/avsystem/commons/core/OptBenchmarks.scala b/commons-benchmark/jvm/src/main/scala/com/avsystem/commons/core/OptBenchmarks.scala index 30ec09fdb..896475bb5 100644 --- a/commons-benchmark/jvm/src/main/scala/com/avsystem/commons/core/OptBenchmarks.scala +++ b/commons-benchmark/jvm/src/main/scala/com/avsystem/commons/core/OptBenchmarks.scala @@ -3,22 +3,79 @@ package core import org.openjdk.jmh.annotations._ -@Warmup(iterations = 5) -@Measurement(iterations = 20) +import scala.annotation.tailrec + +case class NullList(value: Int, tail: NullList) { + @tailrec final def tailrecSum(acc: Int = 0): Int = { + val newAcc = acc + value + if (tail == null) newAcc else tail.tailrecSum(newAcc) + } +} +object NullList { + final val Example = (0 until 1000).foldRight(NullList(1000, null)) { + (value, tail) => NullList(value, tail) + } +} + +case class OptList(value: Int, tail: Opt[OptList]) { + @tailrec final def tailrecSum(acc: Int = 0): Int = { + val newAcc = acc + value + tail match { + case Opt(t) => t.tailrecSum(newAcc) + case Opt.Empty => newAcc + } + } +} +object OptList { + final val Example = (0 until 1000).foldRight(OptList(1000, Opt.Empty)) { + (value, tail) => OptList(value, Opt(tail)) + } +} + +case class OptRefList(value: Int, tail: OptRef[OptRefList]) { + @tailrec final def tailrecSum(acc: Int = 0): Int = { + val newAcc = acc + value + tail match { + case OptRef(t) => t.tailrecSum(newAcc) + case OptRef.Empty => newAcc + } + } +} +object OptRefList { + final val Example = (0 until 1000).foldRight(OptRefList(1000, OptRef.Empty)) { + (value, tail) => OptRefList(value, OptRef(tail)) + } +} + +case class OptionList(value: Int, tail: Option[OptionList]) { + @tailrec final def tailrecSum(acc: Int = 0): Int = { + val newAcc = acc + value + tail match { + case Some(t) => t.tailrecSum(newAcc) + case None => newAcc + } + } +} +object OptionList { + final val Example = (0 until 1000).foldRight(OptionList(1000, None)) { + (value, tail) => OptionList(value, Some(tail)) + } +} + +@Warmup(iterations = 2) +@Measurement(iterations = 5) @Fork(1) @BenchmarkMode(Array(Mode.Throughput)) class OptBenchmarks { - def doSomething(str: String): Unit = () - - def takeOpt(os: Opt[String]): Opt[String] = - os.filter(_.length < 10).map(_.toDouble).map(_.toString) + @Benchmark + def testNull: Int = NullList.Example.tailrecSum() - def takeOption(os: Option[String]): Option[String] = - os.filter(_.length < 10).map(_.toDouble).map(_.toString) + @Benchmark + def testOpt: Int = OptList.Example.tailrecSum() @Benchmark - def testOption: String = takeOption(Option("1234.56")).getOrElse("") + def testOptRef: Int = OptRefList.Example.tailrecSum() @Benchmark - def testOpt: String = takeOpt(Opt("1234.56")).getOrElse("") + def testOption: Int = OptionList.Example.tailrecSum() } diff --git a/commons-benchmark/src/main/scala/com/avsystem/commons/ser/CirceRestImplicits.scala b/commons-benchmark/src/main/scala/com/avsystem/commons/ser/CirceRestImplicits.scala new file mode 100644 index 000000000..85f72061c --- /dev/null +++ b/commons-benchmark/src/main/scala/com/avsystem/commons/ser/CirceRestImplicits.scala @@ -0,0 +1,16 @@ +package com.avsystem.commons +package ser + +import com.avsystem.commons.rest._ +import com.avsystem.commons.rpc._ +import io.circe._ +import io.circe.parser._ +import io.circe.syntax._ + +trait CirceRestImplicits { + implicit def encoderBasedAsRawJson[T: Encoder]: Fallback[AsRaw[JsonValue, T]] = + Fallback(AsRaw.create(v => JsonValue(v.asJson.noSpaces))) + implicit def decoderBasedJsonAsReal[T: Decoder]: Fallback[AsReal[JsonValue, T]] = + Fallback(AsReal.create(json => decode(json.value).fold(throw _, identity))) +} +object CirceRestImplicits extends CirceRestImplicits diff --git a/commons-core/src/main/scala-2.11/com/avsystem/commons/CompatSharedExtensions.scala b/commons-core/src/main/scala-2.11/com/avsystem/commons/CompatSharedExtensions.scala index 08b924bae..a94a77e12 100644 --- a/commons-core/src/main/scala-2.11/com/avsystem/commons/CompatSharedExtensions.scala +++ b/commons-core/src/main/scala-2.11/com/avsystem/commons/CompatSharedExtensions.scala @@ -1,10 +1,16 @@ package com.avsystem.commons -import com.avsystem.commons.CompatSharedExtensions.FutureCompatOps import com.avsystem.commons.concurrent.RunNowEC trait CompatSharedExtensions { + + import CompatSharedExtensions._ + implicit def futureCompatOps[A](fut: Future[A]): FutureCompatOps[A] = new FutureCompatOps(fut) + + implicit def futureCompanionCompatOps(fut: Future.type): FutureCompanionCompatOps.type = FutureCompanionCompatOps + + implicit def tryCompatOps[A](tr: Try[A]): TryCompatOps[A] = new TryCompatOps(tr) } object CompatSharedExtensions { @@ -28,4 +34,15 @@ object CompatSharedExtensions { def zipWith[U, R](that: Future[U])(f: (A, U) => R)(implicit executor: ExecutionContext): Future[R] = fut.flatMap(r1 => that.map(r2 => f(r1, r2)))(RunNowEC) } + + object FutureCompanionCompatOps { + final val unit: Future[Unit] = Future.successful(()) + } + + final class TryCompatOps[A](private val tr: Try[A]) extends AnyVal { + def fold[U](ft: Throwable => U, fa: A => U): U = tr match { + case Success(a) => fa(a) + case Failure(t) => ft(t) + } + } } diff --git a/commons-core/src/main/scala/com/avsystem/commons/CommonAliases.scala b/commons-core/src/main/scala/com/avsystem/commons/CommonAliases.scala index 1f20bf7a3..6a3d78f82 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/CommonAliases.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/CommonAliases.scala @@ -19,7 +19,7 @@ trait CommonAliases { type ClassTag[T] = scala.reflect.ClassTag[T] final val ClassTag = scala.reflect.ClassTag - final def classTag[T: ClassTag] = scala.reflect.classTag[T] + final def classTag[T: ClassTag]: ClassTag[T] = scala.reflect.classTag[T] type Opt[+T] = misc.Opt[T] final val Opt = misc.Opt diff --git a/commons-core/src/main/scala/com/avsystem/commons/SharedExtensions.scala b/commons-core/src/main/scala/com/avsystem/commons/SharedExtensions.scala index dcf3f71c5..40619732f 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/SharedExtensions.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/SharedExtensions.scala @@ -23,7 +23,7 @@ trait SharedExtensions extends CompatSharedExtensions { implicit def futureOps[A](fut: Future[A]): FutureOps[A] = new FutureOps(fut) - implicit def lazyFutureOps[A](fut: => Future[A]): LazyFutureOps[A] = new LazyFutureOps(fut) + implicit def lazyFutureOps[A](fut: => Future[A]): LazyFutureOps[A] = new LazyFutureOps(() => fut) implicit def futureCompanionOps(fut: Future.type): FutureCompanionOps.type = FutureCompanionOps @@ -31,6 +31,8 @@ trait SharedExtensions extends CompatSharedExtensions { implicit def tryOps[A](tr: Try[A]): TryOps[A] = new TryOps(tr) + implicit def lazyTryOps[A](tr: => Try[A]): LazyTryOps[A] = new LazyTryOps(() => tr) + implicit def tryCompanionOps(trc: Try.type): TryCompanionOps.type = TryCompanionOps implicit def partialFunctionOps[A, B](pf: PartialFunction[A, B]): PartialFunctionOps[A, B] = new PartialFunctionOps(pf) @@ -212,9 +214,7 @@ object SharedExtensions extends SharedExtensions { fut.transformWith(f)(RunNowEC) def wrapToTry: Future[Try[A]] = - fut.mapNow(Success(_)).recoverNow { - case NonFatal(t) => Failure(t) - } + fut.transformNow(Success(_)) /** * Maps a `Future` using [[concurrent.RunNowEC RunNowEC]]. @@ -265,7 +265,7 @@ object SharedExtensions extends SharedExtensions { thenReturn(Future.successful {}) } - class LazyFutureOps[A](fut: => Future[A]) { + class LazyFutureOps[A](private val fut: () => Future[A]) extends AnyVal { /** * Evaluates a left-hand-side expression that returns a `Future` and ensures that all exceptions thrown by * that expression are converted to a failed `Future`. @@ -273,7 +273,7 @@ object SharedExtensions extends SharedExtensions { * `NullPointerException`. */ def catchFailures: Future[A] = { - val result = try fut catch { + val result = try fut() catch { case NonFatal(t) => Future.failed(t) } if (result != null) result else Future.failed(new NullPointerException("null Future")) @@ -356,6 +356,16 @@ object SharedExtensions extends SharedExtensions { if (tr.isFailure) OptArg.Empty else OptArg(tr.get) } + class LazyTryOps[A](private val tr: () => Try[A]) extends AnyVal { + /** + * Evaluates a left-hand side expression that return `Try`, + * catches all exceptions and converts them into a `Failure`. + */ + def catchFailures: Try[A] = try tr() catch { + case NonFatal(t) => Failure(t) + } + } + object TryCompanionOps { import scala.language.higherKinds @@ -405,6 +415,11 @@ object SharedExtensions extends SharedExtensions { case NoValueMarker => Opt.Empty case rawValue => Opt(rawValue.asInstanceOf[B]) } + + def fold[C](a: A)(forEmpty: A => C, forNonEmpty: B => C): C = pf.applyOrElse(a, NoValueMarkerFunc) match { + case NoValueMarker => forEmpty(a) + case rawValue => forNonEmpty(rawValue.asInstanceOf[B]) + } } object PartialFunctionOps { private object NoValueMarker @@ -454,6 +469,15 @@ object SharedExtensions extends SharedExtensions { def minOpt[B >: A : Ordering]: Opt[B] = if (coll.isEmpty) Opt.Empty else coll.min[B].opt def minOptBy[B: Ordering](f: A => B): Opt[A] = if (coll.isEmpty) Opt.Empty else coll.minBy(f).opt + + def mkStringOr(start: String, sep: String, end: String, default: String): String = + if (coll.nonEmpty) coll.mkString(start, sep, end) else default + + def mkStringOr(sep: String, default: String): String = + if (coll.nonEmpty) coll.mkString(sep) else default + + def mkStringOrEmpty(start: String, sep: String, end: String): String = + mkStringOr(start, sep, end, "") } class SetOps[A](private val set: BSet[A]) extends AnyVal { diff --git a/commons-core/src/main/scala/com/avsystem/commons/rest/DefaultRestApiCompanion.scala b/commons-core/src/main/scala/com/avsystem/commons/rest/DefaultRestApiCompanion.scala new file mode 100644 index 000000000..72fd80dd9 --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rest/DefaultRestApiCompanion.scala @@ -0,0 +1,100 @@ +package com.avsystem.commons +package rest + +import com.avsystem.commons.rest.RawRest.{AsRawRealRpc, AsRawRpc, AsRealRpc} +import com.avsystem.commons.rpc.{AsRawReal, Fallback, RpcMacroInstances} +import com.avsystem.commons.serialization.json.{JsonStringInput, JsonStringOutput} +import com.avsystem.commons.serialization.{GenCodec, GenKeyCodec} + +trait ClientInstances[Real] { + def metadata: RestMetadata[Real] + def asReal: AsRealRpc[Real] +} +trait ServerInstances[Real] { + def metadata: RestMetadata[Real] + def asRaw: AsRawRpc[Real] +} +trait FullInstances[Real] { + def metadata: RestMetadata[Real] + def asRawReal: AsRawRealRpc[Real] +} + +/** @see [[RestApiCompanion]] */ +abstract class RestClientApiCompanion[Implicits, Real](implicits: Implicits)( + implicit inst: RpcMacroInstances[Implicits, ClientInstances, Real] +) { + implicit final lazy val restMetadata: RestMetadata[Real] = inst(implicits).metadata + implicit final lazy val restAsReal: AsRealRpc[Real] = inst(implicits).asReal + + final def fromHandleRequest(handleRequest: RawRest.HandleRequest): Real = + RawRest.fromHandleRequest(handleRequest) +} + +/** @see [[RestApiCompanion]] */ +abstract class RestServerApiCompanion[Implicits, Real](implicits: Implicits)( + implicit inst: RpcMacroInstances[Implicits, ServerInstances, Real] +) { + implicit final lazy val restMetadata: RestMetadata[Real] = inst(implicits).metadata + implicit final lazy val restAsRaw: AsRawRpc[Real] = inst(implicits).asRaw + + final def asHandleRequest(real: Real): RawRest.HandleRequest = + RawRest.asHandleRequest(real) +} + +/** + * Base class for REST trait companions. Reduces boilerplate needed in order to define appropriate instances + * of `AsRawReal` and `RestMetadata` for given trait. The `Implicits` type parameter lets you inject additional implicits + * into macro materialization of these instances, e.g. [[DefaultRestImplicits]]. + * Usually, for even less boilerplate, this base class is extended by yet another abstract class which fixes + * the `Implicits` type, e.g. [[DefaultRestApiCompanion]]. + */ +abstract class RestApiCompanion[Implicits, Real](implicits: Implicits)( + implicit inst: RpcMacroInstances[Implicits, FullInstances, Real] +) { + implicit final lazy val restMetadata: RestMetadata[Real] = inst(implicits).metadata + implicit final lazy val restAsRawReal: AsRawRealRpc[Real] = inst(implicits).asRawReal + + final def fromHandleRequest(handleRequest: RawRest.HandleRequest): Real = + RawRest.fromHandleRequest(handleRequest) + final def asHandleRequest(real: Real): RawRest.HandleRequest = + RawRest.asHandleRequest(real) +} + +/** + * Defines [[com.avsystem.commons.serialization.GenCodec GenCodec]] and + * [[com.avsystem.commons.serialization.GenKeyCodec GenKeyCodec]] based serialization for REST API traits. + */ +trait DefaultRestImplicits extends FloatingPointRestImplicits { + // Implicits wrapped into `Fallback` so that they don't get higher priority just because they're imported + // This way concrete classes may override these implicits with implicits in their companion objects + implicit def pathValueFallbackAsRealRaw[T: GenKeyCodec]: Fallback[AsRawReal[PathValue, T]] = + Fallback(AsRawReal.create(v => PathValue(GenKeyCodec.write[T](v)), v => GenKeyCodec.read[T](v.value))) + implicit def headerValueDefaultAsRealRaw[T: GenKeyCodec]: Fallback[AsRawReal[HeaderValue, T]] = + Fallback(AsRawReal.create(v => HeaderValue(GenKeyCodec.write[T](v)), v => GenKeyCodec.read[T](v.value))) + implicit def queryValueDefaultAsRealRaw[T: GenKeyCodec]: Fallback[AsRawReal[QueryValue, T]] = + Fallback(AsRawReal.create(v => QueryValue(GenKeyCodec.write[T](v)), v => GenKeyCodec.read[T](v.value))) + implicit def jsonValueDefaultAsRealRaw[T: GenCodec]: Fallback[AsRawReal[JsonValue, T]] = + Fallback(AsRawReal.create(v => JsonValue(JsonStringOutput.write[T](v)), v => JsonStringInput.read[T](v.value))) +} +object DefaultRestImplicits extends DefaultRestImplicits + +/** + * Base class for companions of REST API traits used only for REST clients to external services. + * Injects `GenCodec` and `GenKeyCodec` based serialization. + */ +abstract class DefaultRestClientApiCompanion[Real](implicit inst: RpcMacroInstances[DefaultRestImplicits, ClientInstances, Real]) + extends RestClientApiCompanion[DefaultRestImplicits, Real](DefaultRestImplicits) + +/** + * Base class for companions of REST API traits used only for REST servers exposed to external world. + * Injects `GenCodec` and `GenKeyCodec` based serialization. + */ +abstract class DefaultRestServerApiCompanion[Real](implicit inst: RpcMacroInstances[DefaultRestImplicits, ServerInstances, Real]) + extends RestServerApiCompanion[DefaultRestImplicits, Real](DefaultRestImplicits) + +/** + * Base class for companions of REST API traits used for both REST clients and servers. + * Injects `GenCodec` and `GenKeyCodec` based serialization. + */ +abstract class DefaultRestApiCompanion[Real](implicit inst: RpcMacroInstances[DefaultRestImplicits, FullInstances, Real]) + extends RestApiCompanion[DefaultRestImplicits, Real](DefaultRestImplicits) diff --git a/commons-core/src/main/scala/com/avsystem/commons/rest/FloatingPointRestImplicits.scala b/commons-core/src/main/scala/com/avsystem/commons/rest/FloatingPointRestImplicits.scala new file mode 100644 index 000000000..590294f3c --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rest/FloatingPointRestImplicits.scala @@ -0,0 +1,21 @@ +package com.avsystem.commons +package rest + +import com.avsystem.commons.rpc.AsRawReal + +trait FloatingPointRestImplicits { + implicit final val floatPathValueAsRealRaw: AsRawReal[PathValue, Float] = + AsRawReal.create(v => PathValue(v.toString), _.value.toFloat) + implicit final val floatHeaderValueAsRealRaw: AsRawReal[HeaderValue, Float] = + AsRawReal.create(v => HeaderValue(v.toString), _.value.toFloat) + implicit final val floatQueryValueAsRealRaw: AsRawReal[QueryValue, Float] = + AsRawReal.create(v => QueryValue(v.toString), _.value.toFloat) + + implicit final val doublePathValueAsRealRaw: AsRawReal[PathValue, Double] = + AsRawReal.create(v => PathValue(v.toString), _.value.toDouble) + implicit final val doubleHeaderValueAsRealRaw: AsRawReal[HeaderValue, Double] = + AsRawReal.create(v => HeaderValue(v.toString), _.value.toDouble) + implicit final val doubleQueryValueAsRealRaw: AsRawReal[QueryValue, Double] = + AsRawReal.create(v => QueryValue(v.toString), _.value.toDouble) +} +object FloatingPointRestImplicits extends FloatingPointRestImplicits diff --git a/commons-core/src/main/scala/com/avsystem/commons/rest/RawRest.scala b/commons-core/src/main/scala/com/avsystem/commons/rest/RawRest.scala new file mode 100644 index 000000000..b57caeede --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rest/RawRest.scala @@ -0,0 +1,172 @@ +package com.avsystem.commons +package rest + +import java.util.concurrent.atomic.AtomicBoolean + +import com.avsystem.commons.rpc._ + +case class RestMethodCall(rpcName: String, pathParams: List[PathValue], metadata: RestMethodMetadata[_]) +case class ResolvedPath(prefixes: List[RestMethodCall], finalCall: RestMethodCall, singleBody: Boolean) { + def prepend(rpcName: String, pathParams: List[PathValue], metadata: PrefixMetadata[_]): ResolvedPath = + copy(prefixes = RestMethodCall(rpcName, pathParams, metadata) :: prefixes) + + def rpcChainRepr: String = + prefixes.iterator.map(_.rpcName).mkString("", "->", s"->${finalCall.rpcName}") +} + +@methodTag[RestMethodTag] +trait RawRest { + + import RawRest._ + + @multi + @tried + @tagged[Prefix](whenUntagged = new Prefix) + @paramTag[RestParamTag](defaultTag = new Path) + def prefix(@methodName name: String, @composite parameters: RestParameters): Try[RawRest] + + @multi + @tried + @tagged[GET] + @paramTag[RestParamTag](defaultTag = new Query) + def get(@methodName name: String, @composite parameters: RestParameters): Async[RestResponse] + + @multi + @tried + @tagged[BodyMethodTag](whenUntagged = new POST) + @paramTag[RestParamTag](defaultTag = new JsonBodyParam) + def handle(@methodName name: String, @composite parameters: RestParameters, + @multi @tagged[JsonBodyParam] body: NamedParams[JsonValue]): Async[RestResponse] + + @multi + @tried + @tagged[BodyMethodTag](whenUntagged = new POST) + @paramTag[RestParamTag] + def handleSingle(@methodName name: String, @composite parameters: RestParameters, + @encoded @tagged[Body] body: HttpBody): Async[RestResponse] + + def asHandleRequest(metadata: RestMetadata[_]): HandleRequest = { + metadata.ensureUnambiguousPaths() + metadata.ensureUniqueParams(Nil) + RawRest.safeHandle { case RestRequest(method, parameters, body) => + metadata.resolvePath(method, parameters.path) match { + case Opt(ResolvedPath(prefixes, RestMethodCall(finalRpcName, finalPathParams, _), singleBody)) => + def resolveCall(rawRest: RawRest, prefixes: List[RestMethodCall]): Async[RestResponse] = prefixes match { + case RestMethodCall(rpcName, pathParams, _) :: tail => + rawRest.prefix(rpcName, parameters.copy(path = pathParams)) match { + case Success(nextRawRest) => resolveCall(nextRawRest, tail) + case Failure(e: HttpErrorException) => RawRest.successfulAsync(e.toResponse) + case Failure(cause) => RawRest.failingAsync(cause) + } + case Nil => + val finalParameters = parameters.copy(path = finalPathParams) + if (method == HttpMethod.GET) + rawRest.get(finalRpcName, finalParameters) + else if (singleBody) + rawRest.handleSingle(finalRpcName, finalParameters, body) + else + rawRest.handle(finalRpcName, finalParameters, HttpBody.parseJsonBody(body)) + } + resolveCall(this, prefixes) + case Opt.Empty => + val pathStr = parameters.path.iterator.map(_.value).mkString("/") + RawRest.successfulAsync(RestResponse(404, HttpBody.plain(s"path $pathStr not found"))) + } + } + } +} + +object RawRest extends RawRpcCompanion[RawRest] { + /** + * A callback that gets notified when value of type `T` gets computed or when computation of that value fails. + * Callbacks should never throw exceptions. Preferably, they should be simple notifiers that delegate the real + * work somewhere else, e.g. schedule some handling code on a separate executor + * (e.g. [[scala.concurrent.ExecutionException ExecutionContext]]). + */ + type Callback[T] = Try[T] => Unit + + /** + * The most low-level, raw type representing an asynchronous, possibly side-effecting operation that yields a + * value of type `T` as a result. + * `Async` is a consumer of a callback. When a callback is passed to `Async`, it should start the operation + * and ultimately notify the callback about the result. Each time the callback is passed, the + * entire operation should be repeated, involving all possible side effects. Operation should never be started + * without the callback being passed (i.e. there should be no observable side effects before a callback is passed). + * Implementation of `Async` should also be prepared to accept a callback before the previous one was notified + * about the result (i.e. it should support concurrent execution). + */ + type Async[T] = Callback[T] => Unit + + /** + * Raw type of an operation that executes a [[RestRequest]]. The operation should be run every time the + * resulting `Async` value is passed a callback. It should not be run before that. Each run may involve side + * effects, network communication, etc. Runs may be concurrent. + * Request handlers should never throw exceptions but rather convert them into failing implementation of + * `Async`. One way to do this is by wrapping the handler with [[safeHandle]]. + */ + type HandleRequest = RestRequest => Async[RestResponse] + + /** + * Ensures that all possible exceptions thrown by a request handler are not propagated but converted into + * an instance of `Async` that notifies its callbacks about the failure. + */ + def safeHandle(handleRequest: HandleRequest): HandleRequest = + request => safeAsync(handleRequest(request)) + + private def guardedAsync[T](async: Async[T]): Async[T] = callback => { + val called = new AtomicBoolean + val guardedCallback: Callback[T] = result => + if (!called.getAndSet(true)) { + callback(result) // may possibly throw but better let it fly rather than catch and ignore + } + try async(guardedCallback) catch { + case NonFatal(t) => + // if callback was already called then we can't do much with the failure, rethrow it + if (!called.getAndSet(true)) callback(Failure(t)) else throw t + } + } + + def safeAsync[T](async: => Async[T]): Async[T] = + try guardedAsync(async) catch { + case NonFatal(t) => failingAsync(t) + } + + def readyAsync[T](result: Try[T]): Async[T] = + callback => callback(result) + + def successfulAsync[T](value: T): Async[T] = + readyAsync(Success(value)) + + def failingAsync[T](cause: Throwable): Async[T] = + readyAsync(Failure(cause)) + + def fromHandleRequest[Real: AsRealRpc : RestMetadata](handleRequest: HandleRequest): Real = + RawRest.asReal(new DefaultRawRest(RestMetadata[Real], RestParameters.Empty, handleRequest)) + + def asHandleRequest[Real: AsRawRpc : RestMetadata](real: Real): HandleRequest = + RawRest.asRaw(real).asHandleRequest(RestMetadata[Real]) + + private final class DefaultRawRest(metadata: RestMetadata[_], prefixHeaders: RestParameters, handleRequest: HandleRequest) + extends RawRest { + + def prefix(name: String, parameters: RestParameters): Try[RawRest] = + metadata.prefixMethods.get(name).map { prefixMeta => + val newHeaders = prefixHeaders.append(prefixMeta, parameters) + Success(new DefaultRawRest(prefixMeta.result.value, newHeaders, handleRequest)) + } getOrElse Failure(new RestException(s"no such prefix method: $name")) + + def get(name: String, parameters: RestParameters): Async[RestResponse] = + handleSingle(name, parameters, HttpBody.Empty) + + def handle(name: String, parameters: RestParameters, body: NamedParams[JsonValue]): Async[RestResponse] = + handleSingle(name, parameters, HttpBody.createJsonBody(body)) + + def handleSingle(name: String, parameters: RestParameters, body: HttpBody): Async[RestResponse] = + metadata.httpMethods.get(name).map { methodMeta => + val newHeaders = prefixHeaders.append(methodMeta, parameters) + handleRequest(RestRequest(methodMeta.method, newHeaders, body)) + } getOrElse RawRest.failingAsync(new RestException(s"no such HTTP method: $name")) + } +} + +class RestException(msg: String, cause: Throwable = null) extends RpcException(msg, cause) diff --git a/commons-core/src/main/scala/com/avsystem/commons/rest/RestMetadata.scala b/commons-core/src/main/scala/com/avsystem/commons/rest/RestMetadata.scala new file mode 100644 index 000000000..76f33d6d6 --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rest/RestMetadata.scala @@ -0,0 +1,239 @@ +package com.avsystem.commons +package rest + +import com.avsystem.commons.rpc._ + +@methodTag[RestMethodTag] +case class RestMetadata[T]( + @multi @tagged[Prefix](whenUntagged = new Prefix) + @paramTag[RestParamTag](defaultTag = new Path) + prefixMethods: Map[String, PrefixMetadata[_]], + + @multi @tagged[GET] + @paramTag[RestParamTag](defaultTag = new Query) + httpGetMethods: Map[String, HttpMethodMetadata[_]], + + @multi @tagged[BodyMethodTag](whenUntagged = new POST) + @paramTag[RestParamTag](defaultTag = new JsonBodyParam) + httpBodyMethods: Map[String, HttpMethodMetadata[_]] +) { + val httpMethods: Map[String, HttpMethodMetadata[_]] = + httpGetMethods ++ httpBodyMethods + + def ensureUniqueParams(prefixes: List[(String, PrefixMetadata[_])]): Unit = { + def ensureUniqueParams(methodName: String, method: RestMethodMetadata[_]): Unit = { + for { + (prefixName, prefix) <- prefixes + headerParam <- method.parametersMetadata.headers.keys + if prefix.parametersMetadata.headers.contains(headerParam) + } throw new InvalidRestApiException( + s"Header parameter $headerParam of $methodName collides with header parameter of the same name in prefix $prefixName") + + for { + (prefixName, prefix) <- prefixes + queryParam <- method.parametersMetadata.query.keys + if prefix.parametersMetadata.query.contains(queryParam) + } throw new InvalidRestApiException( + s"Query parameter $queryParam of $methodName collides with query parameter of the same name in prefix $prefixName") + } + + prefixMethods.foreach { + case (name, prefix) => + ensureUniqueParams(name, prefix) + prefix.result.value.ensureUniqueParams((name, prefix) :: prefixes) + } + (httpGetMethods ++ httpBodyMethods).foreach { + case (name, method) => ensureUniqueParams(name, method) + } + } + + def ensureUnambiguousPaths(): Unit = { + val trie = new RestMetadata.Trie + trie.fillWith(this) + trie.mergeWildcardToNamed() + val ambiguities = new MListBuffer[(String, List[String])] + trie.collectAmbiguousCalls(ambiguities) + if (ambiguities.nonEmpty) { + val problems = ambiguities.map { case (path, chains) => + s"$path may result from multiple calls:\n ${chains.mkString("\n ")}" + } + throw new InvalidRestApiException(s"REST API has ambiguous paths:\n${problems.mkString("\n")}") + } + } + + def resolvePath(method: HttpMethod, path: List[PathValue]): Opt[ResolvedPath] = { + def resolve(method: HttpMethod, path: List[PathValue]): Iterator[ResolvedPath] = { + val asFinalCall = for { + (rpcName, m) <- httpMethods.iterator if m.method == method + (pathParams, Nil) <- m.extractPathParams(path) + } yield ResolvedPath(Nil, RestMethodCall(rpcName, pathParams, m), m.singleBody) + + val usingPrefix = for { + (rpcName, prefix) <- prefixMethods.iterator + (pathParams, pathTail) <- prefix.extractPathParams(path).iterator + suffixPath <- prefix.result.value.resolvePath(method, pathTail) + } yield suffixPath.prepend(rpcName, pathParams, prefix) + + asFinalCall ++ usingPrefix + } + resolve(method, path).toList match { + case Nil => Opt.Empty + case single :: Nil => Opt(single) + case multiple => + val pathStr = path.iterator.map(_.value).mkString("/") + val callsRepr = multiple.iterator.map(p => s" ${p.rpcChainRepr}").mkString("\n", "\n", "") + throw new RestException(s"path $pathStr is ambiguous, it could map to following calls:$callsRepr") + } + } +} +object RestMetadata extends RpcMetadataCompanion[RestMetadata] { + private class Trie { + val rpcChains: Map[HttpMethod, MBuffer[String]] = + HttpMethod.values.mkMap(identity, _ => new MArrayBuffer[String]) + + val byName: MMap[String, Trie] = new MHashMap + var wildcard: Opt[Trie] = Opt.Empty + + def forPattern(pattern: List[PathPatternElement]): Trie = pattern match { + case Nil => this + case PathName(PathValue(pathName)) :: tail => + byName.getOrElseUpdate(pathName, new Trie).forPattern(tail) + case PathParam(_) :: tail => + wildcard.getOrElse(new Trie().setup(t => wildcard = Opt(t))).forPattern(tail) + } + + def fillWith(metadata: RestMetadata[_], prefixStack: List[(String, PrefixMetadata[_])] = Nil): Unit = { + def prefixChain: String = + prefixStack.reverseIterator.map({ case (k, _) => k }).mkStringOrEmpty("", "->", "->") + + metadata.prefixMethods.foreach { case entry@(rpcName, pm) => + if (prefixStack.contains(entry)) { + throw new InvalidRestApiException( + s"call chain $prefixChain$rpcName is recursive, recursively defined server APIs are forbidden") + } + forPattern(pm.pathPattern).fillWith(pm.result.value, entry :: prefixStack) + } + metadata.httpMethods.foreach { case (rpcName, hm) => + forPattern(hm.pathPattern).rpcChains(hm.method) += s"$prefixChain${rpcName.stripPrefix(s"${hm.method}_")}" + } + } + + private def merge(other: Trie): Unit = { + HttpMethod.values.foreach { meth => + rpcChains(meth) ++= other.rpcChains(meth) + } + for (w <- wildcard; ow <- other.wildcard) w.merge(ow) + wildcard = wildcard orElse other.wildcard + other.byName.foreach { case (name, trie) => + byName.getOrElseUpdate(name, new Trie).merge(trie) + } + } + + def mergeWildcardToNamed(): Unit = wildcard.foreach { wc => + wc.mergeWildcardToNamed() + byName.values.foreach { trie => + trie.merge(wc) + trie.mergeWildcardToNamed() + } + } + + def collectAmbiguousCalls(ambiguities: MBuffer[(String, List[String])], pathPrefix: List[String] = Nil): Unit = { + rpcChains.foreach { case (method, chains) => + if (chains.size > 1) { + val path = pathPrefix.reverse.mkString(s"$method /", "/", "") + ambiguities += ((path, chains.toList)) + } + } + wildcard.foreach(_.collectAmbiguousCalls(ambiguities, "*" :: pathPrefix)) + byName.foreach { case (name, trie) => + trie.collectAmbiguousCalls(ambiguities, name :: pathPrefix) + } + } + } +} + +sealed trait PathPatternElement +case class PathName(value: PathValue) extends PathPatternElement +case class PathParam(parameter: PathParamMetadata[_]) extends PathPatternElement + +sealed abstract class RestMethodMetadata[T] extends TypedMetadata[T] { + def methodPath: List[PathValue] + def parametersMetadata: RestParametersMetadata + + val pathPattern: List[PathPatternElement] = + methodPath.map(PathName) ++ parametersMetadata.path.flatMap(pp => PathParam(pp) :: pp.pathSuffix.map(PathName)) + + def applyPathParams(params: List[PathValue]): List[PathValue] = { + def loop(params: List[PathValue], pattern: List[PathPatternElement]): List[PathValue] = + (params, pattern) match { + case (Nil, Nil) => Nil + case (_, PathName(patternHead) :: patternTail) => patternHead :: loop(params, patternTail) + case (param :: paramsTail, PathParam(_) :: patternTail) => param :: loop(paramsTail, patternTail) + case _ => throw new IllegalArgumentException( + s"got ${params.size} path params, expected ${parametersMetadata.path.size}") + } + loop(params, pathPattern) + } + + def extractPathParams(path: List[PathValue]): Opt[(List[PathValue], List[PathValue])] = { + def loop(path: List[PathValue], pattern: List[PathPatternElement]): Opt[(List[PathValue], List[PathValue])] = + (path, pattern) match { + case (pathTail, Nil) => Opt((Nil, pathTail)) + case (param :: pathTail, PathParam(_) :: patternTail) => + loop(pathTail, patternTail).map { case (params, tail) => (param :: params, tail) } + case (pathHead :: pathTail, PathName(patternHead) :: patternTail) if pathHead == patternHead => + loop(pathTail, patternTail) + case _ => Opt.Empty + } + loop(path, pathPattern) + } +} + +case class PrefixMetadata[T]( + @reifyAnnot methodTag: Prefix, + @composite parametersMetadata: RestParametersMetadata, + @checked @infer result: RestMetadata.Lazy[T] +) extends RestMethodMetadata[T] { + def methodPath: List[PathValue] = PathValue.split(methodTag.path) +} + +case class HttpMethodMetadata[T]( + @reifyAnnot methodTag: HttpMethodTag, + @composite parametersMetadata: RestParametersMetadata, + @multi @tagged[BodyTag] bodyParams: Map[String, BodyParamMetadata[_]], + @checked @infer responseType: HttpResponseType[T] +) extends RestMethodMetadata[T] { + val method: HttpMethod = methodTag.method + val singleBody: Boolean = bodyParams.values.exists(_.singleBody) + def methodPath: List[PathValue] = PathValue.split(methodTag.path) +} + +/** + * Currently just a marker typeclass used by [[RestMetadata]] materialization to distinguish between + * prefix methods and HTTP methods. In the future this typeclass may contain some additional information, e.g. + * type metadata for generating swagger definitions. + */ +trait HttpResponseType[T] +object HttpResponseType { + implicit def forFuture[T]: HttpResponseType[Future[T]] = + new HttpResponseType[Future[T]] {} +} + +case class RestParametersMetadata( + @multi @tagged[Path] path: List[PathParamMetadata[_]], + @multi @tagged[Header] headers: Map[String, HeaderParamMetadata[_]], + @multi @tagged[Query] query: Map[String, QueryParamMetadata[_]] +) + +case class PathParamMetadata[T]( + @reifyName(rpcName = true) rpcName: String, + @reifyAnnot pathAnnot: Path +) extends TypedMetadata[T] { + val pathSuffix: List[PathValue] = PathValue.split(pathAnnot.pathSuffix) +} + +case class HeaderParamMetadata[T]() extends TypedMetadata[T] +case class QueryParamMetadata[T]() extends TypedMetadata[T] +case class BodyParamMetadata[T](@isAnnotated[Body] singleBody: Boolean) extends TypedMetadata[T] + +class InvalidRestApiException(msg: String) extends RestException(msg) diff --git a/commons-core/src/main/scala/com/avsystem/commons/rest/annotations.scala b/commons-core/src/main/scala/com/avsystem/commons/rest/annotations.scala new file mode 100644 index 000000000..7db962fef --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rest/annotations.scala @@ -0,0 +1,147 @@ +package com.avsystem.commons +package rest + +import com.avsystem.commons.annotation.{AnnotationAggregate, defaultsToName} +import com.avsystem.commons.rpc._ + +/** + * Base trait for tag annotations that determine how a REST method is translated into actual HTTP request. + * A REST method may be annotated with one of HTTP method tags ([[GET]], [[PUT]], [[POST]], [[PATCH]], [[DELETE]]) + * which means that this method represents actual HTTP call and is expected to return a `Future[Result]` where + * `Result` is encodable as [[RestResponse]]. + * + * If a REST method is not annotated with any of HTTP method tags, [[Prefix]] is assumed by default which means + * that this method only contributes to URL path, HTTP headers and query parameters but does not yet represent an + * actual HTTP request. Instead, it is expected to return some other REST API trait. + */ +sealed trait RestMethodTag extends RpcTag { + /** + * HTTP URL path segment associated with REST method annotated with this tag. This path may be multipart + * (i.e. contain slashes). It may also be empty which means that this particular REST method does not contribute + * anything to URL path. Any special characters will be URL-encoded when creating HTTP request. + * If path is not specified explicitly, method name is used (the actual method name, not `rpcName`). + * + * @example + * {{{ + * trait SomeRestApi { + * @GET("users/find") + * def findUser(userId: String): Future[User] + * } + * object SomeRestApi extends RestApiCompanion[SomeRestApi] + * }}} + */ + @defaultsToName def path: String +} + +sealed abstract class HttpMethodTag(val method: HttpMethod) extends RestMethodTag with AnnotationAggregate + +/** + * Base trait for annotations representing HTTP methods which may define a HTTP body. This includes + * [[PUT]], [[POST]], [[PATCH]] and [[DELETE]]. Parameters of REST methods annotated with one of these tags are + * by default serialized into JSON (through encoding to [[JsonValue]]) and combined into JSON object that is sent + * as HTTP body. + * + * Parameters may also contribute to URL path, HTTP headers and query parameters if annotated as + * [[Path]], [[Header]] or [[Query]]. + * + * REST method may also take a single parameter representing the entire HTTP body. Such parameter must be annotated + * as [[Body]] and must be the only body parameter of that method. Value of this parameter will be encoded as + * [[HttpBody]] which doesn't necessarily have to be JSON (it may define its own MIME type). + * + * @example + * {{{ + * trait SomeRestApi { + * @POST("users/create") def createUser(@Body user: User): Future[Unit] + * @PATCH("users/update") def updateUser(id: String, name: String): Future[User] + * } + * object SomeRestApi extends RestApiCompanion[SomeRestApi] + * }}} + */ +sealed abstract class BodyMethodTag(method: HttpMethod) extends HttpMethodTag(method) + +/** + * REST method annotated as `@GET` will translate to HTTP GET request. By default, parameters of such method + * are translated into URL query parameters (encoded as [[QueryValue]]). Alternatively, each parameter + * may be annotated as [[Path]] or [[Header]] which means that it will be translated into HTTP header value + * + * @param path see [[RestMethodTag.path]] + */ +class GET(val path: String = null) extends HttpMethodTag(HttpMethod.GET) { + @rpcNamePrefix("GET_") type Implied +} + +/** See [[BodyMethodTag]] */ +class POST(val path: String = null) extends BodyMethodTag(HttpMethod.POST) { + @rpcNamePrefix("POST_") type Implied +} +/** See [[BodyMethodTag]] */ +class PATCH(val path: String = null) extends BodyMethodTag(HttpMethod.PATCH) { + @rpcNamePrefix("PATCH_") type Implied +} +/** See [[BodyMethodTag]] */ +class PUT(val path: String = null) extends BodyMethodTag(HttpMethod.PUT) { + @rpcNamePrefix("PUT_") type Implied +} +/** See [[BodyMethodTag]] */ +class DELETE(val path: String = null) extends BodyMethodTag(HttpMethod.DELETE) { + @rpcNamePrefix("DELETE_") type Implied +} + +/** + * REST methods annotated as [[Prefix]] are expected to return another REST API trait as their result. + * They do not yet represent an actual HTTP request but contribute to URL path, HTTP headers and query parameters. + * + * By default, parameters of a prefix method are interpreted as URL path fragments. Their values are encoded as + * [[PathValue]] and appended to URL path. Alternatively, each parameter may also be explicitly annotated as + * [[Header]] or [[Query]]. + * + * NOTE: REST method is interpreted as prefix method by default which means that there is no need to apply [[Prefix]] + * annotation explicitly unless you want to specify a custom path. + * + * @param path see [[RestMethodTag.path]] + */ +class Prefix(val path: String = null) extends RestMethodTag + +sealed trait RestParamTag extends RpcTag + +/** + * REST method parameters annotated as [[Path]] will be encoded as [[PathValue]] and appended to URL path, in the + * declaration order. Parameters of [[Prefix]] REST methods are interpreted as [[Path]] parameters by default. + */ +class Path(val pathSuffix: String = "") extends RestParamTag + +/** + * REST method parameters annotated as [[Header]] will be encoded as [[HeaderValue]] and added to HTTP headers. + * Header name must be explicitly given as argument of this annotation. + */ +class Header(override val name: String) + extends rpcName(name) with RestParamTag + +/** + * REST method parameters annotated as [[Query]] will be encoded as [[QueryValue]] and added to URL query + * parameters. Parameters of [[GET]] REST methods are interpreted as [[Query]] parameters by default. + */ +class Query(@defaultsToName override val name: String = null) + extends rpcName(name) with RestParamTag + +sealed trait BodyTag extends RestParamTag + +/** + * REST method parameters annotated as [[JsonBodyParam]] will be encoded as [[JsonValue]] and combined into + * a JSON object that will be sent as HTTP body. Body parameters are allowed only in REST methods annotated as + * [[POST]], [[PATCH]], [[PUT]] or [[DELETE]]. Actually, parameters of these methods are interpreted as + * [[JsonBodyParam]] by default which means that this annotation rarely needs to be applied explicitly. + */ +class JsonBodyParam(@defaultsToName override val name: String = null) + extends rpcName(name) with BodyTag + +/** + * REST methods that can send HTTP body ([[POST]], [[PATCH]], [[PUT]] and [[DELETE]]) may take a single + * parameter annotated as [[Body]] which will be encoded as [[HttpBody]] and sent as the body of HTTP request. + * Such a method may not define any other body parameters (although it may take additional [[Path]], [[Header]] + * or [[Query]] parameters). + * + * The single body parameter may have a completely custom encoding to [[HttpBody]] which may define its own MIME type + * and doesn't necessarily have to be JSON. + */ +final class Body extends BodyTag diff --git a/commons-core/src/main/scala/com/avsystem/commons/rest/data.scala b/commons-core/src/main/scala/com/avsystem/commons/rest/data.scala new file mode 100644 index 000000000..c092eaeb7 --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rest/data.scala @@ -0,0 +1,196 @@ +package com.avsystem.commons +package rest + +import com.avsystem.commons.misc.{AbstractValueEnum, AbstractValueEnumCompanion, EnumCtx} +import com.avsystem.commons.rpc._ +import com.avsystem.commons.serialization.GenCodec.ReadFailure +import com.avsystem.commons.serialization.json.{JsonReader, JsonStringInput, JsonStringOutput} + +import scala.util.control.NoStackTrace + +sealed trait RestValue extends Any { + def value: String +} + +/** + * Value used as encoding of [[Path]] parameters. + */ +case class PathValue(value: String) extends AnyVal with RestValue +object PathValue { + def split(path: String): List[PathValue] = + path.split("/").iterator.filter(_.nonEmpty).map(PathValue(_)).toList +} + +/** + * Value used as encoding of [[Header]] parameters. + */ +case class HeaderValue(value: String) extends AnyVal with RestValue + +/** + * Value used as encoding of [[Query]] parameters. + */ +case class QueryValue(value: String) extends AnyVal with RestValue + +/** + * Value used as encoding of [[JsonBodyParam]] parameters. + */ +case class JsonValue(value: String) extends AnyVal with RestValue + +/** + * Value used to represent HTTP body. Also used as direct encoding of [[Body]] parameters. Types that have + * encoding to [[JsonValue]] automatically have encoding to [[HttpBody]] which uses application/json MIME type. + * There is also a specialized encoding provided for `Unit` which returns empty HTTP body when writing and ignores + * the body when reading. + */ +sealed trait HttpBody { + def contentOpt: Opt[String] = this match { + case HttpBody(content, _) => Opt(content) + case HttpBody.Empty => Opt.Empty + } + + def forNonEmpty(consumer: (String, String) => Unit): Unit = this match { + case HttpBody(content, mimeType) => consumer(content, mimeType) + case HttpBody.Empty => + } + + def readContent(): String = this match { + case HttpBody(content, _) => content + case HttpBody.Empty => throw new ReadFailure("Expected non-empty body") + } + + def readJson(): JsonValue = this match { + case HttpBody(content, HttpBody.JsonType) => JsonValue(content) + case HttpBody(_, mimeType) => + throw new ReadFailure(s"Expected body with application/json type, got $mimeType") + case HttpBody.Empty => + throw new ReadFailure("Expected body with application/json type, got empty body") + } +} +object HttpBody { + object Empty extends HttpBody + final case class NonEmpty(content: String, mimeType: String) extends HttpBody + + def empty: HttpBody = Empty + + def apply(content: String, mimeType: String): HttpBody = + NonEmpty(content, mimeType) + + def unapply(body: HttpBody): Opt[(String, String)] = body match { + case Empty => Opt.Empty + case NonEmpty(content, mimeType) => Opt((content, mimeType)) + } + + final val PlainType = "text/plain" + final val JsonType = "application/json" + + def plain(value: String): HttpBody = HttpBody(value, PlainType) + def json(json: JsonValue): HttpBody = HttpBody(json.value, JsonType) + + def createJsonBody(fields: NamedParams[JsonValue]): HttpBody = + if (fields.isEmpty) HttpBody.Empty else { + val sb = new JStringBuilder + val oo = new JsonStringOutput(sb).writeObject() + fields.foreach { + case (key, JsonValue(json)) => + oo.writeField(key).writeRawJson(json) + } + oo.finish() + HttpBody.json(JsonValue(sb.toString)) + } + + def parseJsonBody(body: HttpBody): NamedParams[JsonValue] = body match { + case HttpBody.Empty => NamedParams.empty + case _ => + val oi = new JsonStringInput(new JsonReader(body.readJson().value)).readObject() + val builder = NamedParams.newBuilder[JsonValue] + while (oi.hasNext) { + val fi = oi.nextField() + builder += ((fi.fieldName, JsonValue(fi.readRawJson()))) + } + builder.result() + } + + implicit val emptyBodyForUnit: AsRawReal[HttpBody, Unit] = + AsRawReal.create(_ => HttpBody.Empty, _ => ()) + implicit def httpBodyJsonAsRaw[T](implicit jsonAsRaw: AsRaw[JsonValue, T]): AsRaw[HttpBody, T] = + AsRaw.create(v => HttpBody.json(jsonAsRaw.asRaw(v))) + implicit def httpBodyJsonAsReal[T](implicit jsonAsReal: AsReal[JsonValue, T]): AsReal[HttpBody, T] = + AsReal.create(v => jsonAsReal.asReal(v.readJson())) +} + +/** + * Enum representing HTTP methods. + */ +final class HttpMethod(implicit enumCtx: EnumCtx) extends AbstractValueEnum +object HttpMethod extends AbstractValueEnumCompanion[HttpMethod] { + final val GET, PUT, POST, PATCH, DELETE: Value = new HttpMethod +} + +case class RestParameters( + @multi @tagged[Path] path: List[PathValue], + @multi @tagged[Header] headers: NamedParams[HeaderValue], + @multi @tagged[Query] query: NamedParams[QueryValue] +) { + def append(method: RestMethodMetadata[_], otherParameters: RestParameters): RestParameters = + RestParameters( + path ::: method.applyPathParams(otherParameters.path), + headers ++ otherParameters.headers, + query ++ otherParameters.query + ) +} +object RestParameters { + final val Empty = RestParameters(Nil, NamedParams.empty, NamedParams.empty) +} + +case class HttpErrorException(code: Int, payload: OptArg[String] = OptArg.Empty) + extends RuntimeException(s"HTTP ERROR $code${payload.fold("")(p => s": $p")}") with NoStackTrace { + def toResponse: RestResponse = + RestResponse(code, payload.fold(HttpBody.empty)(HttpBody.plain)) +} + +case class RestRequest(method: HttpMethod, parameters: RestParameters, body: HttpBody) +case class RestResponse(code: Int, body: HttpBody) { + def toHttpError: HttpErrorException = + HttpErrorException(code, body.contentOpt.toOptArg) + def ensure200OK: RestResponse = + if (code == 200) this else throw toHttpError +} + +object RestResponse { + class LazyOps(private val resp: () => RestResponse) extends AnyVal { + def recoverHttpError: RestResponse = try resp() catch { + case e: HttpErrorException => e.toResponse + } + } + implicit def lazyOps(resp: => RestResponse): LazyOps = new LazyOps(() => resp) + + implicit class TryOps(private val respTry: Try[RestResponse]) extends AnyVal { + def recoverHttpError: Try[RestResponse] = respTry match { + case Failure(e: HttpErrorException) => Success(e.toResponse) + case _ => respTry + } + } + + implicit def bodyBasedFromResponse[T](implicit bodyAsReal: AsReal[HttpBody, T]): AsReal[RestResponse, T] = + AsReal.create(resp => bodyAsReal.asReal(resp.ensure200OK.body)) + + implicit def bodyBasedToResponse[T](implicit bodyAsRaw: AsRaw[HttpBody, T]): AsRaw[RestResponse, T] = + AsRaw.create(value => RestResponse(200, bodyAsRaw.asRaw(value)).recoverHttpError) + + implicit def futureToAsyncResp[T]( + implicit respAsRaw: AsRaw[RestResponse, T] + ): AsRaw[RawRest.Async[RestResponse], Try[Future[T]]] = + AsRaw.create { triedFuture => + val future = triedFuture.fold(Future.failed, identity) + callback => future.onCompleteNow(t => callback(t.map(respAsRaw.asRaw).recoverHttpError)) + } + + implicit def futureFromAsyncResp[T]( + implicit respAsReal: AsReal[RestResponse, T] + ): AsReal[RawRest.Async[RestResponse], Try[Future[T]]] = + AsReal.create { async => + val promise = Promise[T] + async(t => promise.complete(t.map(respAsReal.asReal))) + Success(promise.future) + } +} diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/AsRawReal.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/AsRawReal.scala index 6ba1a2d41..9181394db 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/rpc/AsRawReal.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/AsRawReal.scala @@ -8,11 +8,16 @@ trait AsRaw[Raw, Real] { def asRaw(real: Real): Raw } object AsRaw { + def apply[Raw, Real](implicit asRaw: AsRaw[Raw, Real]): AsRaw[Raw, Real] = asRaw + def create[Raw, Real](asRawFun: Real => Raw): AsRaw[Raw, Real] = new AsRaw[Raw, Real] { def asRaw(real: Real): Raw = asRawFun(real) } - def identity[A]: AsRaw[A, A] = AsRawReal.identity[A] + implicit def identity[A]: AsRaw[A, A] = AsRawReal.identity[A] + implicit def forTry[Raw, Real](implicit asRaw: AsRaw[Raw, Real]): AsRaw[Try[Raw], Try[Real]] = + AsRaw.create(_.map(asRaw.asRaw)) + implicit def fromFallback[Raw, Real](implicit fallback: Fallback[AsRaw[Raw, Real]]): AsRaw[Raw, Real] = fallback.value def materializeForRpc[Raw, Real]: AsRaw[Raw, Real] = macro macros.rpc.RpcMacros.rpcAsRaw[Raw, Real] } @@ -21,17 +26,24 @@ trait AsReal[Raw, Real] { def asReal(raw: Raw): Real } object AsReal { + def apply[Raw, Real](implicit asReal: AsReal[Raw, Real]): AsReal[Raw, Real] = asReal + def create[Raw, Real](asRealFun: Raw => Real): AsReal[Raw, Real] = new AsReal[Raw, Real] { def asReal(raw: Raw): Real = asRealFun(raw) } - def identity[A]: AsReal[A, A] = AsRawReal.identity[A] + implicit def identity[A]: AsReal[A, A] = AsRawReal.identity[A] + implicit def forTry[Raw, Real](implicit asReal: AsReal[Raw, Real]): AsReal[Try[Raw], Try[Real]] = + AsReal.create(_.map(asReal.asReal)) + implicit def fromFallback[Raw, Real](implicit fallback: Fallback[AsReal[Raw, Real]]): AsReal[Raw, Real] = fallback.value def materializeForRpc[Raw, Real]: AsReal[Raw, Real] = macro macros.rpc.RpcMacros.rpcAsReal[Raw, Real] } @implicitNotFound("don't know how to encode and decode between ${Real} and ${Raw}, appropriate AsRawReal instance not found") trait AsRawReal[Raw, Real] extends AsReal[Raw, Real] with AsRaw[Raw, Real] object AsRawReal { + def apply[Raw, Real](implicit asRawReal: AsRawReal[Raw, Real]): AsRawReal[Raw, Real] = asRawReal + def create[Raw, Real](asRawFun: Real => Raw, asRealFun: Raw => Real): AsRawReal[Raw, Real] = new AsRawReal[Raw, Real] { def asRaw(real: Real): Raw = asRawFun(real) @@ -43,12 +55,14 @@ object AsRawReal { def asRaw(real: Any): Any = real } - def identity[A]: AsRawReal[A, A] = + implicit def identity[A]: AsRawReal[A, A] = reusableIdentity.asInstanceOf[AsRawReal[A, A]] + implicit def fromFallback[Raw, Real](implicit fallback: Fallback[AsRawReal[Raw, Real]]): AsRawReal[Raw, Real] = fallback.value + def materializeForRpc[Raw, Real]: AsRawReal[Raw, Real] = macro macros.rpc.RpcMacros.rpcAsRawReal[Raw, Real] } object RpcMetadata { - def materializeForRpc[M[_], Real]: M[Real] = macro macros.rpc.RpcMacros.rpcMetadata[M[Real], Real] + def materializeForRpc[M[_], Real]: M[Real] = macro macros.rpc.RpcMacros.rpcMetadata[Real] } diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/NamedParams.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/NamedParams.scala new file mode 100644 index 000000000..61d491d9f --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/NamedParams.scala @@ -0,0 +1,55 @@ +package com.avsystem.commons +package rpc + +import com.avsystem.commons.rpc.NamedParams.ConcatIterable +import com.avsystem.commons.serialization.GenCodec + +import scala.collection.generic.CanBuildFrom +import scala.collection.mutable + +/** + * Simple immutable structure to collect named RPC parameters while retaining their order and + * providing fast, hashed lookup by parameter name when necessary. + * Intended to be used for [[multi]] raw parameters. + */ +final class NamedParams[+V](private val wrapped: IIterable[(String, V)]) + extends IIterable[(String, V)] with PartialFunction[String, V] { + + private[this] lazy val hashMap = new MLinkedHashMap[String, V].setup(_ ++= wrapped) + + def iterator: Iterator[(String, V)] = + hashMap.iterator + def isDefinedAt(key: String): Boolean = + hashMap.isDefinedAt(key) + override def applyOrElse[A1 <: String, B1 >: V](key: A1, default: A1 => B1): B1 = + hashMap.applyOrElse(key, default) + override def apply(key: String): V = + hashMap.apply(key) + + def ++[V0 >: V](other: NamedParams[V0]): NamedParams[V0] = + if (wrapped.isEmpty) other + else if (other.wrapped.isEmpty) this + else new NamedParams(ConcatIterable(wrapped, other.wrapped)) +} +object NamedParams { + def empty[V]: NamedParams[V] = new NamedParams(Nil) + def newBuilder[V]: mutable.Builder[(String, V), NamedParams[V]] = + new MListBuffer[(String, V)].mapResult(new NamedParams(_)) + + private case class ConcatIterable[+V](first: IIterable[V], second: IIterable[V]) extends IIterable[V] { + def iterator: Iterator[V] = first.iterator ++ second.iterator + } + + private val reusableCBF = new CanBuildFrom[Nothing, (String, Any), NamedParams[Any]] { + def apply(from: Nothing): mutable.Builder[(String, Any), NamedParams[Any]] = newBuilder[Any] + def apply(): mutable.Builder[(String, Any), NamedParams[Any]] = newBuilder[Any] + } + + implicit def canBuildFrom[V]: CanBuildFrom[Nothing, (String, V), NamedParams[V]] = + reusableCBF.asInstanceOf[CanBuildFrom[Nothing, (String, V), NamedParams[V]]] + + implicit def genCodec[V: GenCodec]: GenCodec[NamedParams[V]] = GenCodec.createNullableObject( + oi => new NamedParams(oi.iterator(GenCodec.read[V]).toList), + (oo, np) => np.foreach({ case (k, v) => GenCodec.write[V](oo.writeField(k), v) }) + ) +} diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/OptionLike.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/OptionLike.scala index f87156ec2..f37a39ccf 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/rpc/OptionLike.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/OptionLike.scala @@ -12,6 +12,8 @@ trait BaseOptionLike[O, A] extends OptionLike[O] { type Value = A } object OptionLike { + type Aux[O, V] = OptionLike[O] {type Value = V} + implicit def optionOptionLike[A]: BaseOptionLike[Option[A], A] = new BaseOptionLike[Option[A], A] { def none: Option[A] = None def some(value: A): Option[A] = Some(value) diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/RPCFramework.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/RPCFramework.scala index 2f8692c06..5d34d2100 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/rpc/RPCFramework.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/RPCFramework.scala @@ -1,6 +1,8 @@ package com.avsystem.commons package rpc +import com.avsystem.commons.serialization.GenCodec + import scala.language.higherKinds trait RPCFramework { @@ -8,6 +10,11 @@ trait RPCFramework { type Reader[T] type Writer[T] + case class RawInvocation(@methodName rpcName: String, @multi args: List[RawValue]) + object RawInvocation { + implicit def codec(implicit rawValueCodec: GenCodec[RawValue]): GenCodec[RawInvocation] = GenCodec.materialize + } + type RawRPC val RawRPC: BaseRawRpcCompanion @@ -61,7 +68,8 @@ trait RPCFramework { trait Signature { @reifyName def name: String @multi def paramMetadata: List[ParamMetadata[_]] - @reifyAnnot @multi def annotations: List[MetadataAnnotation] + @reifyAnnot + @multi def annotations: List[MetadataAnnotation] } case class ParamMetadata[T]( diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/RawRpcCompanion.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/RawRpcCompanion.scala index 36ebcf8a0..9549f336b 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/rpc/RawRpcCompanion.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/RawRpcCompanion.scala @@ -11,6 +11,9 @@ trait RawRpcCompanion[Raw] extends RpcImplicitsProvider { type AsRealRpc[Real] = AsReal[Raw, Real] type AsRawRealRpc[Real] = AsRawReal[Raw, Real] + def asReal[Real](raw: Raw)(implicit asRealRpc: AsRealRpc[Real]): Real = asRealRpc.asReal(raw) + def asRaw[Real](real: Real)(implicit asRawRpc: AsRawRpc[Real]): Raw = asRawRpc.asRaw(real) + def materializeAsRaw[Real]: AsRawRpc[Real] = macro RpcMacros.rpcAsRaw[Raw, Real] def materializeAsReal[Real]: AsRealRpc[Real] = macro RpcMacros.rpcAsReal[Raw, Real] def materializeAsRawReal[Real]: AsRawRealRpc[Real] = macro RpcMacros.rpcAsRawReal[Raw, Real] diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcMacroInstances.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcMacroInstances.scala new file mode 100644 index 000000000..e0ed1cf41 --- /dev/null +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcMacroInstances.scala @@ -0,0 +1,85 @@ +package com.avsystem.commons +package rpc + +/** + * Intermediate factory that creates an `InstancesTrait` for given `Real` RPC trait, based on provided `Implicits`. + * Normally, this factory is used as implicit constructor parameter of base classes for companion objects + * of RPC traits (e.g. [[com.avsystem.commons.rest.DefaultRestApiCompanion DefaultRestApiCompanion]]). + * This all serves to reduce boilerplate associated with RPC trait companion declarations and makes RPC trait + * definitions as concise as possible. It also lets the programmer easily inject additional implicits into + * macro-materialization of RPC-related typeclasses (`AsReal`, `AsRaw`, metadata, etc.). + * + * An `InstancesTrait` is a trait that aggregates multiple RPC related typeclass instances for given `Real` RPC trait. + * There is no fixed interface for `InstancesTrait`, its members are inspected by `materialize` macro and implemented + * automatically. `InstancesTrait` must contain only parameterless abstract methods that return either + * `AsRaw[Raw,Real]`, `AsReal[Raw,Real]`, `AsRawReal[Raw,Real]` or some RPC metadata class for `Real`. + * The `Raw` type is arbitrary and may be different for every method. However, it must be a concrete raw RPC trait + * so that it's further understood by the macro engine. All methods of the `InstancesTrait` are macro-implemented + * using `AsRaw/AsReal/AsRawReal/RpcMetadata.materializeForRpc` macros. + * + * Example of `InstancesTrait`: [[com.avsystem.commons.rest.ClientInstances ClientInstances]] + * + * The `Implicits` type is typically a trait with a collection of implicit definitions whose companion object + * implements that trait, e.g. [[com.avsystem.commons.rest.DefaultRestImplicits DefaultRestImplicits]]. + * When the macro implements `apply` method of `RpcMacroInstances` contents of `Implicits` are imported into the + * body of `apply` and visible further by macros that materialize `InstancesTrait`. + */ +trait RpcMacroInstances[Implicits, InstancesTrait[_], Real] { + def apply(implicits: Implicits): InstancesTrait[Real] +} +object RpcMacroInstances { + /** + * Materializes an instance of `RpcMacroInstances[Implicits, InstancesTrait, Real]`. This macro should not be + * invoked directly, it should only be used to materialize implicit parameters of RPC companion base classes, + * e.g. [[com.avsystem.commons.rest.DefaultRestApiCompanion DefaultRestApiCompanion]]. + * + * @example + * {{{ + * trait SomeRawRpc { ... } + * class SomeMetadata[Real](...) + * + * trait SomeInstances[Real} { + * def asReal: AsReal[SomeRawRpc, Real] + * def metadata: SomeMetadata[Real] + * } + * + * trait SomeImplicits { ... } + * object SomeImplicits extends SomeImplicits + * + * trait SomeRealRpc { ... } + * }}} + * + * `RpcMacroInstances.materialize[SomeImplicits, SomeInstances, SomeRealRpc]` would generate: + * + * {{{ + * new RpcMacroInstances[SomeImplicits, SomeInstances, SomeRealRpc] { + * def apply(implicits: SomeImplicits): SomeInstances[SomeRealRpc] = { + * import implicits._ + * new SomeInstances[SomeRealRpc] { + * def asReal: AsReal[SomeRawRpc, SomeRealRpc] = AsReal.materializeForRpc + * def metadata: SomeMetadata[Real] = RpcMetadata.materializeForRpc + * } + * } + * } + * }}} + */ + implicit def materialize[Implicits, InstancesTrait[_], Real]: RpcMacroInstances[Implicits, InstancesTrait, Real] = + macro macros.rpc.RpcMacros.macroInstances +} + +/** + * Wrap your implicit instance of [[AsReal]], [[AsRaw]], [[AsRawReal]] or RPC metadata (with companion that extends + * [[RpcMetadataCompanion]] into `Fallback` in order to lower its implicit priority. + * Useful when some implicit must be imported but we don't want it to get higher priority that imports normally + * have over implicit scope (e.g. implicits from companion objects). + * + * NOTE: `Fallback` does not work for *all* typeclasses, only RPC-related ones (`AsReal`, `AsRaw`, etc). + * You can make it work with your own typeclass, but you must define appropriate forwarder in its companion, e.g. + * {{{ + * trait FallbackAwareTC[T] { ... } + * object FallbackAwareTC { + * implicit def fromFallback[T](implicit f: Fallback[FallbackAwareTC[T]]): FallbackAwareTC[T] = f.value + * } + * }}} + */ +case class Fallback[+T](value: T) extends AnyVal diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcMetadataCompanion.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcMetadataCompanion.scala index 2aa9beb4a..0af6c493a 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcMetadataCompanion.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcMetadataCompanion.scala @@ -4,7 +4,11 @@ package rpc import com.avsystem.commons.macros.rpc.RpcMacros trait RpcMetadataCompanion[M[_]] extends RpcImplicitsProvider { - def materializeForRpc[Real]: M[Real] = macro RpcMacros.rpcMetadata[M[Real], Real] + final def apply[Real](implicit metadata: M[Real]): M[Real] = metadata + + def materializeForRpc[Real]: M[Real] = macro RpcMacros.rpcMetadata[Real] + + implicit final def fromFallback[Real](implicit fallback: Fallback[M[Real]]): M[Real] = fallback.value final class Lazy[Real](metadata: => M[Real]) { lazy val value: M[Real] = metadata diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcUtils.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcUtils.scala index 481734f6d..0484d4242 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcUtils.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/RpcUtils.scala @@ -3,18 +3,30 @@ package rpc import com.avsystem.commons.macros.misc.MiscMacros -/** - * @author ghik - */ +import scala.collection.generic.CanBuildFrom +import scala.collection.mutable + +class RpcException(msg: String, cause: Throwable = null) + extends RuntimeException(msg, cause) + object RpcUtils { + def createEmpty[Coll](cbf: CanBuildFrom[Nothing, Nothing, Coll]): Coll = + createBuilder[Nothing, Coll](cbf, 0).result() + + def createBuilder[Elem, Coll](cbf: CanBuildFrom[Nothing, Elem, Coll], size: Int): mutable.Builder[Elem, Coll] = { + val b = cbf() + b.sizeHint(size) + b + } + def missingArg(rpcName: String, argName: String): Nothing = - throw new IllegalArgumentException(s"Can't interpret raw RPC call $rpcName: argument $argName is missing") + throw new RpcException(s"Can't interpret raw RPC call $rpcName: argument $argName is missing") def unknownRpc(rpcName: String, rawMethodName: String): Nothing = - throw new IllegalArgumentException(s"RPC $rpcName does not map to raw method $rawMethodName") + throw new RpcException(s"RPC $rpcName does not map to raw method $rawMethodName") def missingOptionalRpc(rawMethodName: String): Nothing = - throw new IllegalArgumentException(s"no matching real method for optional raw method $rawMethodName") + throw new RpcException(s"no matching real method for optional raw method $rawMethodName") def compilationError(error: String): Nothing = macro MiscMacros.compilationError } diff --git a/commons-core/src/main/scala/com/avsystem/commons/rpc/StandardRPCFramework.scala b/commons-core/src/main/scala/com/avsystem/commons/rpc/StandardRPCFramework.scala index c59746b92..c1e0bc321 100644 --- a/commons-core/src/main/scala/com/avsystem/commons/rpc/StandardRPCFramework.scala +++ b/commons-core/src/main/scala/com/avsystem/commons/rpc/StandardRPCFramework.scala @@ -9,7 +9,8 @@ trait ProcedureRPCFramework extends RPCFramework { type RawRPC <: ProcedureRawRPC trait ProcedureRawRPC { this: RawRPC => - @multi @verbatim def fire(rpcName: String)(@multi args: List[RawValue]): Unit + @multi + @verbatim def fire(@composite invocation: RawInvocation): Unit } case class ProcedureSignature( @@ -27,7 +28,7 @@ trait FunctionRPCFramework extends RPCFramework { type RawRPC <: FunctionRawRPC trait FunctionRawRPC { this: RawRPC => - @multi def call(rpcName: String)(@multi args: List[RawValue]): Future[RawValue] + @multi def call(@composite invocation: RawInvocation): Future[RawValue] } case class FunctionSignature[T]( @@ -49,13 +50,11 @@ trait FunctionRPCFramework extends RPCFramework { trait GetterRPCFramework extends RPCFramework { type RawRPC <: GetterRawRPC - case class RawInvocation(rpcName: String, args: List[RawValue]) - trait GetterRawRPC { this: RawRPC => - @multi def get(rpcName: String)(@multi args: List[RawValue]): RawRPC + @multi def get(@composite invocation: RawInvocation): RawRPC - def resolveGetterChain(getters: List[RawInvocation]): RawRPC = - getters.foldRight(this)((inv, rpc) => rpc.get(inv.rpcName)(inv.args)) + def resolveGetterChain(getters: Seq[RawInvocation]): RawRPC = + getters.foldRight(this)((inv, rpc) => rpc.get(inv)) } case class GetterSignature[T]( diff --git a/commons-core/src/test/scala/com/avsystem/commons/rest/AbstractRestCallTest.scala b/commons-core/src/test/scala/com/avsystem/commons/rest/AbstractRestCallTest.scala new file mode 100644 index 000000000..39c272ea9 --- /dev/null +++ b/commons-core/src/test/scala/com/avsystem/commons/rest/AbstractRestCallTest.scala @@ -0,0 +1,109 @@ +package com.avsystem.commons +package rest + +import com.avsystem.commons.rest.RawRest.HandleRequest +import com.avsystem.commons.serialization.HasGenCodec +import org.scalactic.source.Position +import org.scalatest.FunSuite +import org.scalatest.concurrent.ScalaFutures + +case class RestEntity(id: String, name: String) +object RestEntity extends HasGenCodec[RestEntity] + +trait RestTestApi { + @GET def trivialGet: Future[Unit] + @GET def failingGet: Future[Unit] + @GET def moreFailingGet: Future[Unit] + + @GET("a/b") def complexGet( + @Path("p1") p1: Int, @Path p2: String, + @Header("X-H1") h1: Int, @Header("X-H2") h2: String, + q1: Int, @Query("q=2") q2: String + ): Future[RestEntity] + + @POST def multiParamPost( + @Path("p1") p1: Int, @Path p2: String, + @Header("X-H1") h1: Int, @Header("X-H2") h2: String, + @Query q1: Int, @Query("q=2") q2: String, + b1: Int, @JsonBodyParam("b\"2") b2: String + ): Future[RestEntity] + + @PUT("") def singleBodyPut( + @Body entity: RestEntity + ): Future[String] + + def prefix( + p0: String, + @Header("X-H0") h0: String, + @Query q0: String + ): RestTestSubApi +} +object RestTestApi extends DefaultRestApiCompanion[RestTestApi] { + val Impl: RestTestApi = new RestTestApi { + def trivialGet: Future[Unit] = Future.unit + def failingGet: Future[Unit] = Future.failed(HttpErrorException(503, "nie")) + def moreFailingGet: Future[Unit] = throw HttpErrorException(503, "nie") + def complexGet(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String): Future[RestEntity] = + Future.successful(RestEntity(s"$p1-$h1-$q1", s"$p2-$h2-$q2")) + def multiParamPost(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, b1: Int, b2: String): Future[RestEntity] = + Future.successful(RestEntity(s"$p1-$h1-$q1-$b1", s"$p2-$h2-$q2-$b2")) + def singleBodyPut(entity: RestEntity): Future[String] = + Future.successful(entity.toString) + def prefix(p0: String, h0: String, q0: String): RestTestSubApi = + RestTestSubApi.impl(s"$p0-$h0-$q0") + } +} + +trait RestTestSubApi { + @GET def subget(@Path p1: Int, @Header("X-H1") h1: Int, q1: Int): Future[String] +} +object RestTestSubApi extends DefaultRestApiCompanion[RestTestSubApi] { + def impl(arg: String): RestTestSubApi = new RestTestSubApi { + def subget(p1: Int, h1: Int, q1: Int): Future[String] = Future.successful(s"$arg-$p1-$h1-$q1") + } +} + +abstract class AbstractRestCallTest extends FunSuite with ScalaFutures { + final val serverHandle: RawRest.HandleRequest = + RawRest.asHandleRequest[RestTestApi](RestTestApi.Impl) + + def clientHandle: RawRest.HandleRequest + + lazy val proxy: RestTestApi = + RawRest.fromHandleRequest[RestTestApi](clientHandle) + + def testCall[T](call: RestTestApi => Future[T])(implicit pos: Position): Unit = + assert(call(proxy).wrapToTry.futureValue == call(RestTestApi.Impl).catchFailures.wrapToTry.futureValue) + + test("trivial GET") { + testCall(_.trivialGet) + } + + test("failing GET") { + testCall(_.failingGet) + } + + test("more failing GET") { + testCall(_.moreFailingGet) + } + + test("complex GET") { + testCall(_.complexGet(0, "a/+&", 1, "b/+&", 2, "ć/+&")) + } + + test("multi-param body POST") { + testCall(_.multiParamPost(0, "a/+&", 1, "b/+&", 2, "ć/+&", 3, "l\"l")) + } + + test("single body PUT") { + testCall(_.singleBodyPut(RestEntity("id", "señor"))) + } + + test("prefixed GET") { + testCall(_.prefix("p0", "h0", "q0").subget(0, 1, 2)) + } +} + +class DirectRestCallTest extends AbstractRestCallTest { + def clientHandle: HandleRequest = serverHandle +} diff --git a/commons-core/src/test/scala/com/avsystem/commons/rest/RawRestTest.scala b/commons-core/src/test/scala/com/avsystem/commons/rest/RawRestTest.scala new file mode 100644 index 000000000..725b400d2 --- /dev/null +++ b/commons-core/src/test/scala/com/avsystem/commons/rest/RawRestTest.scala @@ -0,0 +1,149 @@ +package com.avsystem.commons +package rest + +import com.avsystem.commons.serialization.HasGenCodec +import org.scalactic.source.Position +import org.scalatest.FunSuite +import org.scalatest.concurrent.ScalaFutures + +case class User(id: String, name: String) +object User extends HasGenCodec[User] + +trait UserApi { + @GET def user(userId: String): Future[User] + + @POST("user/save") def user( + @Path("moar/path") paf: String, + @Header("X-Awesome") awesome: Boolean, + @Query("f") foo: Int, + @Body user: User + ): Future[Unit] + + def autopost(bodyarg: String): Future[String] + def singleBodyAutopost(@Body body: String): Future[String] +} +object UserApi extends DefaultRestApiCompanion[UserApi] + +trait RootApi { + @Prefix("") def self: UserApi + def subApi(id: Int, @Query query: String): UserApi + def fail: Future[Unit] + def failMore: Future[Unit] +} +object RootApi extends DefaultRestApiCompanion[RootApi] + +class RawRestTest extends FunSuite with ScalaFutures { + def repr(body: HttpBody, inNewLine: Boolean = true): String = body match { + case HttpBody.Empty => "" + case HttpBody(content, mimeType) => s"${if (inNewLine) "" else " "}$mimeType\n$content" + } + + def repr(req: RestRequest): String = { + val pathRepr = req.parameters.path.map(_.value).mkString("/", "/", "") + val queryRepr = req.parameters.query.iterator + .map({ case (k, v) => s"$k=${v.value}" }).mkStringOrEmpty("?", "&", "") + val hasHeaders = req.parameters.headers.nonEmpty + val headersRepr = req.parameters.headers.iterator + .map({ case (n, v) => s"$n: ${v.value}" }).mkStringOrEmpty("\n", "\n", "\n") + s"-> ${req.method} $pathRepr$queryRepr$headersRepr${repr(req.body, hasHeaders)}".trim + } + + def repr(resp: RestResponse): String = + s"<- ${resp.code} ${repr(resp.body)}".trim + + class RootApiImpl(id: Int, query: String) extends RootApi with UserApi { + def self: UserApi = this + def subApi(newId: Int, newQuery: String): UserApi = new RootApiImpl(newId, query + newQuery) + def user(userId: String): Future[User] = Future.successful(User(userId, s"$userId-$id-$query")) + def user(paf: String, awesome: Boolean, f: Int, user: User): Future[Unit] = Future.unit + def autopost(bodyarg: String): Future[String] = Future.successful(bodyarg.toUpperCase) + def singleBodyAutopost(@Body body: String): Future[String] = Future.successful(body.toUpperCase) + def fail: Future[Unit] = Future.failed(HttpErrorException(400, "zuo")) + def failMore: Future[Unit] = throw HttpErrorException(400, "ZUO") + } + + var trafficLog: String = _ + + val real: RootApi = new RootApiImpl(0, "") + val serverHandle: RawRest.HandleRequest = request => callback => { + RawRest.asHandleRequest(real).apply(request) { result => + callback(result) + result match { + case Success(response) => + trafficLog = s"${repr(request)}\n${repr(response)}\n" + case _ => + } + } + } + + val realProxy: RootApi = RawRest.fromHandleRequest[RootApi](serverHandle) + + def testRestCall[T](call: RootApi => Future[T], expectedTraffic: String)(implicit pos: Position): Unit = { + assert(call(realProxy).wrapToTry.futureValue == call(real).catchFailures.wrapToTry.futureValue) + assert(trafficLog == expectedTraffic) + } + + test("simple GET") { + testRestCall(_.self.user("ID"), + """-> GET /user?userId=ID + |<- 200 application/json + |{"id":"ID","name":"ID-0-"} + |""".stripMargin + ) + } + + test("simple POST with path, header and query") { + testRestCall(_.self.user("paf", awesome = true, 42, User("ID", "Fred")), + """-> POST /user/save/paf/moar/path?f=42 + |X-Awesome: true + |application/json + |{"id":"ID","name":"Fred"} + |<- 200 + |""".stripMargin) + } + + test("auto POST") { + testRestCall(_.self.autopost("bod"), + """-> POST /autopost application/json + |{"bodyarg":"bod"} + |<- 200 application/json + |"BOD" + |""".stripMargin) + } + + test("single body auto POST") { + testRestCall(_.self.singleBodyAutopost("bod"), + """-> POST /singleBodyAutopost application/json + |"bod" + |<- 200 application/json + |"BOD" + |""".stripMargin) + } + + test("simple GET after prefix call") { + testRestCall(_.subApi(1, "query").user("ID"), + """-> GET /subApi/1/user?query=query&userId=ID + |<- 200 application/json + |{"id":"ID","name":"ID-1-query"} + |""".stripMargin + ) + } + + test("failing POST") { + testRestCall(_.fail, + """-> POST /fail + |<- 400 text/plain + |zuo + |""".stripMargin + ) + } + + test("throwing POST") { + testRestCall(_.failMore, + """-> POST /failMore + |<- 400 text/plain + |ZUO + |""".stripMargin + ) + } +} diff --git a/commons-core/src/test/scala/com/avsystem/commons/rest/RestValidationTest.scala b/commons-core/src/test/scala/com/avsystem/commons/rest/RestValidationTest.scala new file mode 100644 index 000000000..b956cff8d --- /dev/null +++ b/commons-core/src/test/scala/com/avsystem/commons/rest/RestValidationTest.scala @@ -0,0 +1,74 @@ +package com.avsystem.commons +package rest + +import org.scalatest.FunSuite + +class RestValidationTest extends FunSuite { + trait Api2 { + def self: Api2 + } + object Api2 { + implicit val metadata: RestMetadata[Api2] = RestMetadata.materializeForRpc[Api2] + } + + test("recursive API") { + val failure = intercept[InvalidRestApiException](Api2.metadata.ensureUnambiguousPaths()) + assert(failure.getMessage == "call chain self->self is recursive, recursively defined server APIs are forbidden") + } + + trait Api1 { + @GET("p") def g1: Future[String] + @GET("p") def g2: Future[String] + @GET("") def g3(@Path("p") arg: String): Future[String] + + @POST("p") def p1: Future[String] + } + + test("simple ambiguous paths") { + val failure = intercept[InvalidRestApiException] { + RestMetadata.materializeForRpc[Api1].ensureUnambiguousPaths() + } + assert(failure.getMessage == + """REST API has ambiguous paths: + |GET /p may result from multiple calls: + | g2 + | g1""".stripMargin + ) + } + + trait PrefixApi1 { + def prefix(@Header("X-Lol") lol: String): SuffixApi1 + } + trait SuffixApi1 { + def post(@Header("X-Lol") lol: String): Future[String] + } + object SuffixApi1 { + implicit val metadata: RestMetadata[SuffixApi1] = RestMetadata.materializeForRpc + } + + test("conflicting header params") { + val failure = intercept[InvalidRestApiException] { + RestMetadata.materializeForRpc[PrefixApi1].ensureUniqueParams(Nil) + } + assert(failure.getMessage == + "Header parameter X-Lol of POST_post collides with header parameter of the same name in prefix prefix") + } + + trait PrefixApi2 { + def prefix(@Query lol: String): SuffixApi2 + } + trait SuffixApi2 { + def post(@Query lol: String): Future[String] + } + object SuffixApi2 { + implicit val metadata: RestMetadata[SuffixApi2] = RestMetadata.materializeForRpc + } + + test("conflicting query params") { + val failure = intercept[InvalidRestApiException] { + RestMetadata.materializeForRpc[PrefixApi2].ensureUniqueParams(Nil) + } + assert(failure.getMessage == + "Query parameter lol of POST_post collides with query parameter of the same name in prefix prefix") + } +} diff --git a/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRawRpc.scala b/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRawRpc.scala index 73dfd6bee..02c5f7cac 100644 --- a/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRawRpc.scala +++ b/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRawRpc.scala @@ -22,42 +22,49 @@ case class renamed(int: Int, name: String) extends DummyParamTag { case class suchMeta(intMeta: Int, strMeta: String) extends StaticAnnotation -sealed trait untagged extends DummyParamTag - sealed trait RestMethod extends RpcTag -case class POST() extends RestMethod +case class POST() extends RestMethod with AnnotationAggregate { + @rpcNamePrefix("POST_") type Implied +} case class GET() extends RestMethod case class PUT() extends RestMethod -@methodTag[RestMethod, RestMethod] -@paramTag[DummyParamTag, untagged] +case class GetterInvocation( + @methodName name: String, + @encoded head: String, + @multi tail: List[String] +) + +@methodTag[RestMethod] +@paramTag[DummyParamTag] trait NewRawRpc { def doSomething(arg: Double): String @optional def doSomethingElse(arg: Double): String @multi - @verbatim def fire(name: String)( + @verbatim def fire( + @methodName name: String, @optional @auxiliary ajdi: Opt[Int], @multi args: Map[String, String]): Unit - @multi def call(name: String)( + @multi def call( + @methodName name: String, @tagged[renamed] @multi renamedArgs: => Map[String, String], @multi args: Map[String, String]): Future[String] - @multi def get(name: String)( - @encoded head: String, @multi tail: List[String]): NewRawRpc + @multi def get(@composite invocation: GetterInvocation): NewRawRpc @multi - @tagged[POST] def post(name: String)( + @tagged[POST] def post(@methodName name: String, @tagged[header] @multi @verbatim headers: Vector[String], @multi body: MLinkedHashMap[String, String]): String - @multi def prefix(name: String): NewRawRpc + @multi def prefix(@methodName name: String): NewRawRpc } object NewRawRpc extends RawRpcCompanion[NewRawRpc] { override val implicits: this.type = this - implicit def AsRawRealFromGenCodec[T: GenCodec]: AsRawReal[String, T] = ??? + implicit def asRawRealFromGenCodec[T: GenCodec]: AsRawReal[String, T] = ??? implicit def futureAsRawRealFromGenCodec[T: GenCodec]: AsRawReal[Future[String], Future[T]] = ??? } @@ -70,11 +77,15 @@ object Utils { import com.avsystem.commons.rpc.Utils._ -@methodTag[RestMethod, RestMethod] -@paramTag[DummyParamTag, untagged] -case class NewRpcMetadata[T: TypeName]( +case class DoSomethings( doSomething: DoSomethingSignature, - @optional doSomethingElse: Opt[DoSomethingSignature], + @optional doSomethingElse: Opt[DoSomethingSignature] +) + +@methodTag[RestMethod] +@paramTag[DummyParamTag] +case class NewRpcMetadata[T: TypeName]( + @composite doSomethings: DoSomethings, @multi @verbatim procedures: Map[String, FireMetadata], @multi functions: Map[String, CallMetadata[_]], @multi getters: Map[String, GetterMetadata[_]], @@ -84,7 +95,7 @@ case class NewRpcMetadata[T: TypeName]( def repr(open: List[NewRpcMetadata[_]]): String = if (open.contains(this)) "\n" else { val membersStr = - s"DO SOMETHING ELSE: ${doSomethingElse.nonEmpty}\n" + + s"DO SOMETHING ELSE: ${doSomethings.doSomethingElse.nonEmpty}\n" + procedures.iterator.map({ case (n, v) => s"$n -> ${v.repr}" }).mkString("PROCEDURES:\n", "\n", "") + "\n" + functions.iterator.map({ case (n, v) => s"$n -> ${v.repr}" }).mkString("FUNCTIONS:\n", "\n", "") + "\n" + posters.iterator.map({ case (n, v) => s"$n -> ${v.repr}" }).mkString("POSTERS:\n", "\n", "") + "\n" + @@ -101,18 +112,15 @@ case class DoSomethingSignature(arg: ArgMetadata) extends TypedMetadata[String] case class ArgMetadata() extends TypedMetadata[Double] trait MethodMetadata[T] { - @reifyName def name: String - @reifyName(rpcName = true) def rpcName: String + @composite def nameInfo: NameInfo def typeName: TypeName[T] - def basicRepr: String = { - val rpcNameStr = if (rpcName != name) s"<$rpcName>" else "" - s"def $name$rpcNameStr: ${typeName.name}" - } + def basicRepr: String = + s"def ${nameInfo.repr}: ${typeName.name}" } case class FireMetadata( - name: String, rpcName: String, + nameInfo: NameInfo, @optional @auxiliary ajdi: Opt[ParameterMetadata[Int]], @multi args: Map[String, ParameterMetadata[_]] ) extends TypedMetadata[Unit] with MethodMetadata[Unit] { @@ -124,7 +132,7 @@ case class FireMetadata( } case class CallMetadata[T]( - name: String, rpcName: String, + nameInfo: NameInfo, @tagged[renamed] @multi renamed: Map[String, ParameterMetadata[_]], @multi args: Map[String, ParameterMetadata[_]] )(implicit val typeName: TypeName[T]) @@ -135,20 +143,26 @@ case class CallMetadata[T]( args.iterator.map({ case (n, pm) => s"$n -> ${pm.repr}" }).mkString("ARGS:\n", "\n", "").indent(" ") } -case class GetterMetadata[T]( - name: String, rpcName: String, +case class GetterParams( @encoded head: ParameterMetadata[_], - @multi tail: List[ParameterMetadata[_]], + @multi tail: List[ParameterMetadata[_]] +) { + def repr: String = (head :: tail).map(_.repr).mkString("ARGS:\n", "\n", "") +} + +case class GetterMetadata[T]( + nameInfo: NameInfo, + @composite params: GetterParams, @infer @checked resultMetadata: NewRpcMetadata.Lazy[T] )(implicit val typeName: TypeName[T]) extends TypedMetadata[T] with MethodMetadata[T] { def repr(open: List[NewRpcMetadata[_]]): String = s"$basicRepr\n" + - (head :: tail).map(_.repr).mkString("ARGS:\n", "\n", "").indent(" ") + "\n" + + params.repr.indent(" ") + "\n" + s"RESULT: ${resultMetadata.value.repr(open)}".indent(" ") } case class PostMetadata[T: TypeName]( - name: String, rpcName: String, + nameInfo: NameInfo, @reifyAnnot post: POST, @tagged[header] @multi @verbatim headers: Vector[ParameterMetadata[String]], @multi body: MLinkedHashMap[String, ParameterMetadata[_]] @@ -161,7 +175,7 @@ case class PostMetadata[T: TypeName]( } case class PrefixMetadata[T]( - name: String, rpcName: String, + nameInfo: NameInfo, @infer @checked resultMetadata: NewRpcMetadata.Lazy[T], @infer typeName: TypeName[T] ) extends TypedMetadata[T] with MethodMetadata[T] { @@ -171,18 +185,23 @@ case class PrefixMetadata[T]( } case class ParameterMetadata[T: TypeName]( - @reifyName name: String, - @reifyName(rpcName = true) rpcName: String, + @composite nameInfo: NameInfo, @reifyPosition pos: ParamPosition, @reifyFlags flags: ParamFlags, @reifyAnnot @multi metas: List[suchMeta], - @hasAnnot[suchMeta] suchMeta: Boolean + @isAnnotated[suchMeta] suchMeta: Boolean ) extends TypedMetadata[T] { def repr: String = { val flagsStr = if (flags != ParamFlags.Empty) s"[$flags]" else "" - val rpcNameStr = if (rpcName != name) s"<$rpcName>" else "" val posStr = s"${pos.index}:${pos.indexOfList}:${pos.indexInList}:${pos.indexInRaw}" val metasStr = if (metas.nonEmpty) metas.mkString(s",metas=", ",", "") else "" - s"$flagsStr$name$rpcNameStr@$posStr: ${TypeName.get[T]} suchMeta=$suchMeta$metasStr" + s"$flagsStr${nameInfo.repr}@$posStr: ${TypeName.get[T]} suchMeta=$suchMeta$metasStr" } } + +case class NameInfo( + @reifyName name: String, + @reifyName(rpcName = true) rpcName: String +) { + def repr: String = name + (if (rpcName != name) s"<$rpcName>" else "") +} diff --git a/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRpcMetadataTest.scala b/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRpcMetadataTest.scala index adf1e55ae..8ba09b83f 100644 --- a/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRpcMetadataTest.scala +++ b/commons-core/src/test/scala/com/avsystem/commons/rpc/NewRpcMetadataTest.scala @@ -1,9 +1,11 @@ package com.avsystem.commons package rpc -import com.avsystem.commons.serialization.whenAbsent +import com.avsystem.commons.serialization.{transientDefault, whenAbsent} import org.scalatest.FunSuite +class td extends transientDefault + trait SomeBase { def difolt: Boolean = true @@ -14,16 +16,16 @@ trait TestApi extends SomeBase { def doSomething(double: Double): String def doSomethingElse(double: Double): String def varargsMethod(krap: String, dubl: Double)(czy: Boolean, @renamed(42, "nejm") ints: Int*): Future[Unit] - def defaultValueMethod(int: Int = 0, @whenAbsent(difolt) bul: Boolean): Future[Unit] + def defaultValueMethod(@td int: Int = 0, @whenAbsent(difolt) bul: Boolean): Future[Unit] def flames(arg: String, otherArg: => Int, varargsy: Double*): Unit - def overload(int: Int): Unit - def overload(lel: String): TestApi - def overload: TestApi + def overload(@td int: Int = 42): Unit + @rpcName("ovgetter") def overload(lel: String): TestApi + @rpcName("ovprefix") def overload: TestApi def getit(stuff: String, @suchMeta(1, "a") otherStuff: List[Int]): TestApi def postit(arg: String, bar: String, int: Int, @suchMeta(3, "c") foo: String): String } object TestApi { - implicit val AsRawReal: NewRawRpc.AsRawRealRpc[TestApi] = NewRawRpc.materializeAsRawReal[TestApi] + implicit val asRawReal: NewRawRpc.AsRawRealRpc[TestApi] = NewRawRpc.materializeAsRawReal[TestApi] implicit val metadata: NewRpcMetadata[TestApi] = NewRpcMetadata.materializeForRpc[TestApi] } @@ -34,9 +36,9 @@ class NewRpcMetadataTest extends FunSuite { | DO SOMETHING ELSE: true | PROCEDURES: | overload -> def overload: void - | AJDI: int@0:0:0:0: int suchMeta=false + | AJDI: [hasDefaultValue]int@0:0:0:0: int suchMeta=false | ARGS: - | int -> int@0:0:0:0: int suchMeta=false + | int -> [hasDefaultValue]int@0:0:0:0: int suchMeta=false | flames -> def flames: void | NO AJDI | ARGS: @@ -58,7 +60,7 @@ class NewRpcMetadataTest extends FunSuite { | dubl -> dubl@1:0:1:1: double suchMeta=false | czy -> czy@2:1:0:2: boolean suchMeta=false | POSTERS: - | postit -> POST() def postit: String + | POST_postit -> POST() def postit: String | HEADERS: | bar@1:0:1:0: String suchMeta=false | foo@3:0:3:1: String suchMeta=true,metas=suchMeta(3,c),suchMeta(2,b) @@ -72,12 +74,12 @@ class NewRpcMetadataTest extends FunSuite { | otherStuff@1:0:1:0: List suchMeta=true,metas=suchMeta(1,a) | RESULT: | - | overload -> def overload: TestApi + | ovgetter -> def overload: TestApi | ARGS: | lel@0:0:0:0: String suchMeta=false | RESULT: | PREFIXERS: - | overload -> def overload: TestApi + | ovprefix -> def overload: TestApi | RESULT: |""".stripMargin ) diff --git a/commons-core/src/test/scala/com/avsystem/commons/rpc/RPCTest.scala b/commons-core/src/test/scala/com/avsystem/commons/rpc/RPCTest.scala index e0bec61e8..21456b680 100644 --- a/commons-core/src/test/scala/com/avsystem/commons/rpc/RPCTest.scala +++ b/commons-core/src/test/scala/com/avsystem/commons/rpc/RPCTest.scala @@ -19,60 +19,61 @@ class RPCTest extends WordSpec with Matchers with BeforeAndAfterAll { "rpc caller" should { "should properly deserialize RPC calls" in { - val invocations = new ArrayBuffer[(String, List[Any])] - val rawRpc = AsRawRPC[TestRPC].asRaw(TestRPC.rpcImpl((name, args, _) => { - invocations += ((name, args)) - name + val invocations = new ArrayBuffer[RawInvocation] + val rawRpc = AsRawRPC[TestRPC].asRaw(TestRPC.rpcImpl((inv, _) => { + invocations += inv + inv.rpcName })) - rawRpc.fire("handleMore")(Nil) - rawRpc.fire("doStuff")(List(42, "omgsrsly", Some(true))) - assert("doStuffResult" === get(rawRpc.call("doStuffBoolean")(List(true)))) - rawRpc.fire("doStuffInt")(List(5)) - rawRpc.fire("doStuffInt")(Nil) - rawRpc.fire("handleMore")(Nil) - rawRpc.fire("handle")(Nil) - rawRpc.fire("takeCC")(Nil) - rawRpc.fire("srslyDude")(Nil) - rawRpc.get("innerRpc")(List("innerName")).fire("proc")(Nil) - assert("innerRpc.funcResult" === get(rawRpc.get("innerRpc")(List("innerName")).call("func")(List(42)))) + rawRpc.fire(RawInvocation("handleMore", Nil)) + rawRpc.fire(RawInvocation("doStuff", List(42, "omgsrsly", Some(true)))) + assert("doStuffResult" === get(rawRpc.call(RawInvocation("doStuffBoolean", List(true))))) + rawRpc.fire(RawInvocation("doStuffInt", List(5))) + rawRpc.fire(RawInvocation("doStuffInt", Nil)) + rawRpc.fire(RawInvocation("handleMore", Nil)) + rawRpc.fire(RawInvocation("handle", Nil)) + rawRpc.fire(RawInvocation("takeCC", Nil)) + rawRpc.fire(RawInvocation("srslyDude", Nil)) + rawRpc.get(RawInvocation("innerRpc", List("innerName"))).fire(RawInvocation("proc", Nil)) + assert("innerRpc.funcResult" === get(rawRpc.get(RawInvocation("innerRpc", List("innerName"))) + .call(RawInvocation("func", List(42))))) assert(invocations.toList === List( - ("handleMore", Nil), - ("doStuff", List(42, "omgsrsly", Some(true))), - ("doStuffBoolean", List(true)), - ("doStuffInt", List(5)), - ("doStuffInt", List(42)), - ("handleMore", Nil), - ("handle", Nil), - ("takeCC", List(Record(-1, "_"))), - ("srslyDude", Nil), - ("innerRpc", List("innerName")), - ("innerRpc.proc", Nil), - ("innerRpc", List("innerName")), - ("innerRpc.func", List(42)) + RawInvocation("handleMore", Nil), + RawInvocation("doStuff", List(42, "omgsrsly", Some(true))), + RawInvocation("doStuffBoolean", List(true)), + RawInvocation("doStuffInt", List(5)), + RawInvocation("doStuffInt", List(42)), + RawInvocation("handleMore", Nil), + RawInvocation("handle", Nil), + RawInvocation("takeCC", List(Record(-1, "_"))), + RawInvocation("srslyDude", Nil), + RawInvocation("innerRpc", List("innerName")), + RawInvocation("innerRpc.proc", Nil), + RawInvocation("innerRpc", List("innerName")), + RawInvocation("innerRpc.func", List(42)) )) } "fail on bad input" in { - val rawRpc = AsRawRPC[TestRPC].asRaw(TestRPC.rpcImpl((_, _, _) => ())) - intercept[Exception](rawRpc.fire("whatever")(Nil)) + val rawRpc = AsRawRPC[TestRPC].asRaw(TestRPC.rpcImpl((_, _) => ())) + intercept[Exception](rawRpc.fire(RawInvocation("whatever", Nil))) } "real rpc should properly serialize calls to raw rpc" in { - val invocations = new ArrayBuffer[(String, List[Any])] + val invocations = new ArrayBuffer[RawInvocation] object rawRpc extends RawRPC with RunNowFutureCallbacks { - def fire(rpcName: String)(args: List[Any]): Unit = - invocations += ((rpcName, args)) + def fire(inv: RawInvocation): Unit = + invocations += inv - def call(rpcName: String)(args: List[Any]): Future[Any] = { - invocations += ((rpcName, args)) - Future.successful(rpcName + "Result") + def call(inv: RawInvocation): Future[Any] = { + invocations += inv + Future.successful(inv.rpcName + "Result") } - def get(rpcName: String)(args: List[Any]): RawRPC = { - invocations += ((rpcName, args)) + def get(inv: RawInvocation): RawRPC = { + invocations += inv this } } @@ -90,20 +91,20 @@ class RPCTest extends WordSpec with Matchers with BeforeAndAfterAll { realRpc.innerRpc("innerName").moreInner("moreInner").moreInner("evenMoreInner").func(42) assert(invocations.toList === List( - ("handleMore", Nil), - ("doStuff", List(42, "omgsrsly", Some(true))), - ("doStuffBoolean", List(true)), - ("doStuffInt", List(5)), - ("handleMore", Nil), - ("handle", Nil), - - ("innerRpc", List("innerName")), - ("proc", Nil), - - ("innerRpc", List("innerName")), - ("moreInner", List("moreInner")), - ("moreInner", List("evenMoreInner")), - ("func", List(42)) + RawInvocation("handleMore", Nil), + RawInvocation("doStuff", List(42, "omgsrsly", Some(true))), + RawInvocation("doStuffBoolean", List(true)), + RawInvocation("doStuffInt", List(5)), + RawInvocation("handleMore", Nil), + RawInvocation("handle", Nil), + + RawInvocation("innerRpc", List("innerName")), + RawInvocation("proc", Nil), + + RawInvocation("innerRpc", List("innerName")), + RawInvocation("moreInner", List("moreInner")), + RawInvocation("moreInner", List("evenMoreInner")), + RawInvocation("func", List(42)) )) } diff --git a/commons-core/src/test/scala/com/avsystem/commons/rpc/TestRPC.scala b/commons-core/src/test/scala/com/avsystem/commons/rpc/TestRPC.scala index 93ca2cb94..fde27c916 100644 --- a/commons-core/src/test/scala/com/avsystem/commons/rpc/TestRPC.scala +++ b/commons-core/src/test/scala/com/avsystem/commons/rpc/TestRPC.scala @@ -1,6 +1,7 @@ package com.avsystem.commons package rpc +import com.avsystem.commons.rpc.DummyRPC._ import com.avsystem.commons.serialization.{HasGenCodec, whenAbsent} import com.github.ghik.silencer.silent @@ -16,7 +17,7 @@ trait InnerRPC { def indirectRecursion(): TestRPC } -object InnerRPC extends DummyRPC.RPCCompanion[InnerRPC] +object InnerRPC extends RPCCompanion[InnerRPC] trait TestRPC { def defaultNum: Int = 42 @@ -42,18 +43,18 @@ trait TestRPC { } @silent -object TestRPC extends DummyRPC.RPCCompanion[TestRPC] { - def rpcImpl(onInvocation: (String, List[Any], Option[Any]) => Any) = new TestRPC { outer => +object TestRPC extends RPCCompanion[TestRPC] { + def rpcImpl(onInvocation: (RawInvocation, Option[Any]) => Any): TestRPC = new TestRPC { outer => private def onProcedure(methodName: String, args: List[Any]): Unit = - onInvocation(methodName, args, None) + onInvocation(RawInvocation(methodName, args), None) private def onCall[T](methodName: String, args: List[Any], result: T): Future[T] = { - onInvocation(methodName, args, Some(result)) + onInvocation(RawInvocation(methodName, args), Some(result)) Future.successful(result) } private def onGet[T](methodName: String, args: List[Any], result: T): T = { - onInvocation(methodName, args, None) + onInvocation(RawInvocation(methodName, args), None) result } diff --git a/commons-core/src/test/scala/com/avsystem/commons/serialization/json/JsonStringInputOutputTest.scala b/commons-core/src/test/scala/com/avsystem/commons/serialization/json/JsonStringInputOutputTest.scala index 039357d0f..a71bb869d 100644 --- a/commons-core/src/test/scala/com/avsystem/commons/serialization/json/JsonStringInputOutputTest.scala +++ b/commons-core/src/test/scala/com/avsystem/commons/serialization/json/JsonStringInputOutputTest.scala @@ -131,6 +131,11 @@ class JsonStringInputOutputTest extends FunSuite with SerializationTestUtils wit assert(read[Map[String, List[Int]]](prettyJson, options) == map) } + test("whitespace skipping") { + val json = """ { "a" : [ 1 , 2 ] , "b" : [ ] } """ + assert(read[Map[String, List[Int]]](json) == Map("a" -> List(1, 2), "b" -> Nil)) + } + test("NaN") { val value = Double.NaN diff --git a/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestClient.scala b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestClient.scala new file mode 100644 index 000000000..9a16e13a0 --- /dev/null +++ b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestClient.scala @@ -0,0 +1,52 @@ +package com.avsystem.commons +package jetty.rest + +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +import com.avsystem.commons.annotation.explicitGenerics +import com.avsystem.commons.rest.{HeaderValue, HttpBody, QueryValue, RawRest, RestMetadata, RestResponse} +import org.eclipse.jetty.client.HttpClient +import org.eclipse.jetty.client.api.Result +import org.eclipse.jetty.client.util.{BufferingResponseListener, StringContentProvider} +import org.eclipse.jetty.http.{HttpHeader, MimeTypes} + +object RestClient { + def apply[@explicitGenerics Real: RawRest.AsRealRpc : RestMetadata](client: HttpClient, baseUrl: String): Real = + RawRest.fromHandleRequest[Real](asHandleRequest(client, baseUrl)) + + def asHandleRequest(client: HttpClient, baseUrl: String): RawRest.HandleRequest = + RawRest.safeHandle(request => callback => { + val path = request.parameters.path.iterator + .map(pv => URLEncoder.encode(pv.value, "utf-8")) + .mkString(baseUrl.ensureSuffix("/"), "/", "") + + val httpReq = client.newRequest(baseUrl).method(request.method.toString) + + httpReq.path(path) + request.parameters.query.foreach { + case (name, QueryValue(value)) => httpReq.param(name, value) + } + request.parameters.headers.foreach { + case (name, HeaderValue(value)) => httpReq.header(name, value) + } + + request.body.forNonEmpty { (content, mimeType) => + httpReq.content(new StringContentProvider(s"$mimeType;charset=utf-8", content, StandardCharsets.UTF_8)) + } + + httpReq.send(new BufferingResponseListener() { + override def onComplete(result: Result): Unit = + if (result.isSucceeded) { + val httpResp = result.getResponse + val body = httpResp.getHeaders.get(HttpHeader.CONTENT_TYPE).opt.fold(HttpBody.empty) { contentType => + HttpBody(getContentAsString(), MimeTypes.getContentTypeWithoutCharset(contentType)) + } + val response = RestResponse(httpResp.getStatus, body) + callback(Success(response)) + } else { + callback(Failure(result.getFailure)) + } + }) + }) +} diff --git a/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestHandler.scala b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestHandler.scala new file mode 100644 index 000000000..4cca73011 --- /dev/null +++ b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestHandler.scala @@ -0,0 +1,20 @@ +package com.avsystem.commons +package jetty.rest + +import com.avsystem.commons.annotation.explicitGenerics +import com.avsystem.commons.rest.{RawRest, RestMetadata} +import javax.servlet.http.{HttpServletRequest, HttpServletResponse} +import org.eclipse.jetty.server.Request +import org.eclipse.jetty.server.handler.AbstractHandler + +class RestHandler(handleRequest: RawRest.HandleRequest) extends AbstractHandler { + override def handle(target: String, baseRequest: Request, request: HttpServletRequest, response: HttpServletResponse): Unit = { + baseRequest.setHandled(true) + RestServlet.handle(handleRequest, request, response) + } +} + +object RestHandler { + def apply[@explicitGenerics Real: RawRest.AsRawRpc : RestMetadata](real: Real): RestHandler = + new RestHandler(RawRest.asHandleRequest[Real](real)) +} diff --git a/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestServlet.scala b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestServlet.scala new file mode 100644 index 000000000..0624afe3e --- /dev/null +++ b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestServlet.scala @@ -0,0 +1,77 @@ +package com.avsystem.commons +package jetty.rest + +import java.net.URLDecoder +import java.util.regex.Pattern + +import com.avsystem.commons.annotation.explicitGenerics +import com.avsystem.commons.rest.{HeaderValue, HttpBody, HttpMethod, PathValue, QueryValue, RawRest, RestParameters, RestMetadata, RestRequest} +import com.avsystem.commons.rpc.NamedParams +import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse} +import org.eclipse.jetty.http.{HttpStatus, MimeTypes} + +class RestServlet(handleRequest: RawRest.HandleRequest) extends HttpServlet { + override def service(req: HttpServletRequest, resp: HttpServletResponse): Unit = { + RestServlet.handle(handleRequest, req, resp) + } +} + +object RestServlet { + def apply[@explicitGenerics Real: RawRest.AsRawRpc : RestMetadata](real: Real): RestServlet = + new RestServlet(RawRest.asHandleRequest[Real](real)) + + private val SeparatorPattern: Pattern = Pattern.compile("/") + + def handle( + handleRequest: RawRest.HandleRequest, + request: HttpServletRequest, + response: HttpServletResponse + ): Unit = { + val method = HttpMethod.byName(request.getMethod) + + // can't use request.getPathInfo because it decodes the URL before we can split it + val encodedPath = request.getRequestURI.stripPrefix(request.getServletPath).stripPrefix("/") + val path = SeparatorPattern + .splitAsStream(encodedPath).asScala + .map(v => PathValue(URLDecoder.decode(v, "utf-8"))) + .to[List] + + val headersBuilder = NamedParams.newBuilder[HeaderValue] + request.getHeaderNames.asScala.foreach { headerName => + headersBuilder += headerName -> HeaderValue(request.getHeader(headerName)) + } + val headers = headersBuilder.result() + + val queryBuilder = NamedParams.newBuilder[QueryValue] + request.getParameterNames.asScala.foreach { parameterName => + queryBuilder += parameterName -> QueryValue(request.getParameter(parameterName)) + } + val query = queryBuilder.result() + + val body = request.getContentType.opt.fold(HttpBody.empty) { contentType => + val bodyReader = request.getReader + val bodyBuilder = new JStringBuilder + Iterator.continually(bodyReader.read()) + .takeWhile(_ != -1) + .foreach(bodyBuilder.appendCodePoint) + HttpBody(bodyBuilder.toString, MimeTypes.getContentTypeWithoutCharset(contentType)) + } + val restRequest = RestRequest(method, RestParameters(path, headers, query), body) + + val asyncContext = request.startAsync() + RawRest.safeAsync(handleRequest(restRequest)) { + case Success(restResponse) => + response.setStatus(restResponse.code) + restResponse.body.forNonEmpty { (content, mimeType) => + response.setContentType(s"$mimeType;charset=utf-8") + response.getWriter.write(content) + } + asyncContext.complete() + case Failure(e) => + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR_500) + response.setContentType(MimeTypes.Type.TEXT_PLAIN_UTF_8.asString()) + response.getWriter.write(e.getMessage) + asyncContext.complete() + } + } +} diff --git a/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rpc/JettyRPCFramework.scala b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rpc/JettyRPCFramework.scala index 4c7dee069..8ae7de7d7 100644 --- a/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rpc/JettyRPCFramework.scala +++ b/commons-jetty/src/main/scala/com/avsystem/commons/jetty/rpc/JettyRPCFramework.scala @@ -37,22 +37,19 @@ object JettyRPCFramework extends StandardRPCFramework with LazyLogging { override def read[T: Reader](raw: RawValue): T = JsonStringInput.read[T](raw.s) override def write[T: Writer](value: T): RawValue = new RawValue(JsonStringOutput.write[T](value)) - case class Invocation(rpcName: String, args: List[RawValue]) - object Invocation extends HasGenCodec[Invocation] - - case class Call(chain: List[Invocation], leaf: Invocation) + case class Call(chain: List[RawInvocation], leaf: RawInvocation) object Call extends HasGenCodec[Call] class RPCClient(httpClient: HttpClient, uri: String, maxResponseLength: Int)(implicit ec: ExecutionContext) { - private class RawRPCImpl(chain: List[Invocation]) extends RawRPC { - override def fire(rpcName: String)(args: List[RawValue]): Unit = - put(Call(chain, Invocation(rpcName, args))) + private class RawRPCImpl(chain: List[RawInvocation]) extends RawRPC { + override def fire(invocation: RawInvocation): Unit = + put(Call(chain, invocation)) - override def call(rpcName: String)(args: List[RawValue]): Future[RawValue] = - post(Call(chain, Invocation(rpcName, args))) + override def call(invocation: RawInvocation): Future[RawValue] = + post(Call(chain, invocation)) - override def get(rpcName: String)(args: List[RawValue]): RawRPC = - new RawRPCImpl(chain :+ Invocation(rpcName, args)) + override def get(invocation: RawInvocation): RawRPC = + new RawRPCImpl(chain :+ invocation) } val rawRPC: RawRPC = new RawRPCImpl(List.empty) @@ -123,11 +120,11 @@ object JettyRPCFramework extends StandardRPCFramework with LazyLogging { } } - type InvokeFunction[T] = RawRPC => String => List[RawValue] => T + type InvokeFunction[T] = RawRPC => RawInvocation => T def invoke[T](call: Call)(f: InvokeFunction[T]): T = { - val rpc = call.chain.foldLeft(rootRpc)((rpc, inv) => rpc.get(inv.rpcName)(inv.args)) - f(rpc)(call.leaf.rpcName)(call.leaf.args) + val rpc = call.chain.foldLeft(rootRpc)((rpc, inv) => rpc.get(inv)) + f(rpc)(call.leaf) } def handlePost(call: Call): Future[RawValue] = diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/HttpRestCallTest.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/HttpRestCallTest.scala new file mode 100644 index 000000000..37232a4ce --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/HttpRestCallTest.scala @@ -0,0 +1,24 @@ +package com.avsystem.commons +package jetty.rest + +import com.avsystem.commons.rest.AbstractRestCallTest +import com.avsystem.commons.rest.RawRest.HandleRequest +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.{ServletHandler, ServletHolder} + +import scala.concurrent.duration._ + +class HttpRestCallTest extends AbstractRestCallTest with UsesHttpServer with UsesHttpClient { + override def patienceConfig: PatienceConfig = PatienceConfig(10.seconds) + + protected def setupServer(server: Server): Unit = { + val servlet = new RestServlet(serverHandle) + val holder = new ServletHolder(servlet) + val handler = new ServletHandler + handler.addServletWithMapping(holder, "/api/*") + server.setHandler(handler) + } + + def clientHandle: HandleRequest = + RestClient.asHandleRequest(client, s"$baseUrl/api") +} diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/RestServletTest.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/RestServletTest.scala new file mode 100644 index 000000000..d51ff6e98 --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/RestServletTest.scala @@ -0,0 +1,56 @@ +package com.avsystem.commons +package jetty.rest + +import java.nio.charset.StandardCharsets + +import org.eclipse.jetty.client.util.StringContentProvider +import org.eclipse.jetty.http.{HttpMethod, HttpStatus} +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.{ServletHandler, ServletHolder} +import org.scalatest.FunSuite + +class RestServletTest extends FunSuite with UsesHttpServer with UsesHttpClient { + override protected def setupServer(server: Server): Unit = { + val servlet = RestServlet[SomeApi](SomeApi.impl) + val holder = new ServletHolder(servlet) + val handler = new ServletHandler + handler.addServletWithMapping(holder, "/*") + server.setHandler(handler) + } + + test("GET method") { + val response = client.newRequest(s"$baseUrl/hello") + .method(HttpMethod.GET) + .param("who", "World") + .send() + + assert(response.getContentAsString === """"Hello, World!"""") + } + + test("POST method") { + val response = client.newRequest(s"$baseUrl/hello") + .method(HttpMethod.POST) + .content(new StringContentProvider("application/json", """{"who":"World"}""", StandardCharsets.UTF_8)) + .send() + + assert(response.getContentAsString === """"Hello, World!"""") + } + + test("error handling") { + val response = client.newRequest(s"$baseUrl/hello") + .method(HttpMethod.GET) + .param("who", SomeApi.poison) + .send() + + assert(response.getStatus === HttpStatus.INTERNAL_SERVER_ERROR_500) + assert(response.getContentAsString === SomeApi.poison) + } + + test("invalid path") { + val response = client.newRequest(s"$baseUrl/invalidPath") + .method(HttpMethod.GET) + .send() + + assert(response.getStatus === HttpStatus.NOT_FOUND_404) + } +} diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/SomeApi.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/SomeApi.scala new file mode 100644 index 000000000..ac884055f --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/SomeApi.scala @@ -0,0 +1,26 @@ +package com.avsystem.commons +package jetty.rest + +import com.avsystem.commons.rest.{DefaultRestApiCompanion, GET, POST} + +trait SomeApi { + @GET + def hello(who: String): Future[String] + + @POST("hello") + def helloThere(who: String): Future[String] +} + +object SomeApi extends DefaultRestApiCompanion[SomeApi] { + def format(who: String) = s"Hello, $who!" + val poison: String = "poison" + + val impl: SomeApi = new SomeApi { + override def hello(who: String): Future[String] = { + if (who == poison) throw new IllegalArgumentException(poison) + else Future.successful(format(who)) + } + + override def helloThere(who: String): Future[String] = hello(who) + } +} diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/UsesHttpClient.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/UsesHttpClient.scala new file mode 100644 index 000000000..59c271b67 --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/UsesHttpClient.scala @@ -0,0 +1,19 @@ +package com.avsystem.commons +package jetty.rest + +import org.eclipse.jetty.client.HttpClient +import org.scalatest.{BeforeAndAfterAll, Suite} + +trait UsesHttpClient extends BeforeAndAfterAll { this: Suite => + val client: HttpClient = new HttpClient() + + override protected def beforeAll(): Unit = { + super.beforeAll() + client.start() + } + + override protected def afterAll(): Unit = { + client.stop() + super.afterAll() + } +} diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/UsesHttpServer.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/UsesHttpServer.scala new file mode 100644 index 000000000..dd7fe73d7 --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/UsesHttpServer.scala @@ -0,0 +1,24 @@ +package com.avsystem.commons +package jetty.rest + +import org.eclipse.jetty.server.Server +import org.scalatest.{BeforeAndAfterAll, Suite} + +trait UsesHttpServer extends BeforeAndAfterAll { this: Suite => + val port: Int = 9090 + val server: Server = new Server(port) + val baseUrl = s"http://localhost:$port" + + protected def setupServer(server: Server): Unit + + override protected def beforeAll(): Unit = { + super.beforeAll() + setupServer(server) + server.start() + } + + override protected def afterAll(): Unit = { + server.stop() + super.afterAll() + } +} diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/ClientMain.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/ClientMain.scala new file mode 100644 index 000000000..377b9b999 --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/ClientMain.scala @@ -0,0 +1,30 @@ +package com.avsystem.commons +package jetty.rest.examples + +import com.avsystem.commons.jetty.rest.RestClient +import org.eclipse.jetty.client.HttpClient + +import scala.concurrent.Await +import scala.concurrent.duration._ + +object ClientMain { + def main(args: Array[String]): Unit = { + val client = new HttpClient + client.start() + + val proxy = RestClient[UserApi](client, "http://localhost:9090/") + + // just for this example, normally it's not recommended + import scala.concurrent.ExecutionContext.Implicits.global + + val result = proxy.createUser("Fred", 1990) + .andThen({ case _ => client.stop() }) + .andThen { + case Success(id) => println(s"User $id created") + case Failure(cause) => cause.printStackTrace() + } + + // just wait until future is complete so that main thread doesn't finish prematurely + Await.result(result, 10.seconds) + } +} diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/ServerMain.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/ServerMain.scala new file mode 100644 index 000000000..d699e4e7f --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/ServerMain.scala @@ -0,0 +1,18 @@ +package com.avsystem.commons +package jetty.rest.examples + +import com.avsystem.commons.jetty.rest.RestHandler +import org.eclipse.jetty.server.Server + +class UserApiImpl extends UserApi { + def createUser(name: String, birthYear: Int): Future[String] = Future.successful(s"$name-ID") +} + +object ServerMain { + def main(args: Array[String]): Unit = { + val server = new Server(9090) + server.setHandler(RestHandler[UserApi](new UserApiImpl)) + server.start() + server.join() + } +} diff --git a/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/UserApi.scala b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/UserApi.scala new file mode 100644 index 000000000..4f928cea2 --- /dev/null +++ b/commons-jetty/src/test/scala/com/avsystem/commons/jetty/rest/examples/UserApi.scala @@ -0,0 +1,10 @@ +package com.avsystem.commons +package jetty.rest.examples + +import com.avsystem.commons.rest._ + +trait UserApi { + /** Returns ID of newly created user */ + def createUser(name: String, birthYear: Int): Future[String] +} +object UserApi extends DefaultRestApiCompanion[UserApi] \ No newline at end of file diff --git a/commons-macros/src/main/scala/com/avsystem/commons/macros/MacroCommons.scala b/commons-macros/src/main/scala/com/avsystem/commons/macros/MacroCommons.scala index b8fdab661..3e4d2e42c 100644 --- a/commons-macros/src/main/scala/com/avsystem/commons/macros/MacroCommons.scala +++ b/commons-macros/src/main/scala/com/avsystem/commons/macros/MacroCommons.scala @@ -35,11 +35,14 @@ trait MacroCommons { bundle => final val NilObj = q"$CollectionPkg.immutable.Nil" final val MapObj = q"$CollectionPkg.immutable.Map" final val MapCls = tq"$CollectionPkg.immutable.Map" + final val TryCls = tq"$ScalaPkg.util.Try" + final val TryObj = q"$ScalaPkg.util.Try" final val MapSym = typeOf[scala.collection.immutable.Map[_, _]].typeSymbol final val FutureSym = typeOf[scala.concurrent.Future[_]].typeSymbol final val OptionClass = definitions.OptionClass final val ImplicitsObj = q"$CommonsPkg.misc.Implicits" final val AnnotationAggregateType = getType(tq"$CommonsPkg.annotation.AnnotationAggregate") + final val DefaultsToNameAT = getType(tq"$CommonsPkg.annotation.defaultsToName") final val SeqCompanionSym = typeOf[scala.collection.Seq.type].termSymbol final lazy val isScalaJs = @@ -68,14 +71,38 @@ trait MacroCommons { bundle => error(msg) } - case class Annot(tree: Tree)(val directSource: Symbol, val aggregate: Option[Annot]) { + class Annot(annotTree: Tree, val subject: Symbol, val directSource: Symbol, val aggregate: Option[Annot]) { def aggregationChain: List[Annot] = aggregate.fold(List.empty[Annot])(a => a :: a.aggregationChain) - def source: Symbol = aggregate.fold(directSource)(_.source) - def tpe: Type = tree.tpe + def aggregationRootSource: Symbol = aggregate.fold(directSource)(_.aggregationRootSource) - def findArg[T: ClassTag](valSym: Symbol, defaultValue: Option[T] = None): T = tree match { + def errorPos: Option[Position] = + List(tree.pos, directSource.pos, aggregationRootSource.pos, subject.pos).find(_ != NoPosition) + + lazy val tree: Tree = annotTree match { + case Apply(constr@Select(New(tpt), termNames.CONSTRUCTOR), args) => + val clsTpe = tpt.tpe + val params = primaryConstructorOf(clsTpe).typeSignature.paramLists.head + + val newArgs = (args zip params) map { + case (arg, param) if param.asTerm.isParamWithDefault && arg.symbol != null && + arg.symbol.isSynthetic && arg.symbol.name.decodedName.toString.contains("$default$") && + findAnnotation(param, DefaultsToNameAT).nonEmpty => + q"${subject.name.decodedName.toString}" + case (arg, _) => arg + } + + treeCopy.Apply(annotTree, constr, newArgs) + case _ => annotTree + } + + def tpe: Type = annotTree.tpe + + def findArg[T: ClassTag](valSym: Symbol): T = + findArg[T](valSym, abort(s"(bug) no default value for ${tree.tpe} parameter ${valSym.name} provided by macro")) + + def findArg[T: ClassTag](valSym: Symbol, whenDefault: => T): T = tree match { case Apply(Select(New(tpt), termNames.CONSTRUCTOR), args) => val clsTpe = tpt.tpe val params = primaryConstructorOf(clsTpe).typeSignature.paramLists.head @@ -88,9 +115,8 @@ trait MacroCommons { bundle => case (param, arg) if param.name == subSym.name => arg match { case Literal(Constant(value: T)) => value case t if param.asTerm.isParamWithDefault && t.symbol.isSynthetic && - t.symbol.name.decodedName.toString.contains("$default$") => - defaultValue.getOrElse( - abort(s"(bug) no default value for $clsTpe parameter ${valSym.name} provided by macro")) + t.symbol.name.decodedName.toString.contains("$default$") => whenDefault + case t if classTag[T] == classTag[Tree] => t.asInstanceOf[T] case _ => abort(s"Expected literal ${classTag[T].runtimeClass} as ${valSym.name} parameter of $clsTpe annotation") } @@ -103,7 +129,8 @@ trait MacroCommons { bundle => if (tpe <:< AnnotationAggregateType) { val argsInliner = new AnnotationArgInliner(tree) val impliedMember = tpe.member(TypeName("Implied")) - impliedMember.annotations.map(a => Annot(argsInliner.transform(a.tree))(impliedMember, Some(this))) + impliedMember.annotations.map(a => + new Annot(argsInliner.transform(a.tree), subject, impliedMember, Some(this))) } else Nil } @@ -135,35 +162,38 @@ trait MacroCommons { bundle => private def maybeWithSuperSymbols(s: Symbol, withSupers: Boolean): Iterator[Symbol] = if (withSupers) withSuperSymbols(s) else Iterator(s) - def allAnnotations(s: Symbol, tpeFilter: Type = typeOf[Any], withInherited: Boolean = true): List[Annot] = - maybeWithSuperSymbols(s, withInherited) - .flatMap(ss => ss.annotations.map(a => Annot(a.tree)(ss, None))) + def allAnnotations(s: Symbol, tpeFilter: Type, withInherited: Boolean = true, fallback: List[Tree] = Nil): List[Annot] = { + val nonFallback = maybeWithSuperSymbols(s, withInherited) + .flatMap(ss => ss.annotations.map(a => new Annot(a.tree, s, ss, None))) + + (nonFallback ++ fallback.iterator.map(t => new Annot(t, s, s, None))) .flatMap(_.withAllAggregated).filter(_.tpe <:< tpeFilter).toList + } - def findAnnotation(s: Symbol, tpe: Type, withInherited: Boolean = true): Option[Annot] = - maybeWithSuperSymbols(s, withInherited).map { ss => - def find(annots: List[Annot]): Option[Annot] = annots match { - case head :: tail => - val fromHead = Some(head).filter(_.tpe <:< tpe).orElse(find(head.aggregated)) - for { - found <- fromHead - ignored <- tail.filter(_.tpe <:< tpe) - } { - val errorPos = List(ignored.tree.pos, ignored.directSource.pos, ss.pos, s.pos) - .find(_ != NoPosition).getOrElse(c.enclosingPosition) - val aggInfo = - if (ignored.aggregate.isEmpty) "" - else ignored.aggregationChain.mkString(" (aggregated by ", " aggregated by", ")") - c.error(errorPos, s"Annotation $ignored$aggInfo is ignored because it's overridden by $found") - } - fromHead orElse find(tail) - case Nil => None - } - find(ss.annotations.map(a => Annot(a.tree)(ss, None))) - }.collectFirst { - case Some(annot) => annot + def findAnnotation(s: Symbol, tpe: Type, withInherited: Boolean = true, fallback: List[Tree] = Nil): Option[Annot] = { + def find(annots: List[Annot]): Option[Annot] = annots match { + case head :: tail => + val fromHead = Some(head).filter(_.tpe <:< tpe).orElse(find(head.aggregated)) + for { + found <- fromHead + ignored <- tail.filter(_.tpe <:< tpe) + } { + val errorPos = ignored.errorPos.getOrElse(c.enclosingPosition) + val aggInfo = + if (ignored.aggregate.isEmpty) "" + else ignored.aggregationChain.mkString(" (aggregated by ", " aggregated by", ")") + c.error(errorPos, s"Annotation $ignored$aggInfo is ignored because it's overridden by $found") + } + fromHead orElse find(tail) + case Nil => None } + maybeWithSuperSymbols(s, withInherited) + .map(ss => find(ss.annotations.map(a => new Annot(a.tree, s, ss, None)))) + .collectFirst { case Some(annot) => annot } + .orElse(find(fallback.map(t => new Annot(t, s, s, None)))) + } + private var companionReplacement = Option.empty[(Symbol, TermName)] def mkMacroGenerated(tpe: Type, tree: => Tree): Tree = { @@ -186,7 +216,7 @@ trait MacroCommons { bundle => // Replace references to companion object being constructed with casted reference to lambda parameter // of function wrapped by `MacroGenerated` class. This is all to workaround overzealous Scala validation of - // self-reference being passed to super constructor parameter. + // self-reference being passed to super constructor parameter (https://github.com/scala/bug/issues/7666) def replaceCompanion(typedTree: Tree): Tree = { val symToCheck = typedTree match { case This(_) => typedTree.symbol.asClass.module @@ -687,6 +717,11 @@ trait MacroCommons { bundle => } } + /** + * @param apply case class constructor or companion object's apply method + * @param unapply companion object'a unapply method or `NoSymbol` for case class with more than 22 fields + * @param params parameters with trees evaluating to default values (or `EmptyTree`s) + */ case class ApplyUnapply(apply: Symbol, unapply: Symbol, params: List[(TermSymbol, Tree)]) def applyUnapplyFor(tpe: Type): Option[ApplyUnapply] = @@ -704,8 +739,8 @@ trait MacroCommons { bundle => } else EmptyTree - def paramsWithDefaults(methodSig: Type) = - methodSig.paramLists.head.zipWithIndex.map({ case (p, i) => (p.asTerm, defaultValueFor(p, i)) }) + def paramsWithDefaults(methodSig: Type): List[(TermSymbol, Tree)] = + methodSig.paramLists.head.zipWithIndex.map { case (p, i) => (p.asTerm, defaultValueFor(p, i)) } // Seq is a weird corner case where technically an apply/unapplySeq pair exists but is recursive val applyUnapplyPairs = diff --git a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/Res.scala b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/Res.scala index cda5c997b..74505d3f7 100644 --- a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/Res.scala +++ b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/Res.scala @@ -5,6 +5,10 @@ import scala.collection.generic.CanBuildFrom import scala.collection.mutable sealed trait Res[+A] { + def isOk: Boolean = this match { + case Ok(_) => true + case _: Fail => false + } def map[B](fun: A => B): Res[B] = this match { case Ok(value) => Ok(fun(value)) case f: Fail => f diff --git a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMacros.scala b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMacros.scala index c357e8614..6a20de8cb 100644 --- a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMacros.scala +++ b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMacros.scala @@ -10,6 +10,7 @@ abstract class RpcMacroCommons(ctx: blackbox.Context) extends AbstractMacroCommo import c.universe._ val RpcPackage = q"$CommonsPkg.rpc" + val RpcUtils = q"$RpcPackage.RpcUtils" val AsRealCls = tq"$RpcPackage.AsReal" val AsRealObj = q"$RpcPackage.AsReal" val AsRawCls = tq"$RpcPackage.AsRaw" @@ -20,9 +21,16 @@ abstract class RpcMacroCommons(ctx: blackbox.Context) extends AbstractMacroCommo val CanBuildFromCls = tq"$CollectionPkg.generic.CanBuildFrom" val ParamPositionObj = q"$RpcPackage.ParamPosition" + val AsRealTpe: Type = getType(tq"$AsRealCls[_,_]") + val AsRawTpe: Type = getType(tq"$AsRawCls[_,_]") val RpcNameAT: Type = getType(tq"$RpcPackage.rpcName") - val RpcNameNameSym: Symbol = RpcNameAT.member(TermName("name")) + val RpcNameArg: Symbol = RpcNameAT.member(TermName("name")) + val RpcNamePrefixAT: Type = getType(tq"$RpcPackage.rpcNamePrefix") + val RpcNamePrefixArg: Symbol = RpcNamePrefixAT.member(TermName("prefix")) val WhenAbsentAT: Type = getType(tq"$CommonsPkg.serialization.whenAbsent[_]") + val TransientDefaultAT: Type = getType(tq"$CommonsPkg.serialization.transientDefault") + val MethodNameAT: Type = getType(tq"$RpcPackage.methodName") + val CompositeAT: Type = getType(tq"$RpcPackage.composite") val RpcArityAT: Type = getType(tq"$RpcPackage.RpcArity") val SingleArityAT: Type = getType(tq"$RpcPackage.single") val OptionalArityAT: Type = getType(tq"$RpcPackage.optional") @@ -30,15 +38,17 @@ abstract class RpcMacroCommons(ctx: blackbox.Context) extends AbstractMacroCommo val RpcEncodingAT: Type = getType(tq"$RpcPackage.RpcEncoding") val VerbatimAT: Type = getType(tq"$RpcPackage.verbatim") val AuxiliaryAT: Type = getType(tq"$RpcPackage.auxiliary") - val MethodTagAT: Type = getType(tq"$RpcPackage.methodTag[_,_]") - val ParamTagAT: Type = getType(tq"$RpcPackage.paramTag[_,_]") + val TriedAT: Type = getType(tq"$RpcPackage.tried") + val MethodTagAT: Type = getType(tq"$RpcPackage.methodTag[_]") + val ParamTagAT: Type = getType(tq"$RpcPackage.paramTag[_]") val TaggedAT: Type = getType(tq"$RpcPackage.tagged[_]") + val WhenUntaggedArg: Symbol = TaggedAT.member(TermName("whenUntagged")) val RpcTagAT: Type = getType(tq"$RpcPackage.RpcTag") val RpcImplicitsSym: Symbol = getType(tq"$RpcPackage.RpcImplicitsProvider").member(TermName("implicits")) val TypedMetadataType: Type = getType(tq"$RpcPackage.TypedMetadata[_]") val MetadataParamStrategyType: Type = getType(tq"$RpcPackage.MetadataParamStrategy") val ReifyAnnotAT: Type = getType(tq"$RpcPackage.reifyAnnot") - val HasAnnotAT: Type = getType(tq"$RpcPackage.hasAnnot[_]") + val IsAnnotatedAT: Type = getType(tq"$RpcPackage.isAnnotated[_]") val InferAT: Type = getType(tq"$RpcPackage.infer") val ReifyNameAT: Type = getType(tq"$RpcPackage.reifyName") val ReifyPositionAT: Type = getType(tq"$RpcPackage.reifyPosition") @@ -65,7 +75,7 @@ abstract class RpcMacroCommons(ctx: blackbox.Context) extends AbstractMacroCommo } def containsInaccessibleThises(tree: Tree): Boolean = tree.exists { - case t@This(_) if !enclosingClasses.contains(t.symbol) => true + case t@This(_) if !t.symbol.isPackageClass && !enclosingClasses.contains(t.symbol) => true case _ => false } } @@ -95,6 +105,7 @@ class RpcMacros(ctx: blackbox.Context) extends RpcMacroCommons(ctx) val raw = RawRpcTrait(weakTypeOf[Raw].dealias) val real = RealRpcTrait(weakTypeOf[Real].dealias) val mapping = RpcMapping(real, raw, forAsRaw = true, forAsReal = false) + mapping.ensureUniqueRpcNames() // must be evaluated before `cachedImplicitDeclarations`, don't inline it into the quasiquote val asRawDef = mapping.asRawImpl @@ -111,6 +122,7 @@ class RpcMacros(ctx: blackbox.Context) extends RpcMacroCommons(ctx) val raw = RawRpcTrait(weakTypeOf[Raw].dealias) val real = RealRpcTrait(weakTypeOf[Real].dealias) val mapping = RpcMapping(real, raw, forAsRaw = true, forAsReal = true) + mapping.ensureUniqueRpcNames() // these two must be evaluated before `cachedImplicitDeclarations`, don't inline them into the quasiquote val asRealDef = mapping.asRealImpl @@ -125,17 +137,11 @@ class RpcMacros(ctx: blackbox.Context) extends RpcMacroCommons(ctx) """ } - def rpcMetadata[M: WeakTypeTag, Real: WeakTypeTag]: Tree = { + def rpcMetadata[Real: WeakTypeTag]: Tree = { val realRpc = RealRpcTrait(weakTypeOf[Real].dealias) - val metadataTpe = weakTypeOf[M] match { // scalac, why do I have to do this? - case TypeRef(pre, sym, Nil) => - internal.typeRef(pre, sym, List(realRpc.tpe)) - case TypeRef(pre, sym, List(TypeRef(NoPrefix, wc, Nil))) if wc.name == typeNames.WILDCARD => - internal.typeRef(pre, sym, List(realRpc.tpe)) - case t => t - } + val metadataTpe = c.macroApplication.tpe.dealias - val constructor = new RpcMetadataConstructor(metadataTpe) + val constructor = new RpcMetadataConstructor(metadataTpe, None) // separate object for cached implicits so that lazy vals are members instead of local variables val depsObj = c.freshName(TermName("deps")) val selfName = c.freshName(TermName("self")) @@ -146,7 +152,7 @@ class RpcMacros(ctx: blackbox.Context) extends RpcMacroCommons(ctx) val lazyMetadataTpe = getType(tq"$comp.Lazy[${realRpc.tpe}]") val lazySelfName = c.freshName(TermName("lazySelf")) registerImplicit(lazyMetadataTpe, lazySelfName) - val tree = constructor.materializeFor(realRpc) + val tree = constructor.materializeFor(realRpc, constructor.methodMappings(realRpc)) q""" object $depsObj { @@ -159,7 +165,7 @@ class RpcMacros(ctx: blackbox.Context) extends RpcMacroCommons(ctx) """ case None => - val tree = constructor.materializeFor(realRpc) + val tree = constructor.materializeFor(realRpc, constructor.methodMappings(realRpc)) q""" object $depsObj { ..$cachedImplicitDeclarations @@ -170,6 +176,62 @@ class RpcMacros(ctx: blackbox.Context) extends RpcMacroCommons(ctx) } } + def macroInstances: Tree = { + val resultTpe = c.macroApplication.tpe + val realTpe = resultTpe.typeArgs.last + val applySig = resultTpe.member(TermName("apply")).typeSignatureIn(resultTpe) + val implicitsTpe = applySig.paramLists.head.head.typeSignature + val instancesTpe = applySig.finalResultType + + if (c.macroApplication.symbol.isImplicit && c.enclosingPosition.source != realTpe.typeSymbol.pos.source) { + abort(s"Implicit materialization of RpcMacroInstances is only allowed in the same file where RPC trait is defined ($realTpe)") + } + + val instTs = instancesTpe.typeSymbol + if (!(instTs.isClass && instTs.isAbstract)) { + abort(s"Expected trait or abstract class type, got $instancesTpe") + } + + val asRawTpe = getType(tq"$AsRawCls[_,$realTpe]") + val asRealTpe = getType(tq"$AsRealCls[_,$realTpe]") + val asRawRealTpe = getType(tq"$AsRawRealCls[_,$realTpe]") + + val impls = instancesTpe.members.iterator.filter(m => m.isAbstract && m.isMethod).map { m => + val sig = m.typeSignatureIn(instancesTpe) + val resultTpe = sig.finalResultType.dealias + if (sig.typeParams.nonEmpty || sig.paramLists.nonEmpty) { + abort(s"Problem with $m: expected non-generic, parameterless method") + } + + val body = + if (resultTpe <:< asRawRealTpe) + q"$AsRawRealObj.materializeForRpc[..${resultTpe.typeArgs}]" + else if (resultTpe <:< asRawTpe) + q"$AsRawObj.materializeForRpc[..${resultTpe.typeArgs}]" + else if (resultTpe <:< asRealTpe) + q"$AsRealObj.materializeForRpc[..${resultTpe.typeArgs}]" + else resultTpe.typeArgs match { + case List(st) if st =:= realTpe => + q"$RpcPackage.RpcMetadata.materializeForRpc[${resultTpe.typeConstructor}, $realTpe]" + case _ => abort(s"Bad result type $resultTpe of $m: " + + s"it must be an AsReal/AsRaw/AsRealRaw or RPC metadata instance for $realTpe") + } + + q"def ${m.name.toTermName} = $body" + }.toList + + val implicitsName = c.freshName(TermName("implicits")) + + q""" + new $resultTpe { + def apply($implicitsName: $implicitsTpe): $instancesTpe = { + import $implicitsName._ + new $instancesTpe { ..$impls; () } + } + } + """ + } + def lazyMetadata(metadata: Tree): Tree = q"${c.prefix}($metadata)" } diff --git a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMappings.scala b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMappings.scala index 16bf49516..a3ea3552f 100644 --- a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMappings.scala +++ b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMappings.scala @@ -10,7 +10,7 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => def collectMethodMappings[R <: RawRpcSymbol with AritySymbol, M]( rawSymbols: List[R], rawShortDesc: String, realMethods: List[RealMethod])( - createMapping: (R, RealMethod) => Res[M]): List[M] = { + createMapping: (R, MatchedMethod) => Res[M]): List[M] = { val failedReals = new ListBuffer[String] def addFailure(realMethod: RealMethod, message: String): Unit = { @@ -21,9 +21,10 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => val result = realMethods.flatMap { realMethod => val methodMappings = rawSymbols.map { rawSymbol => val res = for { - _ <- rawSymbol.matchName(realMethod) - _ <- rawSymbol.matchTag(realMethod) - methodMapping <- createMapping(rawSymbol, realMethod) + fallbackTag <- rawSymbol.matchTag(realMethod) + matchedMethod = MatchedMethod(realMethod, fallbackTag) + _ <- rawSymbol.matchName(matchedMethod) + methodMapping <- createMapping(rawSymbol, matchedMethod) } yield (rawSymbol, methodMapping) res.mapFailure(msg => s"${rawSymbol.shortDescription} ${rawSymbol.nameStr} did not match: $msg") } @@ -46,12 +47,12 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => result } - def collectParamMappings[R <: RawParamLike, M](raws: List[R], rawShortDesc: String, realMethod: RealMethod) + def collectParamMappings[R <: RealParamTarget, M](raws: List[R], rawShortDesc: String, matchedMethod: MatchedMethod) (createMapping: (R, ParamsParser) => Res[M]): Res[List[M]] = { - val parser = new ParamsParser(realMethod) + val parser = new ParamsParser(matchedMethod) Res.traverse(raws)(createMapping(_, parser)).flatMap { result => - if (parser.remaining.isEmpty) Ok(result.reverse) + if (parser.remaining.isEmpty) Ok(result) else { val unmatched = parser.remaining.iterator.map(_.nameStr).mkString(",") Fail(s"no $rawShortDesc(s) were found that would match real parameter(s) $unmatched") @@ -59,112 +60,130 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => } } - class ParamsParser(realMethod: RealMethod) { + class ParamsParser(matchedMethod: MatchedMethod) { import scala.collection.JavaConverters._ private val realParams = new java.util.LinkedList[RealParam] - realParams.addAll(realMethod.realParams.asJava) + realParams.addAll(matchedMethod.real.realParams.asJava) def remaining: Seq[RealParam] = realParams.asScala - def extractSingle[B](raw: RawParamLike, matcher: RealParam => Res[B]): Res[B] = { + def extractSingle[B](raw: RealParamTarget, matcher: MatchedParam => Res[B]): Res[B] = { val it = realParams.listIterator() def loop(): Res[B] = if (it.hasNext) { val real = it.next() - if (raw.matchesTag(real)) { - if (!raw.auxiliary) { - it.remove() - } - matcher(real) - } else loop() - } else Fail(s"${raw.shortDescription} ${raw.nameStr} was not matched by real parameter") + raw.matchTag(real) match { + case Ok(fallbackTag) => + if (!raw.auxiliary) { + it.remove() + } + matcher(MatchedParam(real, fallbackTag, matchedMethod)) + case Fail(_) => loop() + } + } else Fail(s"${raw.shortDescription} ${raw.pathStr} was not matched by real parameter") loop() } - def extractOptional[B](raw: RawParamLike, matcher: RealParam => Res[B]): Option[B] = { + def extractOptional[B](raw: RealParamTarget, matcher: MatchedParam => Res[B]): Option[B] = { val it = realParams.listIterator() def loop(): Option[B] = if (it.hasNext) { val real = it.next() - if (raw.matchesTag(real)) { - val res = matcher(real).toOption - if (!raw.auxiliary) { - res.foreach(_ => it.remove()) - } - res - } else loop() + raw.matchTag(real) match { + case Ok(fallbackTag) => + val res = matcher(MatchedParam(real, fallbackTag, matchedMethod)).toOption + if (!raw.auxiliary) { + res.foreach(_ => it.remove()) + } + res + case Fail(_) => loop() + } } else None loop() } - def extractMulti[B](raw: RawParamLike, matcher: (RealParam, Int) => Res[B], named: Boolean): Res[List[B]] = { - val seenRpcNames = new mutable.HashSet[String] + def extractMulti[B](raw: RealParamTarget, matcher: (MatchedParam, Int) => Res[B], named: Boolean): Res[List[B]] = { val it = realParams.listIterator() def loop(result: ListBuffer[B]): Res[List[B]] = if (it.hasNext) { val real = it.next() - if (raw.matchesTag(real)) { - if (!raw.auxiliary) { - it.remove() - } - matcher(real, result.size) match { - case Ok(b) => - result += b - if (named && !seenRpcNames.add(real.rpcName)) { - realMethod.reportProblem(s"multiple parameters matched to ${raw.shortDescription} ${raw.nameStr} " + - s"have the same @rpcName: ${real.rpcName}") - } - loop(result) - case fail: Fail => - fail - } - } else loop(result) + raw.matchTag(real) match { + case Ok(fallbackTag) => + if (!raw.auxiliary) { + it.remove() + } + matcher(MatchedParam(real, fallbackTag, matchedMethod), result.size) match { + case Ok(b) => + result += b + loop(result) + case fail: Fail => + fail + } + case Fail(_) => loop(result) + } } else Ok(result.result()) loop(new ListBuffer[B]) } } - case class EncodedRealParam(realParam: RealParam, encoding: RpcEncoding) { - def safeName: TermName = realParam.safeName - def rawValueTree: Tree = encoding.applyAsRaw(realParam.safeName) - def localValueDecl(body: Tree): Tree = realParam.localValueDecl(body) + case class EncodedRealParam(matchedParam: MatchedParam, encoding: RpcEncoding) { + def realParam: RealParam = matchedParam.real + def rpcName: String = matchedParam.rpcName + def safeName: TermName = matchedParam.real.safeName + def rawValueTree: Tree = encoding.applyAsRaw(matchedParam.real.safeName) + def localValueDecl(body: Tree): Tree = matchedParam.real.localValueDecl(body) } object EncodedRealParam { - def create(rawParam: RawParam, realParam: RealParam): Res[EncodedRealParam] = - RpcEncoding.forParam(rawParam, realParam).map(EncodedRealParam(realParam, _)) + def create(rawParam: RawValueParam, matchedParam: MatchedParam): Res[EncodedRealParam] = + RpcEncoding.forParam(rawParam, matchedParam.real).map(EncodedRealParam(matchedParam, _)) } sealed trait ParamMapping { - def rawParam: RawParam + def rawParam: RawValueParam def rawValueTree: Tree def realDecls: List[Tree] + + def allMatchedParams: List[MatchedParam] = this match { + case ParamMapping.Single(_, erp) => List(erp.matchedParam) + case ParamMapping.Optional(_, erpOpt) => erpOpt.map(_.matchedParam).toList + case multi: ParamMapping.Multi => multi.reals.map(_.matchedParam) + } } object ParamMapping { - case class Single(rawParam: RawParam, realParam: EncodedRealParam) extends ParamMapping { + case class Single(rawParam: RawValueParam, realParam: EncodedRealParam) extends ParamMapping { def rawValueTree: Tree = realParam.rawValueTree def realDecls: List[Tree] = - List(realParam.localValueDecl(realParam.encoding.applyAsReal(rawParam.safeName))) + List(realParam.localValueDecl(realParam.encoding.applyAsReal(rawParam.safePath))) } - case class Optional(rawParam: RawParam, wrapped: Option[EncodedRealParam]) extends ParamMapping { - def rawValueTree: Tree = - rawParam.mkOptional(wrapped.map(_.rawValueTree)) + case class Optional(rawParam: RawValueParam, wrapped: Option[EncodedRealParam]) extends ParamMapping { + def rawValueTree: Tree = { + val noneRes: Tree = q"${rawParam.optionLike}.none" + wrapped.fold(noneRes) { erp => + val baseRes = q"${rawParam.optionLike}.some(${erp.rawValueTree})" + if (erp.matchedParam.transientDefault) + q"if(${erp.safeName} != ${erp.matchedParam.transientValueTree}) $baseRes else $noneRes" + else baseRes + } + } def realDecls: List[Tree] = wrapped.toList.map { erp => - val defaultValueTree = erp.realParam.defaultValueTree + val defaultValueTree = erp.matchedParam.fallbackValueTree erp.realParam.localValueDecl(erp.encoding.foldWithAsReal( - rawParam.optionLike, rawParam.safeName, defaultValueTree)) + rawParam.optionLike, rawParam.safePath, defaultValueTree)) } } - abstract class ListedMulti extends ParamMapping { - protected def reals: List[EncodedRealParam] + abstract class Multi extends ParamMapping { + def reals: List[EncodedRealParam] + } + abstract class ListedMulti extends Multi { def rawValueTree: Tree = rawParam.mkMulti(reals.map(_.rawValueTree)) } - case class IterableMulti(rawParam: RawParam, reals: List[EncodedRealParam]) extends ListedMulti { - def realDecls: List[Tree] = { + case class IterableMulti(rawParam: RawValueParam, reals: List[EncodedRealParam]) extends ListedMulti { + def realDecls: List[Tree] = if (reals.isEmpty) Nil else { val itName = c.freshName(TermName("it")) - val itDecl = q"val $itName = ${rawParam.safeName}.iterator" + val itDecl = q"val $itName = ${rawParam.safePath}.iterator" itDecl :: reals.map { erp => val rp = erp.realParam if (rp.symbol.asTerm.isByNameParam) { @@ -172,31 +191,43 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => s"${rawParam.cannotMapClue}: by-name real parameters cannot be extracted from @multi raw parameters") } erp.localValueDecl( - q"if($itName.hasNext) ${erp.encoding.applyAsReal(q"$itName.next()")} else ${rp.defaultValueTree}") + q"if($itName.hasNext) ${erp.encoding.applyAsReal(q"$itName.next()")} else ${erp.matchedParam.fallbackValueTree}") } } } - case class IndexedMulti(rawParam: RawParam, reals: List[EncodedRealParam]) extends ListedMulti { + case class IndexedMulti(rawParam: RawValueParam, reals: List[EncodedRealParam]) extends ListedMulti { def realDecls: List[Tree] = { reals.zipWithIndex.map { case (erp, idx) => - val rp = erp.realParam - erp.realParam.localValueDecl( + erp.localValueDecl( q""" - ${erp.encoding.andThenAsReal(rawParam.safeName)} - .applyOrElse($idx, (_: $IntCls) => ${rp.defaultValueTree}) + ${erp.encoding.andThenAsReal(rawParam.safePath)} + .applyOrElse($idx, (_: $IntCls) => ${erp.matchedParam.fallbackValueTree}) """) } } } - case class NamedMulti(rawParam: RawParam, reals: List[EncodedRealParam]) extends ParamMapping { + case class NamedMulti(rawParam: RawValueParam, reals: List[EncodedRealParam]) extends Multi { def rawValueTree: Tree = - rawParam.mkMulti(reals.map(erp => q"(${erp.realParam.rpcName}, ${erp.rawValueTree})")) + if (reals.isEmpty) q"$RpcUtils.createEmpty(${rawParam.canBuildFrom})" else { + val builderName = c.freshName(TermName("builder")) + val addStatements = reals.map { erp => + val baseStat = q"$builderName += ((${erp.rpcName}, ${erp.rawValueTree}))" + if (erp.matchedParam.transientDefault) + q"if(${erp.safeName} != ${erp.matchedParam.transientValueTree}) $baseStat" + else baseStat + } + q""" + val $builderName = $RpcUtils.createBuilder(${rawParam.canBuildFrom}, ${reals.size}) + ..$addStatements + $builderName.result() + """ + } def realDecls: List[Tree] = reals.map { erp => - erp.realParam.localValueDecl( + erp.localValueDecl( q""" - ${erp.encoding.andThenAsReal(rawParam.safeName)} - .applyOrElse(${erp.realParam.rpcName}, (_: $StringCls) => ${erp.realParam.defaultValueTree}) + ${erp.encoding.andThenAsReal(rawParam.safePath)} + .applyOrElse(${erp.rpcName}, (_: $StringCls) => ${erp.matchedParam.fallbackValueTree}) """) } } @@ -208,12 +239,12 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => def applyAsRaw[T: Liftable](arg: T): Tree = q"$asRaw.asRaw($arg)" def applyAsReal[T: Liftable](arg: T): Tree = q"$asReal.asReal($arg)" - def foldWithAsReal(optionLike: TermName, opt: TermName, default: Tree): Tree = + def foldWithAsReal[T: Liftable](optionLike: TermName, opt: T, default: Tree): Tree = q"$optionLike.fold($opt, $default)($asReal.asReal(_))" - def andThenAsReal(func: TermName): Tree = q"$func.andThen($asReal.asReal(_))" + def andThenAsReal[T: Liftable](func: T): Tree = q"$func.andThen($asReal.asReal(_))" } object RpcEncoding { - def forParam(rawParam: RawParam, realParam: RealParam): Res[RpcEncoding] = { + def forParam(rawParam: RawValueParam, realParam: RealParam): Res[RpcEncoding] = { val encArgType = rawParam.arity.collectedType if (rawParam.verbatim) { if (realParam.actualType =:= encArgType) @@ -231,9 +262,9 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => def asReal = q"$AsRealObj.identity[$tpe]" override def applyAsRaw[T: Liftable](arg: T): Tree = q"$arg" override def applyAsReal[T: Liftable](arg: T): Tree = q"$arg" - override def foldWithAsReal(optionLike: TermName, opt: TermName, default: Tree): Tree = + override def foldWithAsReal[T: Liftable](optionLike: TermName, opt: T, default: Tree): Tree = q"$optionLike.getOrElse($opt, $default)" - override def andThenAsReal(func: TermName): Tree = q"$func" + override def andThenAsReal[T: Liftable](func: T): Tree = q"$func" } case class RealRawEncoding(realType: Type, rawType: Type, clueWithPos: Option[(String, Position)]) extends RpcEncoding { @@ -252,30 +283,52 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => } } - case class MethodMapping(realMethod: RealMethod, rawMethod: RawMethod, - paramMappings: List[ParamMapping], resultEncoding: RpcEncoding) { + case class MethodMapping(matchedMethod: MatchedMethod, rawMethod: RawMethod, + paramMappingList: List[ParamMapping], resultEncoding: RpcEncoding) { - def realImpl: Tree = { - val rpcNameParamDecl: Option[Tree] = rawMethod.arity match { - case RpcMethodArity.Multi(rpcNameParam) => - Some(q"val ${rpcNameParam.safeName} = ${realMethod.rpcName}") - case RpcMethodArity.Single | RpcMethodArity.Optional => - None - } + def realMethod: RealMethod = matchedMethod.real + def rpcName: String = matchedMethod.rpcName + + val paramMappings: Map[RawValueParam, ParamMapping] = + paramMappingList.iterator.map(m => (m.rawParam, m)).toMap + + def ensureUniqueRpcNames(): Unit = + paramMappings.valuesIterator.filterNot(_.rawParam.auxiliary).toList + .flatMap(_.allMatchedParams).groupBy(_.rpcName) + .foreach { + case (rpcName, head :: tail) if tail.nonEmpty => + head.real.reportProblem(s"it has the same RPC name ($rpcName) as ${tail.size} other parameters") + case _ => + } + + private def rawValueTree(rawParam: RawParam): Tree = rawParam match { + case _: MethodNameParam => q"$rpcName" + case rvp: RawValueParam => paramMappings(rvp).rawValueTree + case crp: CompositeRawParam => + q""" + ..${crp.paramLists.flatten.map(p => p.localValueDecl(rawValueTree(p)))} + new ${crp.actualType}(...${crp.paramLists.map(_.map(_.safeName))}) + """ + } + + private def maybeTry(tree: Tree): Tree = + if (rawMethod.tried) q"$TryObj($tree)" else tree + private def maybeUntry(tree: Tree): Tree = + if (rawMethod.tried) q"$tree.get" else tree + + def realImpl: Tree = q""" def ${realMethod.name}(...${realMethod.paramDecls}): ${realMethod.resultType} = { - ..${rpcNameParamDecl.toList} - ..${paramMappings.map(pm => pm.rawParam.localValueDecl(pm.rawValueTree))} - ${resultEncoding.applyAsReal(q"${rawMethod.owner.safeName}.${rawMethod.name}(...${rawMethod.argLists})")} + ..${rawMethod.rawParams.map(rp => rp.localValueDecl(rawValueTree(rp)))} + ${maybeUntry(resultEncoding.applyAsReal(q"${rawMethod.owner.safeName}.${rawMethod.name}(...${rawMethod.argLists})"))} } """ - } def rawCaseImpl: Tree = q""" - ..${paramMappings.filterNot(_.rawParam.auxiliary).flatMap(_.realDecls)} - ${resultEncoding.applyAsRaw(q"${realMethod.owner.safeName}.${realMethod.name}(...${realMethod.argLists})")} + ..${paramMappings.values.filterNot(_.rawParam.auxiliary).flatMap(_.realDecls)} + ${resultEncoding.applyAsRaw(maybeTry(q"${realMethod.owner.safeName}.${realMethod.name}(...${realMethod.argLists})"))} """ } @@ -290,8 +343,10 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => } registerCompanionImplicits(raw.tpe) - private def extractMapping(rawParam: RawParam, parser: ParamsParser): Res[ParamMapping] = { - def createErp(realParam: RealParam, index: Int): Res[EncodedRealParam] = EncodedRealParam.create(rawParam, realParam) + private def extractMapping(rawParam: RawValueParam, parser: ParamsParser): Res[ParamMapping] = { + def createErp(matchedParam: MatchedParam, index: Int): Res[EncodedRealParam] = + EncodedRealParam.create(rawParam, matchedParam) + rawParam.arity match { case _: RpcParamArity.Single => parser.extractSingle(rawParam, createErp(_, 0)).map(ParamMapping.Single(rawParam, _)) @@ -306,31 +361,44 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => } } - private def mappingRes(rawMethod: RawMethod, realMethod: RealMethod): Res[MethodMapping] = { + private def mappingRes(rawMethod: RawMethod, matchedMethod: MatchedMethod): Res[MethodMapping] = { + val realMethod = matchedMethod.real + val realResultType = + if (rawMethod.tried) getType(tq"$TryCls[${realMethod.resultType}]") else realMethod.resultType def resultEncoding: Res[RpcEncoding] = if (rawMethod.verbatimResult) { - if (rawMethod.resultType =:= realMethod.resultType) + if (rawMethod.resultType =:= realResultType) Ok(RpcEncoding.Verbatim(rawMethod.resultType)) else - Fail(s"real result type ${realMethod.resultType} does not match raw result type ${rawMethod.resultType}") + Fail(s"real result type $realResultType does not match raw result type ${rawMethod.resultType}") } else { - val e = RpcEncoding.RealRawEncoding(realMethod.resultType, rawMethod.resultType, None) + val e = RpcEncoding.RealRawEncoding(realResultType, rawMethod.resultType, None) if ((!forAsRaw || e.asRawName != termNames.EMPTY) && (!forAsReal || e.asRealName != termNames.EMPTY)) Ok(e) else Fail(s"no encoding/decoding found between real result type " + - s"${realMethod.resultType} and raw result type ${rawMethod.resultType}") + s"$realResultType and raw result type ${rawMethod.resultType}") } for { resultConv <- resultEncoding - paramMappings <- collectParamMappings(rawMethod.rawParams.getOrElse(Nil), "raw parameter", realMethod)(extractMapping) - } yield MethodMapping(realMethod, rawMethod, paramMappings, resultConv) + paramMappings <- collectParamMappings(rawMethod.allValueParams, "raw parameter", matchedMethod)(extractMapping) + } yield MethodMapping(matchedMethod, rawMethod, paramMappings, resultConv) } lazy val methodMappings: List[MethodMapping] = collectMethodMappings(raw.rawMethods, "raw methods", real.realMethods)(mappingRes) + def ensureUniqueRpcNames(): Unit = + methodMappings.groupBy(_.matchedMethod.rpcName).foreach { + case (_, single :: Nil) => + single.ensureUniqueRpcNames() + case (rpcName, head :: tail) => + head.realMethod.reportProblem(s"it has the same RPC name ($rpcName) as ${tail.size} other methods - " + + s"if you want to overload RPC methods, disambiguate them with @rpcName") + case _ => + } + def asRealImpl: Tree = q""" def asReal(${raw.safeName}: ${raw.tpe}): ${real.tpe} = new ${real.tpe} { @@ -341,14 +409,8 @@ trait RpcMappings { this: RpcMacroCommons with RpcSymbols => def asRawImpl: Tree = { val caseImpls = raw.rawMethods.iterator.map(rm => (rm, new mutable.LinkedHashMap[String, Tree])).toMap methodMappings.foreach { mapping => - val prevCaseDef = caseImpls(mapping.rawMethod).put(mapping.realMethod.rpcName, mapping.rawCaseImpl) - if (prevCaseDef.nonEmpty) { - mapping.realMethod.reportProblem( - s"multiple RPCs named ${mapping.realMethod.rpcName} map to raw method ${mapping.rawMethod.nameStr}. " + - "If you want to overload RPCs, disambiguate them with @rpcName annotation") - } + caseImpls(mapping.rawMethod).put(mapping.rpcName, mapping.rawCaseImpl) } - val rawMethodImpls = raw.rawMethods.map(m => m.rawImpl(caseImpls(m).toList)) q""" diff --git a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMetadatas.scala b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMetadatas.scala index fd959640f..5c1d2a466 100644 --- a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMetadatas.scala +++ b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcMetadatas.scala @@ -35,6 +35,30 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => def description = s"$shortDescription $nameStr of ${owner.description}" } + sealed abstract class CompositeMetadataParam[Real <: RealRpcSymbol]( + owner: MetadataConstructor[Real], symbol: Symbol) extends MetadataParam[Real](owner, symbol) { + val constructor: MetadataConstructor[Real] + + override def description: String = s"${super.description} at ${owner.description}" + } + + class RpcCompositeParam(override val owner: RpcMetadataConstructor, symbol: Symbol) + extends CompositeMetadataParam[RealRpcTrait](owner, symbol) { + val constructor: RpcMetadataConstructor = new RpcMetadataConstructor(actualType, Some(this)) + } + + class MethodCompositeParam(override val owner: MethodMetadataConstructor, symbol: Symbol) + extends CompositeMetadataParam[RealMethod](owner, symbol) { + val constructor: MethodMetadataConstructor = new MethodMetadataConstructor(actualType, Right(this)) + + def pathStr: String = owner.atParam.fold(_ => nameStr, cp => s"${cp.pathStr}.$nameStr") + } + + class ParamCompositeParam(override val owner: ParamMetadataConstructor, symbol: Symbol) + extends CompositeMetadataParam[RealParam](owner, symbol) { + val constructor: ParamMetadataConstructor = new ParamMetadataConstructor(actualType, Right(this), owner.indexInRaw) + } + class MethodMetadataParam(owner: RpcMetadataConstructor, symbol: Symbol) extends MetadataParam[RealRpcTrait](owner, symbol) with RawRpcSymbol with ArityParam { @@ -42,8 +66,8 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => def allowNamedMulti: Boolean = true def allowListedMulti: Boolean = false - def baseTag: Type = owner.baseMethodTag - def defaultTag: Type = owner.defaultMethodTag + def baseTagTpe: Type = owner.baseMethodTag + def fallbackTag: FallbackTag = owner.fallbackMethodTag val verbatimResult: Boolean = annot(RpcEncodingAT).map(_.tpe <:< VerbatimAT).getOrElse(arity.verbatimByDefault) @@ -52,22 +76,25 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => reportProblem(s"method metadata type must be a subtype TypedMetadata[_]") } - val List(baseParamTag, defaultParamTag) = + val (baseParamTag, fallbackParamTag) = annot(ParamTagAT).orElse(findAnnotation(arity.collectedType.typeSymbol, ParamTagAT)) - .map(_.tpe.baseType(ParamTagAT.typeSymbol).typeArgs) - .getOrElse(List(owner.baseParamTag, owner.defaultParamTag)) - - def mappingFor(realMethod: RealMethod): Res[MethodMetadataMapping] = for { - mdType <- actualMetadataType(arity.collectedType, realMethod, verbatimResult) - tree <- new MethodMetadataConstructor(mdType, this).tryMaterializeFor(realMethod) - } yield MethodMetadataMapping(realMethod, this, tree) + .map(tagSpec).getOrElse(owner.baseParamTag, owner.fallbackParamTag) + + def mappingFor(matchedMethod: MatchedMethod): Res[MethodMetadataMapping] = for { + mdType <- actualMetadataType(arity.collectedType, matchedMethod.real, verbatimResult) + constructor = new MethodMetadataConstructor(mdType, Left(this)) + paramMappings <- constructor.paramMappings(matchedMethod) + tree <- constructor.tryMaterializeFor(matchedMethod, paramMappings) + } yield MethodMetadataMapping(matchedMethod, this, tree) } class ParamMetadataParam(owner: MethodMetadataConstructor, symbol: Symbol) - extends MetadataParam[RealMethod](owner, symbol) with RawParamLike { + extends MetadataParam[RealMethod](owner, symbol) with RealParamTarget { + + def pathStr: String = owner.atParam.fold(_ => nameStr, cp => s"${cp.pathStr}.$nameStr") - def baseTag: Type = owner.ownerParam.baseParamTag - def defaultTag: Type = owner.ownerParam.defaultParamTag + def baseTagTpe: Type = owner.containingMethodParam.baseParamTag + def fallbackTag: FallbackTag = owner.containingMethodParam.fallbackParamTag def cannotMapClue = s"cannot map it to $shortDescription $nameStr of ${owner.ownerType}" @@ -75,10 +102,11 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => reportProblem(s"type ${arity.collectedType} is not a subtype of TypedMetadata[_]") } - private def metadataTree(realParam: RealParam, indexInRaw: Int): Res[Tree] = { + private def metadataTree(matchedParam: MatchedParam, indexInRaw: Int): Res[Tree] = { + val realParam = matchedParam.real val result = for { mdType <- actualMetadataType(arity.collectedType, realParam, verbatim) - tree <- new ParamMetadataConstructor(mdType, this, indexInRaw).tryMaterializeFor(realParam) + tree <- new ParamMetadataConstructor(mdType, Left(this), indexInRaw).tryMaterializeFor(matchedParam) } yield tree result.mapFailure(msg => s"${realParam.problemStr}: $cannotMapClue: $msg") } @@ -89,7 +117,8 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => case _: RpcParamArity.Optional => Ok(mkOptional(parser.extractOptional(this, metadataTree(_, 0)))) case RpcParamArity.Multi(_, true) => - parser.extractMulti(this, (rp, i) => metadataTree(rp, i).map(t => q"(${rp.rpcName}, $t)"), named = true).map(mkMulti(_)) + parser.extractMulti(this, (mp, i) => metadataTree(mp, i) + .map(t => q"(${mp.rpcName}, $t)"), named = true).map(mkMulti(_)) case _: RpcParamArity.Multi => parser.extractMulti(this, metadataTree, named = false).map(mkMulti(_)) } @@ -101,8 +130,8 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => sealed abstract class MetadataConstructor[Real <: RealRpcSymbol](val symbol: Symbol) extends RpcMethod { def ownerType: Type - override def annot(tpe: Type): Option[Annot] = - super.annot(tpe) orElse { + def annot(tpe: Type): Option[Annot] = + findAnnotation(symbol, tpe) orElse { // fallback to annotations on the class itself if (symbol.asMethod.isConstructor) findAnnotation(ownerType.typeSymbol, tpe) @@ -116,26 +145,24 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => case t if t <:< InferAT => new ImplicitParam(this, paramSym) case t if t <:< ReifyAnnotAT => new ReifiedAnnotParam(this, paramSym) case t if t <:< ReifyNameAT => - val useRpcName = annot.findArg[Boolean](ReifyNameAT.member(TermName("rpcName")), Some(false)) + val useRpcName = annot.findArg[Boolean](ReifyNameAT.member(TermName("rpcName")), false) new ReifiedNameParam(this, paramSym, useRpcName) - case t if t <:< HasAnnotAT => - new HasAnnotParam(this, paramSym, t.typeArgs.head) + case t if t <:< IsAnnotatedAT => + new IsAnnotatedParam(this, paramSym, t.typeArgs.head) case t => reportProblem(s"metadata param strategy $t is not allowed here") } + def createCompositeParam(paramSym: Symbol): CompositeMetadataParam[Real] def createDefaultParam(paramSym: Symbol): MetadataParam[Real] lazy val paramLists: List[List[MetadataParam[Real]]] = symbol.typeSignatureIn(ownerType).paramLists.map(_.map { ps => - findAnnotation(ps, MetadataParamStrategyType).map(createDirectParam(ps, _)) + if (findAnnotation(ps, CompositeAT).nonEmpty) + createCompositeParam(ps) + else findAnnotation(ps, MetadataParamStrategyType).map(createDirectParam(ps, _)) .getOrElse(if (ps.isImplicit) new ImplicitParam(this, ps) else createDefaultParam(ps)) }) - lazy val plainParams: List[DirectMetadataParam[Real]] = - paramLists.flatten.collect { - case dmp: DirectMetadataParam[Real] => dmp - } - def constructorCall(argDecls: List[Tree]): Tree = q""" ..$argDecls @@ -143,47 +170,45 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => """ } - case class MethodMetadataMapping(realMethod: RealMethod, mdParam: MethodMetadataParam, tree: Tree) + case class MethodMetadataMapping(matchedMethod: MatchedMethod, mdParam: MethodMetadataParam, tree: Tree) - class RpcMetadataConstructor(val ownerType: Type) - extends MetadataConstructor[RealRpcTrait](primaryConstructor(ownerType, None)) with RawRpcSymbol { + class RpcMetadataConstructor(val ownerType: Type, val atParam: Option[RpcCompositeParam]) + extends MetadataConstructor[RealRpcTrait](primaryConstructor(ownerType, atParam)) with RawRpcSymbol { - def baseTag: Type = typeOf[Nothing] - def defaultTag: Type = typeOf[Nothing] + def baseTagTpe: Type = NothingTpe + def fallbackTag: FallbackTag = FallbackTag.Empty - val List(baseMethodTag, defaultMethodTag) = - annot(MethodTagAT) - .map(_.tpe.baseType(MethodTagAT.typeSymbol).typeArgs) - .getOrElse(List(NothingTpe, NothingTpe)) + override def annot(tpe: Type): Option[Annot] = + super[MetadataConstructor].annot(tpe) - val List(baseParamTag, defaultParamTag) = - annot(ParamTagAT) - .map(_.tpe.baseType(ParamTagAT.typeSymbol).typeArgs) - .getOrElse(List(NothingTpe, NothingTpe)) + val (baseMethodTag, fallbackMethodTag) = + annot(MethodTagAT).map(tagSpec).getOrElse((NothingTpe, FallbackTag.Empty)) + val (baseParamTag, fallbackParamTag) = + annot(ParamTagAT).map(tagSpec).getOrElse((NothingTpe, FallbackTag.Empty)) - lazy val methodMdParams: List[MethodMetadataParam] = - paramLists.flatten.collect({ case mmp: MethodMetadataParam => mmp }) + lazy val methodMdParams: List[MethodMetadataParam] = paramLists.flatten.flatMap { + case mmp: MethodMetadataParam => List(mmp) + case rcp: RpcCompositeParam => rcp.constructor.methodMdParams + case _ => Nil + } - def createDefaultParam(paramSym: Symbol): MetadataParam[RealRpcTrait] = + def createDefaultParam(paramSym: Symbol): MethodMetadataParam = new MethodMetadataParam(this, paramSym) - def materializeFor(rpc: RealRpcTrait): Tree = { - val allMappings = collectMethodMappings(methodMdParams, "metadata parameters", rpc.realMethods)(_.mappingFor(_)) + def createCompositeParam(paramSym: Symbol): RpcCompositeParam = + new RpcCompositeParam(this, paramSym) - val mappingsByParam = allMappings.groupBy(_.mdParam) - mappingsByParam.foreach { case (mmp, mappings) => - mappings.groupBy(_.realMethod.rpcName).foreach { - case (rpcName, MethodMetadataMapping(realMethod, _, _) :: tail) if tail.nonEmpty => - realMethod.reportProblem(s"multiple RPCs named $rpcName map to metadata parameter ${mmp.nameStr}. " + - s"If you want to overload RPCs, disambiguate them with @rpcName annotation") - case _ => - } - } + def methodMappings(rpc: RealRpcTrait): Map[MethodMetadataParam, List[MethodMetadataMapping]] = + collectMethodMappings(methodMdParams, "metadata parameters", rpc.realMethods)(_.mappingFor(_)).groupBy(_.mdParam) + def materializeFor(rpc: RealRpcTrait, methodMappings: Map[MethodMetadataParam, List[MethodMetadataMapping]]): Tree = { val argDecls = paramLists.flatten.map { - case dmp: DirectMetadataParam[RealRpcTrait] => dmp.localValueDecl(dmp.materializeFor(rpc)) + case rcp: RpcCompositeParam => + rcp.localValueDecl(rcp.constructor.materializeFor(rpc, methodMappings)) + case dmp: DirectMetadataParam[RealRpcTrait] => + dmp.localValueDecl(dmp.materializeFor(MatchedRpcTrait(rpc))) case mmp: MethodMetadataParam => mmp.localValueDecl { - val mappings = mappingsByParam.getOrElse(mmp, Nil) + val mappings = methodMappings.getOrElse(mmp, Nil) mmp.arity match { case RpcParamArity.Single(_) => mappings match { case Nil => abort(s"no real method found that would match ${mmp.description}") @@ -196,7 +221,7 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => case _ => abort(s"multiple real methods match ${mmp.description}") } case RpcParamArity.Multi(_, _) => - mmp.mkMulti(mappings.map(m => q"(${m.realMethod.rpcName}, ${m.tree})")) + mmp.mkMulti(mappings.map(m => q"(${m.matchedMethod.rpcName}, ${m.tree})")) } } } @@ -204,33 +229,48 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => } } - class MethodMetadataConstructor(val ownerType: Type, val ownerParam: MethodMetadataParam) - extends MetadataConstructor[RealMethod](primaryConstructor(ownerType, Some(ownerParam))) { + class MethodMetadataConstructor( + val ownerType: Type, + val atParam: Either[MethodMetadataParam, MethodCompositeParam] + ) extends MetadataConstructor[RealMethod]( + primaryConstructor(ownerType, Some(atParam.fold[RpcSymbol](identity, identity)))) { - lazy val paramMdParams: List[ParamMetadataParam] = - paramLists.flatten.collect({ case pmp: ParamMetadataParam => pmp }) + val containingMethodParam: MethodMetadataParam = + atParam.fold(identity, _.owner.containingMethodParam) - def createDefaultParam(paramSym: Symbol): MetadataParam[RealMethod] = + lazy val paramMdParams: List[ParamMetadataParam] = paramLists.flatten.flatMap { + case pmp: ParamMetadataParam => List(pmp) + case mcp: MethodCompositeParam => mcp.constructor.paramMdParams + case _ => Nil + } + + def createDefaultParam(paramSym: Symbol): ParamMetadataParam = new ParamMetadataParam(this, paramSym) - def tryMaterializeFor(realMethod: RealMethod): Res[Tree] = - for { - paramMappings <- collectParamMappings(paramMdParams, "metadata parameter", realMethod)( - (param, parser) => param.metadataFor(parser).map(t => (param, t))).map(_.toMap) - argDecls <- Res.traverse(paramLists.flatten) { - case dmp: DirectMetadataParam[RealMethod] => - dmp.tryMaterializeFor(realMethod).map(dmp.localValueDecl) - case pmp: ParamMetadataParam => - Ok(pmp.localValueDecl(paramMappings(pmp))) - } - } yield constructorCall(argDecls) + def createCompositeParam(paramSym: Symbol): MethodCompositeParam = + new MethodCompositeParam(this, paramSym) + + def paramMappings(matchedMethod: MatchedMethod): Res[Map[ParamMetadataParam, Tree]] = + collectParamMappings(paramMdParams, "metadata parameter", matchedMethod)( + (param, parser) => param.metadataFor(parser).map(t => (param, t))).map(_.toMap) + + def tryMaterializeFor(matchedMethod: MatchedMethod, paramMappings: Map[ParamMetadataParam, Tree]): Res[Tree] = + Res.traverse(paramLists.flatten) { + case cmp: MethodCompositeParam => + cmp.constructor.tryMaterializeFor(matchedMethod, paramMappings).map(cmp.localValueDecl) + case dmp: DirectMetadataParam[RealMethod] => + dmp.tryMaterializeFor(matchedMethod).map(dmp.localValueDecl) + case pmp: ParamMetadataParam => + Ok(pmp.localValueDecl(paramMappings(pmp))) + }.map(constructorCall) } - class ParamMetadataConstructor(val ownerType: Type, val ownerParam: ParamMetadataParam, val indexInRaw: Int) - extends MetadataConstructor[RealParam](primaryConstructor(ownerType, Some(ownerParam))) { - - override def description: String = - s"${super.description} at ${ownerParam.description}" + class ParamMetadataConstructor( + val ownerType: Type, + val atParam: Either[ParamMetadataParam, ParamCompositeParam], + val indexInRaw: Int + ) extends MetadataConstructor[RealParam]( + primaryConstructor(ownerType, Some(atParam.fold[RpcSymbol](identity, identity)))) { override def createDirectParam(paramSym: Symbol, annot: Annot): DirectMetadataParam[RealParam] = annot.tpe match { @@ -239,37 +279,42 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => case _ => super.createDirectParam(paramSym, annot) } - def createDefaultParam(paramSym: Symbol): MetadataParam[RealParam] = + def createDefaultParam(paramSym: Symbol): UnknownParam[RealParam] = new UnknownParam(this, paramSym) - def materializeFor(param: RealParam): Tree = - constructorCall(plainParams.map(p => p.localValueDecl(p.materializeFor(param)))) + def createCompositeParam(paramSym: Symbol): ParamCompositeParam = + new ParamCompositeParam(this, paramSym) - def tryMaterializeFor(param: RealParam): Res[Tree] = - Res.traverse(plainParams)(p => p.tryMaterializeFor(param).map(p.localValueDecl)).map(constructorCall) + def tryMaterializeFor(matchedParam: MatchedParam): Res[Tree] = + Res.traverse(paramLists.flatten) { + case pcp: ParamCompositeParam => + pcp.constructor.tryMaterializeFor(matchedParam).map(pcp.localValueDecl) + case dmp: DirectMetadataParam[RealParam] => + dmp.tryMaterializeFor(matchedParam).map(dmp.localValueDecl) + }.map(constructorCall) } sealed abstract class DirectMetadataParam[Real <: RealRpcSymbol](owner: MetadataConstructor[Real], symbol: Symbol) extends MetadataParam[Real](owner, symbol) { - def materializeFor(rpcSym: Real): Tree - def tryMaterializeFor(rpcSym: Real): Res[Tree] + def materializeFor(matchedSymbol: MatchedRealSymbol[Real]): Tree + def tryMaterializeFor(matchedSymbol: MatchedRealSymbol[Real]): Res[Tree] } class ImplicitParam[Real <: RealRpcSymbol](owner: MetadataConstructor[Real], symbol: Symbol) extends DirectMetadataParam[Real](owner, symbol) { - val checked: Boolean = annot(CheckedAT).nonEmpty + val checked: Boolean = findAnnotation(symbol, CheckedAT).nonEmpty - def materializeFor(rpcSym: Real): Tree = + def materializeFor(matchedSymbol: MatchedRealSymbol[Real]): Tree = q"${infer(actualType)}" - def tryMaterializeFor(rpcSym: Real): Res[Tree] = + def tryMaterializeFor(matchedSymbol: MatchedRealSymbol[Real]): Res[Tree] = if (checked) tryInferCachedImplicit(actualType).map(n => Ok(q"$n")) - .getOrElse(Fail(s"no implicit value $actualType for parameter $description could be found")) + .getOrElse(Fail(s"no implicit value $actualType for $description could be found")) else - Ok(materializeFor(rpcSym)) + Ok(materializeFor(matchedSymbol)) } class ReifiedAnnotParam[Real <: RealRpcSymbol](owner: MetadataConstructor[Real], symbol: Symbol) @@ -283,38 +328,44 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => reportProblem(s"${arity.collectedType} is not a subtype of StaticAnnotation") } - def validated(annot: Annot): Annot = { - if (containsInaccessibleThises(annot.tree)) { - reportProblem(s"reified annotation must not contain this-references inaccessible outside RPC trait") + def materializeFor(matchedSymbol: MatchedRealSymbol[Real]): Tree = { + def validated(annot: Annot): Annot = { + if (containsInaccessibleThises(annot.tree)) { + echo(showCode(annot.tree)) + matchedSymbol.real.reportProblem(s"reified annotation contains this-references inaccessible outside RPC trait") + } + annot } - annot - } - def materializeFor(rpcSym: Real): Tree = arity match { - case RpcParamArity.Single(annotTpe) => - rpcSym.annot(annotTpe).map(a => c.untypecheck(validated(a).tree)).getOrElse { - val msg = s"${rpcSym.problemStr}: cannot materialize value for $description: no annotation of type $annotTpe found" - q"$RpcPackage.RpcUtils.compilationError(${StringLiteral(msg, rpcSym.pos)})" - } - case RpcParamArity.Optional(annotTpe) => - mkOptional(rpcSym.annot(annotTpe).map(a => c.untypecheck(validated(a).tree))) - case RpcParamArity.Multi(annotTpe, _) => - mkMulti(allAnnotations(rpcSym.symbol, annotTpe).map(a => c.untypecheck(validated(a).tree))) + val rpcSym = matchedSymbol.real + arity match { + case RpcParamArity.Single(annotTpe) => + matchedSymbol.annot(annotTpe).map(a => c.untypecheck(validated(a).tree)).getOrElse { + val msg = s"${rpcSym.problemStr}: cannot materialize value for $description: no annotation of type $annotTpe found" + q"$RpcUtils.compilationError(${StringLiteral(msg, rpcSym.pos)})" + } + case RpcParamArity.Optional(annotTpe) => + mkOptional(matchedSymbol.annot(annotTpe).map(a => c.untypecheck(validated(a).tree))) + case RpcParamArity.Multi(annotTpe, _) => + mkMulti(allAnnotations(rpcSym.symbol, annotTpe).map(a => c.untypecheck(validated(a).tree))) + } } - def tryMaterializeFor(rpcSym: Real): Res[Tree] = - Ok(materializeFor(rpcSym)) + def tryMaterializeFor(matchedSymbol: MatchedRealSymbol[Real]): Res[Tree] = + Ok(materializeFor(matchedSymbol)) } - class HasAnnotParam[Real <: RealRpcSymbol](owner: MetadataConstructor[Real], symbol: Symbol, annotTpe: Type) + class IsAnnotatedParam[Real <: RealRpcSymbol](owner: MetadataConstructor[Real], symbol: Symbol, annotTpe: Type) extends DirectMetadataParam[Real](owner, symbol) { if (!(actualType =:= typeOf[Boolean])) { reportProblem("@hasAnnot can only be used on Boolean parameters") } - def materializeFor(rpcSym: Real): Tree = q"${allAnnotations(rpcSym.symbol, annotTpe).nonEmpty}" - def tryMaterializeFor(rpcSym: Real): Res[Tree] = Ok(materializeFor(rpcSym)) + def materializeFor(matchedSymbol: MatchedRealSymbol[Real]): Tree = + q"${matchedSymbol.allAnnots(annotTpe).nonEmpty}" + def tryMaterializeFor(matchedSymbol: MatchedRealSymbol[Real]): Res[Tree] = + Ok(materializeFor(matchedSymbol)) } class ReifiedNameParam[Real <: RealRpcSymbol](owner: MetadataConstructor[Real], symbol: Symbol, useRpcName: Boolean) @@ -324,11 +375,11 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => reportProblem(s"its type is not String") } - def materializeFor(rpcSym: Real): Tree = - q"${if (useRpcName) rpcSym.rpcName else rpcSym.nameStr}" + def materializeFor(matchedSymbol: MatchedRealSymbol[Real]): Tree = + q"${if (useRpcName) matchedSymbol.rpcName else matchedSymbol.real.nameStr}" - def tryMaterializeFor(rpcSym: Real): Res[Tree] = - Ok(materializeFor(rpcSym)) + def tryMaterializeFor(matchedSymbol: MatchedRealSymbol[Real]): Res[Tree] = + Ok(materializeFor(matchedSymbol)) } class ReifiedPositionParam(owner: ParamMetadataConstructor, symbol: Symbol) @@ -338,11 +389,13 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => reportProblem("its type is not ParamPosition") } - def materializeFor(rpcSym: RealParam): Tree = + def materializeFor(matchedParam: MatchedRealSymbol[RealParam]): Tree = { + val rpcSym = matchedParam.real q"$ParamPositionObj(${rpcSym.index}, ${rpcSym.indexOfList}, ${rpcSym.indexInList}, ${owner.indexInRaw})" + } - def tryMaterializeFor(rpcSym: RealParam): Res[Tree] = - Ok(materializeFor(rpcSym)) + def tryMaterializeFor(matchedParam: MatchedRealSymbol[RealParam]): Res[Tree] = + Ok(materializeFor(matchedParam)) } class ReifiedFlagsParam(owner: ParamMetadataConstructor, symbol: Symbol) @@ -352,7 +405,8 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => reportProblem("its type is not ParamFlags") } - def materializeFor(rpcSym: RealParam): Tree = { + def materializeFor(matchedParam: MatchedRealSymbol[RealParam]): Tree = { + val rpcSym = matchedParam.real def flag(cond: Boolean, bit: Int) = if (cond) 1 << bit else 0 val s = rpcSym.symbol.asTerm val rawFlags = @@ -364,16 +418,16 @@ trait RpcMetadatas { this: RpcMacroCommons with RpcSymbols with RpcMappings => q"new $ParamFlagsTpe($rawFlags)" } - def tryMaterializeFor(rpcSym: RealParam): Res[Tree] = - Ok(materializeFor(rpcSym)) + def tryMaterializeFor(matchedParam: MatchedRealSymbol[RealParam]): Res[Tree] = + Ok(materializeFor(matchedParam)) } class UnknownParam[Real <: RealRpcSymbol](owner: MetadataConstructor[Real], symbol: Symbol) extends DirectMetadataParam[Real](owner, symbol) { - def materializeFor(rpcSym: Real): Tree = + def materializeFor(matchedSymbol: MatchedRealSymbol[Real]): Tree = reportProblem(s"no strategy annotation (e.g. @infer) found") - def tryMaterializeFor(rpcSym: Real): Res[Tree] = - Ok(materializeFor(rpcSym)) + def tryMaterializeFor(matchedSymbol: MatchedRealSymbol[Real]): Res[Tree] = + Ok(materializeFor(matchedSymbol)) } } diff --git a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcSymbols.scala b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcSymbols.scala index 5066928b1..70270ca5c 100644 --- a/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcSymbols.scala +++ b/commons-macros/src/main/scala/com/avsystem/commons/macros/rpc/RpcSymbols.scala @@ -18,7 +18,8 @@ trait RpcSymbols { this: RpcMacroCommons => object RpcParamArity { def fromAnnotation(param: ArityParam, allowMulti: Boolean, allowListed: Boolean, allowNamed: Boolean): RpcParamArity = { - val at = param.annot(RpcArityAT).fold(SingleArityAT)(_.tpe) + + val at = findAnnotation(param.symbol, RpcArityAT).fold(SingleArityAT)(_.tpe) if (at <:< SingleArityAT) RpcParamArity.Single(param.actualType) else if (at <:< OptionalArityAT) { val optionLikeType = typeOfCachedImplicit(param.optionLike) @@ -53,29 +54,15 @@ trait RpcSymbols { this: RpcMacroCommons => object RpcMethodArity { def fromAnnotation(method: RawMethod): RpcMethodArity = { val at = method.annot(RpcArityAT).fold(SingleArityAT)(_.tpe) - if (at <:< SingleArityAT || at <:< OptionalArityAT) { - method.sig.paramLists match { - case Nil | List(_) => - case _ => method.reportProblem(s"non-multi raw method can only have zero or one parameter list") - } - if (at <:< OptionalArityAT) Optional else Single - } - else if (at <:< MultiArityAT) method.sig.paramLists match { - case List(rpcNameParam) :: (Nil | List(_)) => - if (!(actualParamType(rpcNameParam) <:< typeOf[String])) { - method.reportProblem("RPC name parameter of multi raw method must be of type String", rpcNameParam.pos) - } - Multi(RpcNameParam(method, rpcNameParam)) - case _ => - method.reportProblem(s"multi raw method must take at most two parameter lists where the first one " + - "must contain RPC name parameter typed as String") - } + if (at <:< SingleArityAT) Single + else if (at <:< OptionalArityAT) Optional + else if (at <:< MultiArityAT) Multi else method.reportProblem(s"unrecognized RPC arity annotation: $at") } case object Single extends RpcMethodArity(true) with RpcArity.Single case object Optional extends RpcMethodArity(true) with RpcArity.Optional - case class Multi(rpcNameParam: RpcNameParam) extends RpcMethodArity(false) with RpcArity.Multi + case object Multi extends RpcMethodArity(false) with RpcArity.Multi } abstract class RpcSymbol { @@ -99,8 +86,6 @@ trait RpcSymbols { this: RpcMacroCommons => val nameStr: String = name.decodedName.toString val encodedNameStr: String = name.encodedName.toString - def annot(tpe: Type): Option[Annot] = findAnnotation(symbol, tpe) - override def equals(other: Any): Boolean = other match { case rpcSym: RpcSymbol => symbol == rpcSym.symbol case _ => false @@ -109,41 +94,126 @@ trait RpcSymbols { this: RpcMacroCommons => override def toString: String = symbol.toString } + case class FallbackTag(annotTree: Tree) { + def asList: List[Tree] = List(annotTree).filter(_ != EmptyTree) + def orElse(other: FallbackTag): FallbackTag = FallbackTag(annotTree orElse other.annotTree) + } + object FallbackTag { + final val Empty = FallbackTag(EmptyTree) + } + + sealed trait MatchedRealSymbol[+Real <: RealRpcSymbol] { + def real: Real + def fallbackTagUsed: FallbackTag + + def annot(tpe: Type): Option[Annot] = + findAnnotation(real.symbol, tpe, fallback = fallbackTagUsed.asList) + + def allAnnots(tpe: Type): List[Annot] = + allAnnotations(real.symbol, tpe, fallback = fallbackTagUsed.asList) + + val rpcName: String = { + val prefixes = allAnnotations(real.symbol, RpcNamePrefixAT, fallback = fallbackTagUsed.asList) + .map(_.findArg[String](RpcNamePrefixArg)) + val rpcName = annot(RpcNameAT).fold(real.nameStr)(_.findArg[String](RpcNameArg)) + prefixes.mkString("", "", rpcName) + } + } + + case class MatchedRpcTrait(real: RealRpcTrait) extends MatchedRealSymbol[RealRpcTrait] { + def fallbackTagUsed: FallbackTag = FallbackTag.Empty + } + + case class MatchedMethod(real: RealMethod, fallbackTagUsed: FallbackTag) + extends MatchedRealSymbol[RealMethod] + + case class MatchedParam(real: RealParam, fallbackTagUsed: FallbackTag, matchedOwner: MatchedMethod) + extends MatchedRealSymbol[RealParam] { + + val whenAbsent: Tree = + annot(WhenAbsentAT).fold(EmptyTree) { annot => + val annotatedDefault = annot.tree.children.tail.head + if (!(annotatedDefault.tpe <:< real.actualType)) { + real.reportProblem(s"expected value of type ${real.actualType} in @whenAbsent annotation, " + + s"got ${annotatedDefault.tpe.widen}") + } + val transformer = new Transformer { + override def transform(tree: Tree): Tree = tree match { + case Super(t@This(_), _) if !enclosingClasses.contains(t.symbol) => + real.reportProblem(s"illegal super-reference in @whenAbsent annotation") + case This(_) if tree.symbol == real.owner.owner.symbol => q"${real.owner.owner.safeName}" + case This(_) if !enclosingClasses.contains(tree.symbol) => + real.reportProblem(s"illegal this-reference in @whenAbsent annotation") + case t => super.transform(t) + } + } + transformer.transform(annotatedDefault) + } + + val hasDefaultValue: Boolean = + whenAbsent != EmptyTree || real.symbol.asTerm.isParamWithDefault + + val transientDefault: Boolean = + hasDefaultValue && annot(TransientDefaultAT).nonEmpty + + def fallbackValueTree: Tree = + if (whenAbsent != EmptyTree) c.untypecheck(whenAbsent) + else if (real.symbol.asTerm.isParamWithDefault) defaultValue(false) + else q"$RpcUtils.missingArg(${matchedOwner.rpcName}, $rpcName)" + + def transientValueTree: Tree = + if (real.symbol.asTerm.isParamWithDefault) defaultValue(true) + else c.untypecheck(whenAbsent) + + private def defaultValue(useThis: Boolean): Tree = { + val prevListParams = real.owner.realParams.take(real.index - real.indexInList).map(rp => q"${rp.safeName}") + val prevListParamss = List(prevListParams).filter(_.nonEmpty) + val realInst = if (useThis) q"this" else q"${real.owner.owner.safeName}" + q"$realInst.${TermName(s"${real.owner.encodedNameStr}$$default$$${real.index + 1}")}(...$prevListParamss)" + } + } + trait RawRpcSymbol extends RpcSymbol { - def baseTag: Type - def defaultTag: Type + def baseTagTpe: Type + def fallbackTag: FallbackTag + + def annot(tpe: Type): Option[Annot] = + findAnnotation(symbol, tpe) - lazy val requiredTag: Type = { - val result = annot(TaggedAT).fold(baseTag)(_.tpe.baseType(TaggedAT.typeSymbol).typeArgs.head) - if (!(result <:< baseTag)) { + def tagSpec(a: Annot): (Type, FallbackTag) = { + val tagType = a.tpe.dealias.typeArgs.head + val defaultTagArg = a.tpe.member(TermName("defaultTag")) + val fallbackTag = FallbackTag(a.findArg[Tree](defaultTagArg, EmptyTree)) + (tagType, fallbackTag) + } + + lazy val (requiredTag, whenUntaggedTag) = { + val taggedAnnot = annot(TaggedAT) + val requiredTagType = taggedAnnot.fold(baseTagTpe)(_.tpe.typeArgs.head) + if (!(requiredTagType <:< baseTagTpe)) { val msg = - if (baseTag =:= NothingTpe) + if (baseTagTpe =:= NothingTpe) "cannot use @tagged, no tag annotation type specified with @methodTag/@paramTag" - else s"tag annotation type $requiredTag specified in @tagged annotation " + - s"must be a subtype of specified base tag $baseTag" + else s"tag annotation type $requiredTagType specified in @tagged annotation " + + s"must be a subtype of specified base tag $baseTagTpe" reportProblem(msg) } - result + val whenUntagged = FallbackTag(taggedAnnot.map(_.findArg[Tree](WhenUntaggedArg, EmptyTree)).getOrElse(EmptyTree)) + (requiredTagType, whenUntagged) } - def matchesTag(realSymbol: RealRpcSymbol): Boolean = - realSymbol.tag(baseTag, defaultTag) <:< requiredTag + // returns fallback tag tree only IF it was necessary + def matchTag(realRpcSymbol: RealRpcSymbol): Res[FallbackTag] = { + val tagAnnot = findAnnotation(realRpcSymbol.symbol, baseTagTpe) + val fallbackTagUsed = if (tagAnnot.isEmpty) whenUntaggedTag orElse fallbackTag else FallbackTag.Empty + val realTagTpe = tagAnnot.map(_.tpe).getOrElse(NoType) orElse fallbackTagUsed.annotTree.tpe orElse baseTagTpe - def matchTag(realRpcSymbol: RealRpcSymbol): Res[Unit] = - if (matchesTag(realRpcSymbol)) Ok(()) - else { - val tag = realRpcSymbol.tag(baseTag, defaultTag) - Fail(s"it does not accept ${realRpcSymbol.shortDescription}s tagged with $tag") - } + if (realTagTpe <:< requiredTag) Ok(fallbackTagUsed) + else Fail(s"it does not accept ${realRpcSymbol.shortDescription}s tagged with $realTagTpe") + } } - sealed trait RealRpcSymbol extends RpcSymbol { - def tag(baseTag: Type, defaultTag: Type): Type = - annot(baseTag).fold(defaultTag)(_.tpe) - - lazy val rpcName: String = - annot(RpcNameAT).fold(nameStr)(_.findArg[String](RpcNameNameSym)) - } + sealed trait RealRpcSymbol extends RpcSymbol abstract class RpcTrait(val symbol: Symbol) extends RpcSymbol { def tpe: Type @@ -191,20 +261,15 @@ trait RpcSymbols { this: RpcMacroCommons => if (isRepeated(symbol)) q"$safeName: _*" else q"$safeName" } - case class RpcNameParam(owner: RawMethod, symbol: Symbol) extends RpcParam { - def shortDescription = "RPC name parameter" - def description = s"$shortDescription $nameStr of ${owner.description}" - } - trait AritySymbol extends RpcSymbol { val arity: RpcArity // @unchecked because "The outer reference in this type test cannot be checked at runtime" // Srsly scalac, from static types it should be obvious that outer references are the same - def matchName(realRpcSymbol: RealRpcSymbol): Res[Unit] = arity match { + def matchName(matchedReal: MatchedRealSymbol[RealRpcSymbol]): Res[Unit] = arity match { case _: RpcArity.Single@unchecked | _: RpcArity.Optional@unchecked => - if (realRpcSymbol.rpcName == nameStr) Ok(()) - else Fail(s"it only matches ${realRpcSymbol.shortDescription}s with RPC name $nameStr") + if (matchedReal.rpcName == nameStr) Ok(()) + else Fail(s"it only matches ${matchedReal.real.shortDescription}s with RPC name $nameStr") case _: RpcArity.Multi@unchecked => Ok(()) } } @@ -230,22 +295,25 @@ trait RpcSymbols { this: RpcMacroCommons => def mkOptional[T: Liftable](opt: Option[T]): Tree = opt.map(t => q"$optionLike.some($t)").getOrElse(q"$optionLike.none") - def mkMulti[T: Liftable](elements: List[T]): Tree = { - val builderName = c.freshName(TermName("builder")) - q""" - val $builderName = $canBuildFrom() - $builderName.sizeHint(${elements.size}) - ..${elements.map(t => q"$builderName += $t")} - $builderName.result() - """ - } + def mkMulti[T: Liftable](elements: List[T]): Tree = + if (elements.isEmpty) q"$RpcUtils.createEmpty($canBuildFrom)" + else { + val builderName = c.freshName(TermName("builder")) + q""" + val $builderName = $RpcUtils.createBuilder($canBuildFrom, ${elements.size}) + ..${elements.map(t => q"$builderName += $t")} + $builderName.result() + """ + } } - trait RawParamLike extends ArityParam with RawRpcSymbol { + trait RealParamTarget extends ArityParam with RawRpcSymbol { def allowMulti: Boolean = true def allowNamedMulti: Boolean = true def allowListedMulti: Boolean = true + def pathStr: String + val verbatim: Boolean = annot(RpcEncodingAT).map(_.tpe <:< VerbatimAT).getOrElse(arity.verbatimByDefault) @@ -255,47 +323,65 @@ trait RpcSymbols { this: RpcMacroCommons => def cannotMapClue: String } - case class RawParam(owner: RawMethod, symbol: Symbol) extends RawParamLike { - def baseTag: Type = owner.baseParamTag - def defaultTag: Type = owner.defaultParamTag + object RawParam { + def apply(owner: Either[RawMethod, CompositeRawParam], symbol: Symbol): RawParam = + if (findAnnotation(symbol, MethodNameAT).nonEmpty) + MethodNameParam(owner, symbol) + else if (findAnnotation(symbol, CompositeAT).nonEmpty) + CompositeRawParam(owner, symbol) + else RawValueParam(owner, symbol) + } + + sealed trait RawParam extends RpcParam { + val owner: Either[RawMethod, CompositeRawParam] + val containingRawMethod: RawMethod = owner.fold(identity, _.containingRawMethod) + + def safePath: Tree = owner.fold(_ => q"$safeName", _.safeSelect(this)) + def pathStr: String = owner.fold(_ => nameStr, cp => s"${cp.pathStr}.$nameStr") def shortDescription = "raw parameter" - def description = s"$shortDescription $nameStr of ${owner.description}" - def cannotMapClue = s"cannot map it to $shortDescription $nameStr of ${owner.nameStr}" + def description = s"$shortDescription $nameStr of ${owner.fold(_.description, _.description)}" } - case class RealParam(owner: RealMethod, symbol: Symbol, index: Int, indexOfList: Int, indexInList: Int) - extends RpcParam with RealRpcSymbol { + case class MethodNameParam(owner: Either[RawMethod, CompositeRawParam], symbol: Symbol) extends RawParam { + if (!(actualType =:= typeOf[String])) { + reportProblem("@methodName parameter must be of type String") + } + } - def shortDescription = "real parameter" - def description = s"$shortDescription $nameStr of ${owner.description}" + case class CompositeRawParam(owner: Either[RawMethod, CompositeRawParam], symbol: Symbol) extends RawParam { + val constructorSig: Type = primaryConstructorOf(actualType, problemStr).typeSignatureIn(actualType) - val whenAbsent: Tree = annot(WhenAbsentAT).fold(EmptyTree) { annot => - val annotatedDefault = annot.tree.children.tail.head - if (!(annotatedDefault.tpe <:< actualType)) { - reportProblem(s"expected value of type $actualType in @whenAbsent annotation, got ${annotatedDefault.tpe.widen}") - } - val transformer = new Transformer { - override def transform(tree: Tree): Tree = tree match { - case Super(t@This(_), _) if !enclosingClasses.contains(t.symbol) => - reportProblem(s"illegal super-reference in @whenAbsent annotation") - case This(_) if tree.symbol == owner.owner.symbol => q"${owner.owner.safeName}" - case This(_) if !enclosingClasses.contains(tree.symbol) => - reportProblem(s"illegal this-reference in @whenAbsent annotation") - case t => super.transform(t) - } - } - transformer.transform(annotatedDefault) + val paramLists: List[List[RawParam]] = + constructorSig.paramLists.map(_.map(RawParam(Right(this), _))) + + def allLeafParams: Iterator[RawParam] = paramLists.iterator.flatten.flatMap { + case crp: CompositeRawParam => crp.allLeafParams + case other => Iterator(other) } - def defaultValueTree: Tree = - if (whenAbsent != EmptyTree) c.untypecheck(whenAbsent) - else if (symbol.asTerm.isParamWithDefault) { - val prevListParams = owner.realParams.take(index - indexInList).map(rp => q"${rp.safeName}") - val prevListParamss = List(prevListParams).filter(_.nonEmpty) - q"${owner.owner.safeName}.${TermName(s"${owner.encodedNameStr}$$default$$${index + 1}")}(...$prevListParamss)" + def safeSelect(subParam: RawParam): Tree = { + if (!alternatives(actualType.member(subParam.name)).exists(s => s.isMethod && s.asTerm.isParamAccessor)) { + subParam.reportProblem(s"it is not a public member and cannot be accessed, turn it into a val") } - else q"$RpcPackage.RpcUtils.missingArg(${owner.rpcName}, $rpcName)" + q"$safePath.${subParam.name}" + } + } + + case class RawValueParam(owner: Either[RawMethod, CompositeRawParam], symbol: Symbol) + extends RawParam with RealParamTarget { + + def baseTagTpe: Type = containingRawMethod.baseParamTag + def fallbackTag: FallbackTag = containingRawMethod.fallbackParamTag + + def cannotMapClue = s"cannot map it to $shortDescription $pathStr of ${containingRawMethod.nameStr}" + } + + case class RealParam(owner: RealMethod, symbol: Symbol, index: Int, indexOfList: Int, indexInList: Int) + extends RpcParam with RealRpcSymbol { + + def shortDescription = "real parameter" + def description = s"$shortDescription $nameStr of ${owner.description}" } case class RawMethod(owner: RawRpcTrait, symbol: Symbol) extends RpcMethod with RawRpcSymbol with AritySymbol { @@ -303,33 +389,38 @@ trait RpcSymbols { this: RpcMacroCommons => def description = s"$shortDescription $nameStr of ${owner.description}" def ownerType: Type = owner.tpe - def baseTag: Type = owner.baseMethodTag - def defaultTag: Type = owner.defaultMethodTag + def baseTagTpe: Type = owner.baseMethodTag + def fallbackTag: FallbackTag = owner.fallbackMethodTag val arity: RpcMethodArity = RpcMethodArity.fromAnnotation(this) + val tried: Boolean = annot(TriedAT).nonEmpty val verbatimResult: Boolean = annot(RpcEncodingAT).map(_.tpe <:< VerbatimAT).getOrElse(arity.verbatimByDefault) - val List(baseParamTag, defaultParamTag) = - annot(ParamTagAT) - .map(_.tpe.baseType(ParamTagAT.typeSymbol).typeArgs) - .getOrElse(List(owner.baseParamTag, owner.defaultParamTag)) + val (baseParamTag, fallbackParamTag) = + annot(ParamTagAT).map(tagSpec).getOrElse(owner.baseParamTag, owner.fallbackParamTag) - val rawParams: Option[List[RawParam]] = arity match { - case RpcMethodArity.Single | RpcMethodArity.Optional => - sig.paramLists.headOption.map(_.map(RawParam(this, _))) - case RpcMethodArity.Multi(_) => - sig.paramLists.tail.headOption.map(_.map(RawParam(this, _))) + val rawParams: List[RawParam] = sig.paramLists match { + case Nil | List(_) => sig.paramLists.flatten.map(RawParam(Left(this), _)) + case _ => reportProblem(s"raw methods cannot take multiple parameter lists") } - val paramLists: List[List[RpcParam]] = arity match { - case RpcMethodArity.Single | RpcMethodArity.Optional => - rawParams.toList - case RpcMethodArity.Multi(rpcNameParam) => - List(rpcNameParam) :: rawParams.toList + val paramLists: List[List[RawParam]] = + if (sig.paramLists.isEmpty) Nil else List(rawParams) + + def allLeafParams: Iterator[RawParam] = rawParams.iterator.flatMap { + case crp: CompositeRawParam => crp.allLeafParams + case other => Iterator(other) } + val allValueParams: List[RawValueParam] = + allLeafParams.collect({ case rvp: RawValueParam => rvp }).toList + + lazy val methodNameParam: MethodNameParam = + allLeafParams.collectFirst({ case mnp: MethodNameParam => mnp }) + .getOrElse(reportProblem("no @methodName parameter found on @multi raw method")) + def rawImpl(caseDefs: List[(String, Tree)]): Tree = { val body = arity match { case RpcMethodArity.Single => caseDefs match { @@ -338,15 +429,16 @@ trait RpcSymbols { this: RpcMacroCommons => case _ => abort(s"multiple real methods match $description") } case RpcMethodArity.Optional => caseDefs match { - case Nil => q"$RpcPackage.RpcUtils.missingOptionalRpc($nameStr)" + case Nil => q"$RpcUtils.missingOptionalRpc($nameStr)" case List((_, single)) => single case _ => abort(s"multiple real methods match $description") } - case RpcMethodArity.Multi(rpcNameParam) => + case RpcMethodArity.Multi => + val methodNameName = c.freshName(TermName("methodName")) q""" - ${rpcNameParam.safeName} match { + ${methodNameParam.safePath} match { case ..${caseDefs.map({ case (rpcName, tree) => cq"$rpcName => $tree" })} - case _ => $RpcPackage.RpcUtils.unknownRpc(${rpcNameParam.safeName}, $nameStr) + case $methodNameName => $RpcUtils.unknownRpc($methodNameName, $nameStr) } """ } @@ -383,18 +475,13 @@ trait RpcSymbols { this: RpcMacroCommons => def shortDescription = "raw RPC" def description = s"$shortDescription $tpe" - def baseTag: Type = typeOf[Nothing] - def defaultTag: Type = typeOf[Nothing] - - val List(baseMethodTag, defaultMethodTag) = - annot(MethodTagAT) - .map(_.tpe.baseType(MethodTagAT.typeSymbol).typeArgs) - .getOrElse(List(NothingTpe, NothingTpe)) + def baseTagTpe: Type = NothingTpe + def fallbackTag: FallbackTag = FallbackTag.Empty - val List(baseParamTag, defaultParamTag) = - annot(ParamTagAT) - .map(_.tpe.baseType(ParamTagAT.typeSymbol).typeArgs) - .getOrElse(List(NothingTpe, NothingTpe)) + val (baseMethodTag, fallbackMethodTag) = + annot(MethodTagAT).map(tagSpec).getOrElse((NothingTpe, FallbackTag.Empty)) + val (baseParamTag, fallbackParamTag) = + annot(ParamTagAT).map(tagSpec).getOrElse((NothingTpe, FallbackTag.Empty)) lazy val rawMethods: List[RawMethod] = tpe.members.iterator.filter(m => m.isTerm && m.isAbstract).map(RawMethod(this, _)).toList diff --git a/commons-redis/src/main/scala/com/avsystem/commons/redis/util/SingletonSeq.scala b/commons-redis/src/main/scala/com/avsystem/commons/redis/util/SingletonSeq.scala index 7a10459b2..cc9d48c2b 100644 --- a/commons-redis/src/main/scala/com/avsystem/commons/redis/util/SingletonSeq.scala +++ b/commons-redis/src/main/scala/com/avsystem/commons/redis/util/SingletonSeq.scala @@ -3,8 +3,8 @@ package redis.util final class SingletonSeq[+A](value: A) extends IIndexedSeq[A] { - def length = 1 - def apply(idx: Int) = idx match { + def length: Int = 1 + def apply(idx: Int): A = idx match { case 0 => value case _ => throw new IndexOutOfBoundsException } diff --git a/docs/REST.md b/docs/REST.md new file mode 100644 index 000000000..3b392ff1d --- /dev/null +++ b/docs/REST.md @@ -0,0 +1,734 @@ +# REST framework + +The commons library contains an RPC based REST framework for defining REST services using Scala traits. +It may be used for implementing both client and server side and works in both JVM and JS, as long as +appropriate network layer is implemented. For JVM, Jetty-based implementations for client and server +are provided. + +The core of REST framework is platform independent and network-implementation indepenedent and therefore +has no external dependencies. Because of that, it's a part of `commons-core` module. This is enough to +be able to define REST interfaces. But if you want to expose your REST interface through an actual HTTP +server or have an actual HTTP client for that interface, you need separate implementations for that. +The `commons-jetty` module provides Jetty-based implementations for JVM. + + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [Quickstart example](#quickstart-example) +- [REST API traits](#rest-api-traits) + - [Companion objects](#companion-objects) + - [Manual declaration of implicits](#manual-declaration-of-implicits) + - [HTTP REST methods](#http-rest-methods) + - [Choosing HTTP method](#choosing-http-method) + - [`GET` methods](#get-methods) + - [Customizing paths](#customizing-paths) + - [Path parameters](#path-parameters) + - [Query parameters](#query-parameters) + - [Header parameters](#header-parameters) + - [JSON Body parameters](#json-body-parameters) + - [Single body parameters](#single-body-parameters) + - [Prefix methods](#prefix-methods) +- [Serialization](#serialization) + - [Real and raw values](#real-and-raw-values) + - [Path, query and header serialization](#path-query-and-header-serialization) + - [JSON body parameter serialization](#json-body-parameter-serialization) + - [Single body serialization](#single-body-serialization) + - [Result serialization](#result-serialization) + - [Customizing serialization](#customizing-serialization) + - [Introduction](#introduction) + - [Plugging in entirely custom serialization](#plugging-in-entirely-custom-serialization) + - [Customizing serialization for your own type](#customizing-serialization-for-your-own-type) + - [Providing serialization for third party type](#providing-serialization-for-third-party-type) + - [Supporting result containers other than `Future`](#supporting-result-containers-other-than-future) +- [API evolution](#api-evolution) +- [Implementing backends](#implementing-backends) + - [Handler function](#handler-function) + - [Implementing a server](#implementing-a-server) + - [Implementing a client](#implementing-a-client) + + + +## Quickstart example + +First, make sure appropriate dependencies are configured for your project (assuming SBT): + +```scala +val commonsVersion: String = ??? // appropriate version of scala-commons here +libraryDependencies ++= Seq( + "com.avsystem.commons" %% "commons-core" % commonsVersion, + "com.avsystem.commons" %% "commons-jetty" % commonsVersion +) +``` + +Then, define some trivial REST interface: + +```scala +import com.avsystem.commons.rest._ + +trait UserApi { + /** Returns ID of newly created user */ + def createUser(name: String, birthYear: Int): Future[String] +} +object UserApi extends DefaultRestApiCompanion[UserApi] +``` + +Then, implement it on server side and expose it on localhost port 9090 using Jetty: + +```scala +import com.avsystem.commons.jetty.rest.RestHandler +import org.eclipse.jetty.server.Server + +class UserApiImpl extends UserApi { + def createUser(name: String, birthYear: Int) = Future.successful(s"$name-ID") +} + +object ServerMain { + def main(args: Array[String]): Unit = { + val server = new Server(9090) + server.setHandler(RestHandler[UserApi](new UserApiImpl)) + server.start() + server.join() + } +} +``` + +Finally, obtain a client proxy for your API using Jetty HTTP client and make a call: + +```scala +import com.avsystem.commons.jetty.rest.RestClient +import org.eclipse.jetty.client.HttpClient + +import scala.concurrent.Await +import scala.concurrent.duration._ + +object ClientMain { + def main(args: Array[String]): Unit = { + val client = new HttpClient + client.start() + + val proxy = RestClient[UserApi](client, "http://localhost:9090/") + + // just for this example, normally it's not recommended + import scala.concurrent.ExecutionContext.Implicits.global + + val result = proxy.createUser("Fred", 1990) + .andThen { case _ => client.stop() } + .andThen { + case Success(id) => println(s"User $id created") + case Failure(cause) => cause.printStackTrace() + } + + // just wait until future is complete so that main thread doesn't finish prematurely + Await.result(result, 10.seconds) + } +} +``` + +If we look at HTTP traffic, that's what we'll see: + +Request: +``` +POST http://localhost:9090/createUser HTTP/1.1 +Accept-Encoding: gzip +User-Agent: Jetty/9.3.23.v20180228 +Host: localhost:9090 +Content-Type: application/json;charset=utf-8 +Content-Length: 32 + +{"name":"Fred","birthYear":1990} +``` + +Response: +``` +HTTP/1.1 200 OK +Date: Wed, 18 Jul 2018 11:43:08 GMT +Content-Type: application/json;charset=utf-8 +Content-Length: 9 +Server: Jetty(9.3.23.v20180228) + +"Fred-ID" +``` + +## REST API traits + +As we saw in the quickstart example, REST API is defined by a Scala trait adjusted with annotations. +This approach is analogous to various well-established REST frameworks for other languages, e.g. JAX-RS for Java. +However, such frameworks are usually based on runtime reflection while in Scala it can be +done using compile-time reflection through macros which offers several advantages: + +* platform independency - REST traits are understood by both ScalaJVM and ScalaJS +* full type information - compile-time reflection is not limited by type erasure +* type safety - compile-time reflection can perform thorough validation of REST traits and + raise compilation errors in case anything is wrong +* pluggable typeclass based serialization - for serialization of REST parameters and results, + typeclasses are used which also offers strong compile-time safety. If any of your parameters or + method results cannot be serialized, a detailed compilation error will be raised. + +### Companion objects + +In order for a trait to be understood as REST API, it must have a well defined companion object that contains +appropriate implicits: + +* in order to expose REST API on a server, implicit instances of `RawRest.AsRawRpc` and `RestMetadata` for API trait are required. +* in order to use REST API client, implicit instances of `RawRest.AsRealRpc` and `RestMetadata` for API trait are required. +* when API trait is used by both client and server, `RawRest.AsRawRpc` and `RawRest.AsRealRpc` may be provided by a single + combined instance of `RawRest.AsRawRealRpc` for API trait. + +Usually there is no need to declare these implicit instances manually because you can use one of the convenience +base classes for REST API companion objects, e.g. + +```scala +import com.avsystem.commons.rest._ + +trait MyApi { ... } +object MyApi extends DefaultRestApiCompanion[MyApi] +``` + +`DefaultRestApiCompanion` takes a magic implicit parameter generated by a macro which will effectively +materialize all the necessary typeclass instances mentioned earlier. The "`Default`" in its name means that +`DefaultRestImplicits` is used as a provider of serialization-related implicits. See [serialization](#serialization) +for more details on customizing serialization. + +`DefaultRestApiCompanion` provides all the implicit instances necessary for both the client and server. +If you intend to use your API trait only on the server or only on the client, you may want to use more lightweight +`DefaultRestClientApiCompanion` or `DefaultRestServerApiCompanion`. This may help you reduce the amount of macro +generated code and make compilation faster. + +#### Manual declaration of implicits + +On less frequent occasions you might be unable to use one of the companion base classes. In such situations you must +declare all the implicit instances manually (however, they will still be implemented with a macro). +For example: + +```scala +import com.avsystem.commons.rest._ + +trait MyApi { ... } +object GenericApi { + import DefaultRestImplicits._ + implicit val restAsRawReal: RawRest.AsRawRealRpc[MyApi] = RawRest.materializeAsRawReal + implicit val restMetadata: RestMetadata[MyApi] = RestMetadata.materializeForRpc +} +``` + +This is usually necessary when implicit instances need some additional implicit dependencies or when +the API trait is generic (has type parameters). + +### HTTP REST methods + +REST macro engine inspects API trait and looks for all abstract methods. It then tries to translate every abstract +method into a HTTP REST call. + +* By default (if not annotated explicitly) each method is interpreted as HTTP `POST`. +* Method name is appended to the URL path. +* Every parameter is interpreted as part of the body - all the body parameters will be + combined into a JSON object sent through HTTP body. However, for `GET` methods, every parameter + is interpreted as URL query parameter while the body is empty. +* Result type of each method is typically expected to be a `Future` wrapping some + arbitrary response type. This response type will be serialized into HTTP response which + by default translates it into JSON and creates a `200 OK` response with `application/json` + content type. If response type is `Unit` (method result type is `Future[Unit]`) then empty + body is created when serializing and body is ignored when deseriarlizing. +* Each method may also throw a `HttpErrorException` (or return failed `Future`). It will be + automatically translated into appropriate HTTP error response with given status code and + plaintext message. + +For details on how exactly serialization works and how to customize it, see [serialization](#serialization). +Note that if you don't want to use `Future`, this customization also allows you to use other wrappers for method result types. + +### Choosing HTTP method + +As mentioned earlier, each trait method is by default translated into a `POST` request. +You can specify which HTTP method you want by explicitly annotating trait method as +`@GET`/`@POST`/`@PATCH`/`@PUT` or `@DELETE` (from `com.avsystem.commons.rest` package). + +```scala +@DELETE def deleteUser(id: String): Future[Unit] +``` + +#### `GET` methods + +Trait method annotated as `@GET` is interpreted somewhat differently from other HTTP methods. +Its parameters are interpreted as _query_ parameters rather than _body_ parameters. For example: + +```scala +@GET def getUsername(id: String): Future[String] +``` + +Calling `getUsername("ID")` on the client will result in HTTP request: + +``` +GET http://localhost:9090/getUsername?userId=ID HTTP/1.1 +Accept-Encoding: gzip +User-Agent: Jetty/9.3.23.v20180228 +Host: localhost:9090 + +``` + +### Customizing paths + +By default, method name is appended to URL path when translating method call to HTTP request. +This can be customized. Every annotation specifying HTTP method (e.g. `GET`) takes an optional +`path` argument that you can use to customize your path: + +```scala +@GET("username") def getUsername(id: String): Future[String] +``` + +The specified path may be multipart (it may contain slashes) or it may even be empty. +However, for server-side APIs all paths must be unambiguous, i.e. there must not be more +than one method translating to the same path. This is validated in runtime, upon +creating a server. + +Empty paths may be especially useful for [prefix methods](#prefix-methods). + +### Path parameters + +If a parameter of REST API trait method is annotated as `@Path`, its value is +appended to URL path rather than translated into query parameter or body part. + +```scala +@GET("username") def getUsername(@Path id: String): Future[String] +``` + +Calling `getUsername("ID")` will make a HTTP request on path `username/ID`. + +If there are multiple `@Path` parameters, their values are appended to the path in the +order of declaration. Each path parameters may also optionally specify a _path suffix_ that +will be appended to path after value of each parameter: + +```scala +@GET("users") def getUsername(@Path(pathSuffix = "name") id: String): Future[String] +``` + +Calling `getUsername("ID")` will make a HTTP request on path `users/ID/name`. + +This way you can model completely arbitrary path patterns. + +Values of path parameters are serialized into `PathValue` objects, +see [serialization](#path-query-and-header-serialization) for more details. + +### Query parameters + +You may explicitly request that some parameter is translated into URL query parameter +using `@Query` annotation. As mentioned earlier, parameters of `GET` methods are treated +as query parameters by default, so this is only strictly necessary for non-`GET` methods. + +`@Query` annotation also takes optional `name` parameter which may be specified to customize +URL parameter name. If not specified, trait method parameter name is used. + +Values of query parameters are serialized into `QueryValue` objects, +see [serialization](#path-query-and-header-serialization) for more details. + +### Header parameters + +You may also request that some parameter is translated into a HTTP header using `@Header` annotation. +It takes an obligatory `name` argument that specifies HTTP header name. + +Values of header parameters are serialized into `HeaderValue` objects, +see [serialization](#path-query-and-header-serialization) for more details. + +### JSON Body parameters + +As mentioned earlier, every parameter of non-`GET` API trait method is interpreted as a field +of a JSON object sent as HTTP body. Just like for path, query and header parameters, there is a +`@JsonBodyParam` annotation which requests this explicitly. However, it only makes sense to use this +annotation when one wants to customize the field name because `GET` methods do not accept body parameters. +A method annotated as `@GET` having a parameter annotated as `@JsonBodyParam` will be rejected by REST +macro engine. + +JSON body parameters are serialized into `JsonValue` objects, +see [serialization](#json-body-parameter-serialization) for more details. + +### Single body parameters + +Every non-`GET` API method may also take a single parameter annotated as `@Body` in place of multiple +unannotated parameters (implicitly interpreted as `@JsonBodyParam`s). This way the value of that parameter +will be serialized straight into HTTP body. This gives you full control over the contents sent in HTTP body +and their format (i.e. it no longer needs to be `application/json`). + +```scala +case class User(id: String, login: String) +object User extends HasGenCodec[User] + +@PUT def updateUser(@Body user: User): Future[Unit] +``` + +Single body parameters are serialized into `HttpBody` objects, +see [serialization](#single-body-serialization) for more details. + +### Prefix methods + +If a method in REST API trait doesn't return `Future[T]` (or other type that +properly translates to asynchronous HTTP response) then it may also be interpreted as _prefix_ method. + +Prefix methods are methods that return other REST API traits. They are useful for: + +* capturing common path or path/query/header parameters in a single prefix call +* splitting your REST API into multiple traits in order to better organize it + +Just like HTTP API methods (`GET`, `POST`, etc.), prefix methods have their own +annotation that can be used explicitly when you want your trait method to be treated as +a prefix method. This annotation is `@Prefix` and just like HTTP method annotations, it +takes an optional `path` parameter. If you don't need to specify path explicitly then +annotation is not necessary as long as your method returns a valid REST API trait +(where "valid" is determined by presence of appropriate implicits - +see [companion objects](#companion-objects)). + +Prefix methods may take parameters. They are interpreted as path parameters by default, +but they may also be annotated as `@Query` or `@Header`. Prefix methods must not take +body parameters. + +Path and parameters collected by a prefix method will be prepended/added +to the HTTP request generated by a HTTP method call on the API trait returned by this +prefix method. This way prefix methods "contribute" to the final HTTP requests. + +However, sometimes it may also be useful to create completely "transparent" prefix methods - +prefix methods with empty path and no parameters. This is useful when you want to refactor your +REST API trait by grouping methods into multiple, separate traits without changing the +format of HTTP requests. + +Example of prefix method that adds authentication header to the overall API: + +```scala +trait UserApi { ... } +object UserApi extends DefaultRestApiCompanion[UserApi] + +trait RootApi { + @Prefix("") def auth(@Header("Authorization") token: String): UserApi +} +object RootApi extends DefaultRestApiCompanion[RootApi] +``` + +## Serialization + +REST macro engine must be able to generate code that serializes and deserializes +every parameter value and every method result into appropriate raw values which can +be easily sent through network. Serialization in REST framework is typeclass based, +which is a typical, functional and typesafe approach to serialization in Scala. + +Examples of typeclass based serialization libraries include [GenCodec](GenCodec.md) +(which is the default serialization used by this REST framework), [circe](https://circe.github.io/circe/) +(one of the most popular JSON libraries for Scala) or [µPickle](http://www.lihaoyi.com/upickle/). +Any of these solutions can be plugged into REST framework. + +### Real and raw values + +Depending on the context where a type is used in a REST API trait, it will be serialized to a different +_raw value_: + +* path/query/header parameters are serialized as `PathValue`/`QueryValue`/`HeaderValue` +* JSON body parameters are serialized as `JsonValue` +* Single body parameters are serialized as `HttpBody` +* Response types are serialized as `RestResponse` +* Prefix result types (other REST API traits) are "serialized" as `RawRest` + +When a macro needs to serialize a value of some type (let's call it `Real`) to one of these raw types +listed above (let's call it `Raw`) then it looks for an implicit instance of `AsRaw[Raw, Real]`. +In the same manner, an implicit instance of `AsReal[Raw, Real]` is used for deserialization. +Additionally, if there is an implicit instance of `AsRawReal[Raw, Real]` then it serves both purposes. + +These implicit instances may come from multiple sources: + +* implicit scope of the `Raw` type (e.g. its companion object) +* implicit scope of the `Real` type (e.g. its companion object) +* implicits plugged by REST API trait companion + (e.g. `DefaultRestApiCompanion` plugs in `DefaultRestImplicits`) +* imports + +Of course, these implicits may also depend on other implicits which effectively means that +you can use whatever typeclass-based serialization library you want. +For example, you can define an instance of `AsRaw[JsonValue, Real]` which actually uses +`Encoder[Real]` from [circe](https://circe.github.io/circe/). See [Customizing serialization](#customizing-serialization) +for more details. + +### Path, query and header serialization + +Path, query and header parameter values are serialized into `PathValue`/`QueryValue`/`HeaderValue`. +These three classes are all simple `String` wrappers. Thanks to the fact that they are distinct types, +you can have completely different serialization defined for each one of them if you need. + +There are no "global" implicits defined for these raw types. They must be either imported, defined by each +"real" type or plugged in by REST API trait companion. For example, the `DefaultRestApiCompanion` and its +variations automatically provide serialization to `PathValue`/`QueryValue`/`HeaderValue` based on `GenKeyCodec` +and additional instances for `Float` and `Double`. This effectively provides serialization for all the +primitive types, its Java boxed counterparts, `String`, all `NamedEnum`s, Java enums and `Timestamp`. +It's also easy to provide path/query/header serialization for any type which has a natural, unambiguous textual +representation. + +Serialized values of path & query parameters are automatically URL-encoded when being embedded into +HTTP requests. This means that serialization should not worry about that. + +### JSON body parameter serialization + +JSON body parameters are serialized into `JsonValue` which is also a simple wrapper class over `String`, +but is importantly distinct from `PathValue`/`QueryValue`/`HeaderValue` because it must always contain +a valid JSON string. This is required because JSON body parameters are ultimately composed into a single +JSON object sent as HTTP body. + +There are no "global" implicits defined for `JsonValue` - JSON serialization must be either imported, +defined by each "real" type manually or plugged in by REST API trait companion. For example, +`DefaultRestApiCompanion` and its variations automatically provide serialization to `JsonValue` based +on `GenCodec`, which means that if `DefaultRestApiCompanion` is used then every type used in REST API trait +that has a `GenCodec` instance will be serializable to `JsonValue`. + +### Single body serialization + +Single body parameters (annotated as `@Body`) are serialized straight into `HttpBody`, which encapsulates +not only raw content but also MIME type. This way you can define custom body serializations for your types and +you are not limited to `application/json`. + +By default (if there are no more specific implicit instances defined), +serialization to `HttpBody` falls back to `JsonValue` and simply wraps JSON string into HTTP body +with `application/json` type. This means that all types serializable to `JsonValue` are automatically +serializable to `HttpBody`. + +### Result serialization + +Result type of every REST API method is wrapped into `Try` (in case the method throws an exception) +and "serialized" into `RawRest.Async[RestResponse]`. +This means that macro engine looks for an implicit instance of `AsRaw/AsReal[RawRest.Async[RestResponse], Try[R]]` +for every HTTP method with result type `R`. + +`RestResponse` itself is a simple class that aggregates HTTP status code and body. + +`RestResponse` companion object defines default implicit instances of `AsRaw/Real[RawRest.Async[RestResponse], Try[Future[R]]]` +which depends on implicit `AsRaw/Real[RestResponse, R]`. This effectively means that if your method returns `Future[R]` then +it's enough if `R` is serializable as `RestResponse`. + +However, there are even more defaults provided: if `R` is serializable as `HttpBody` then it's automatically serializable +as `RestResponse`. This default translation of `HttpBody` into `RestResponse` always uses 200 as a status code. +When translating `RestResponse` into `HttpBody` and response contains other status code than 200, `HttpErrorException` is thrown +(which will be subsequently captured into failed `Future`). + +Going even further with defaults, all types serializable as `JsonValue` are serializable as `HttpBody`. +This effectively means that when your method returns `Future[R]` then you can provide serialization +of `R` into any of the following: `JsonValue`, `HttpBody`, `RestResponse` - depending on how much control +you need. + +Ultimately, if you don't want to use `Future`s, you may replace it with some other asynchronous wrapper type, +e.g. Monix Task or some IO monad. +See [supporting result containers other than `Future`](#supporting-result-containers-other-than-future). + +### Customizing serialization + +#### Introduction + +When Scala compiler needs to find an implicit, it searches two scopes: _lexical_ scope and _implicit_ scope. + +_Lexical scope_ is made of locally visible and imported implicits. It has priority over implicit scope - +implicit scope is searched only when implicit could not be found in lexical scope. + +_Implicit scope_ is made of companion objects of all traits and classes _associated_ with the +type of implicit being searched for. Consult [Scala Language Specification](https://www.scala-lang.org/files/archive/spec/2.12/07-implicits.html) +for precise definition of the word "_associated_". As an example, implicit scope of type `AsRaw[JsonValue,MyClass]` is +made of companion objects of `AsRaw`, `JsonValue`, `MyClass` + companion objects of all supertraits, superclasses and +enclosing traits/classes of `MyClass`. + +Implicits defined in _implicit scope_ are effectively global and don't need to be imported. + +#### Plugging in entirely custom serialization + +REST framework deliberately provides **no** default implicits for serialization and deserialization. +Instead, it introduces a mechanism through which serialization implicits are injected by +[companion objects](#companion-objects) of REST API traits. Thanks to this mechanism REST framework is +not bound to any specific serialization library. At the same time it provides a concise method to inject +serialization implicits that does not require importing them explicitly. + +An example usage of this mechanism is `DefaultRestApiCompanion` which injects [`GenCodec`](GenCodec.md)-based +serialization. + +Let's say you want to use e.g. [circe](https://circe.github.io/circe/) for serialization to `JsonValue`. + +First, define a trait that contains implicits which translate Circe's `Encoder` and `Decoder` into +appropriate instances of `AsReal`/`AsRaw`: + +```scala +import com.avsystem.commons.rest._ +import com.avsystem.commons.rpc._ +import io.circe._ +import io.circe.parser._ +import io.circe.syntax._ + +trait CirceRestImplicits { + implicit def encoderBasedAsRawJson[T: Encoder]: Fallback[AsRaw[JsonValue, T]] = + Fallback(AsRaw.create(v => JsonValue(v.asJson.noSpaces))) + implicit def decoderBasedJsonAsReal[T: Decoder]: Fallback[AsReal[JsonValue, T]] = + Fallback(AsReal.create(json => decode(json.value).fold(throw _, identity))) +} +object CirceRestImplicits extends CirceRestImplicits +``` + +Note that implicits are wrapped into `Fallback`. This is not strictly required, but it's recommended +because these implicits ultimately will have to be imported into lexical scope, even if a macro does it +for us. However, we don't want these implicits to have higher priority than implicits from companion objects +of some concrete classes which need custom serialization. Because of that, we wrap our implicits into +`Fallback` which keeps them visible but without elevated priority. + +Now, in order to define a REST API trait that uses Circe-based serialization, you must appropriately +inject it into its companion object: + +```scala +trait MyRestApi { ... } +object MyRestApi extends RestApiCompanion[CirceRestImplicits, MyRestApi](CirceRestImplicits) +``` + +If you happen to use this often (e.g. because you always want to use Circe) then it may be useful +to create convenience companion base class just for Circe: + +```scala +abstract class CirceRestApiCompanion[Real]( + implicit inst: RpcMacroInstances[CirceRestImplicits, FullInstances, Real]) +) extends RestApiCompanion[CirceRestImplicits, Real](CirceRestImplicits) +``` + +Now you can define your trait more concisely as: + +```scala +trait MyRestApi { ... } +object MyRestApi extends CirceRestApiCompanion[MyRestApi] +``` + +#### Customizing serialization for your own type + +If you need to write manual serialization for your own type, the easiest way to do this is to +provide appropriate implicit in its companion object: + +```scala +class MyClass { ... } +object MyClass { + implicit val jsonAsRawReal: AsRawReal[JsonValue, MyClass] = AsRawReal.create(...) +} +``` + +#### Providing serialization for third party type + +If you need to define serialization implicits for a third party type, you can't do it through +implicit scope because you can't modify its companion object. Instead, you can adjust implicits injected +into REST API trait companion object. + +Assume that companion objects of your REST API traits normally extend `DefaultRestApiCompanion`, i.e. +`GenCodec`-based serialization is used. Now, you can extend `DefaultRestImplicits` to add serialization for +third party types: + +```scala +trait EnhancedRestImplicits extends DefaultRestImplicits { + implicit val thirdPartyJsonAsRawReal: AsRawReal[JsonValue, ThirdParty] = + AsRawReal.create(...) +} +object EnhancedRestImplicits extends EnhancedRestImplicits +``` + +Then, you need to define your REST API trait as: + +```scala +trait MyRestApi { ... } +object MyRestApi extends RestApiCompanion[EnhancedRestImplicits, MyRestApi](EnhancedRestImplicits) +``` + +#### Supporting result containers other than `Future` + +By default, every HTTP method in REST API trait must return its return wrapped into a `Future`. +However, `Future` is low level and limited in many ways (e.g. there is no way to control when the actual +asynchronous computation starts). It is possible to use other task-like containers, e.g. +[Monix Task](https://monix.io/docs/2x/eval/task.html) or [Cats IO](https://typelevel.org/cats-effect/). + +In order to do that, you must provide some additional "serialization" implicits which will translate your +wrapped results into `RawRest.Async[RestResponse]`. Just like when +[providing serialization for third party type](#providing-serialization-for-third-party-type), +you should put that implicit into a trait and inject it into REST API trait's companion object. + +Also note that the macro engine re-wraps the already wrapped result into `Try` in case some REST API method throws an +exception. Therefore, if you want your methods to return `Task[T]` then you need serialization for `Try[Task[T]]`. + +Additionally, you should provide an implicit instance of `HttpResponseType`, similar to the one defined +in its companion object for `Future`s. This drives materialization of `RestMetadata` in a similar way +`AsRaw` and `AsReal` drive materialization of real<->raw interface translation. + +Here's an example that shows what exactly must be implemented to add Monix Task support: + +```scala +import monix.eval.Task +import com.avsystem.commons.rest.RawRest + +trait MonixTaskRestImplicits { + implicit def taskAsAsync[T](implicit asResp: AsRaw[RestResponse, T]): AsRaw[RawRest.Async[RestResponse], Try[Task[T]]] = + AsRaw.create(...) + implicit def asyncAsTask[T](implicit fromResp: AsReal[RestResponse, T]): AsReal[RawRest.Async[RestResponse], Try[Task[T]]] = + AsReal.create(...) + implicit def taskResponseType[T]: HttpResponseType[Task[T]] = + new HttpResponseType[Task[T]] {} +} +object MonixTaskRestImplicits +``` + +## API evolution + +REST framework gives you a certain amount of guarantees about backwards compatibility of your API. +Here's a list of changes that you may safely do to your REST API traits without breaking clients +that still use the old version: + +* Adding new REST methods, as long as paths are still the same and unambiguous. +* Renaming REST methods, as long as old `path` is configured for them explicitly (e.g. `@GET("oldname") def newname(...)`) +* Reordering parameters of your REST methods, except for `@Path` parameters which may be freely intermixed + with other parameters but they must retain the same order relative to each other. +* Splitting parameters into multiple parameter lists or making them `implicit`. +* Renaming `@Path` parameters - their names are not used in REST requests +* Renaming non-`@Path` parameters, as long as the previous name is explicitly configured by + `@Query`, `@Header` or `@JsonBodyParam` annotation. +* Removing non-`@Path` parameters - even if the client sends them, the server will just ignore them. +* Adding new non-`@Path` parameters, as long as default value is provided for them - either as + Scala-level default parameter value or by using `@whenAbsent` annotation. The server will simply + use the default value if parameter is missing in incoming HTTP request. +* Changing parameter or result types or their serialization - as long as serialized formats of new and old type + are compatible. This depends on on the serialization library you're using. If you're using `GenCodec`, consult + [its documentation on retaining backwards compatibility](GenCodec.md#safely-introducing-changes-to-serialized-classes-retaining-backwards-compatibility). + +## Implementing backends + +Core REST framework (`commons-core` module only) does not contain any backends, i.e. there is no actual network +client or server implementation. However, it's fairly easy to build them as most of the heavy-lifting related to +REST is already done by the core framework (its macro engine, in particular). + +Jetty-based `RestClient` and `RestHandler` are provided by `commons-jetty` module. They may serve as simple +reference backend implementation. + +### Handler function + +`RawRest` object defines following type aliases: + +```scala +type Callback[T] = Try[T] => Unit +type Async[T] = Callback[T] => Unit +type HandleRequest = RestRequest => Async[RestResponse] +``` + +`RestRequest` is a simple, immutable representation of HTTP request. It contains HTTP method, path, URL +parameters, HTTP header values and a body (`HttpBody`). All data is already in serialized form so it can be +easily sent through network. + +`RestResponse` is, similarly, a simple representation of HTTP response. `RestResponse` is made of HTTP status +code and HTTP body (`HttpBody`, which also contains MIME type). + +### Implementing a server + +An existing implementation of REST API trait can be easily turned into a `HandleRequest` function using `RawRest.asHandleRequest`. + +Therefore, the only thing you need to do to expose your REST API trait as an actual web service it to turn +`HandleRequest` function into a server. This is usually just a matter of translating native HTTP request into `RestRequest`, +passing them to `HandleRequest` function and translating resulting `RestResponse` to native HTTP response. + +See [`RestServlet`](../commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestServlet.scala) for an example implementation. + +### Implementing a client + +If you already have a `HandleRequest` function, you can easily turn it into an implementation of desired REST API trait +using `RawRest.fromHandleRequest`. This implementation is a macro-generated proxy which translates actual +method calls into invocations of provided `HandleRequest` function. + +Therefore, the only thing you need to to in order to wrap a native HTTP client into a REST API trait instance is +to turn this native HTTP client into a `HandleRequest` function. + +See Jetty-based [`RestClient`](../commons-jetty/src/main/scala/com/avsystem/commons/jetty/rest/RestClient.scala) for +an example implementation. diff --git a/version.sbt b/version.sbt index 200fd671b..3c89c5095 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "1.28.3" +version in ThisBuild := "1.29.0-SNAPSHOT"