Skip to content

Commit

Permalink
sbt-airframe: Use pre-built airframe-http package for generating clie…
Browse files Browse the repository at this point in the history
…nt code (#998)

* Download airframe-http using Coursier
* Add CLI launcher
* Add defaultClassName
* Multiple Scala version support #970
* Refresh the client code if the service interface is changed
  • Loading branch information
xerial committed Mar 18, 2020
2 parents 8f54793 + aceb356 commit a893a26
Show file tree
Hide file tree
Showing 11 changed files with 275 additions and 60 deletions.
Expand Up @@ -42,8 +42,13 @@ object JSHttpClient {
val protocol = window.location.protocol
val hostname = window.location.hostname
val port = window.location.port.toInt
val address = ServerAddress(hostname, port, protocol)
JSHttpClient(JSHttpClientConfig(serverAddress = Some(address)))
if (hostname == "localhost" && protocol == "http") {
// Use local client for testing
localClient
} else {
val address = ServerAddress(hostname, port, protocol)
JSHttpClient(JSHttpClientConfig(serverAddress = Some(address)))
}
}

// An http client that can be used for local testing
Expand Down
Expand Up @@ -12,11 +12,15 @@
* limitations under the License.
*/
package wvlet.airframe.http.codegen
import java.io.{File, FileWriter}
import java.net.URLClassLoader

import wvlet.airframe.codec.MessageCodec
import wvlet.airframe.control.Control
import wvlet.airframe.http.Router
import wvlet.airframe.http.codegen.client.{AsyncClient, HttpClientType}
import wvlet.log.LogSupport
import wvlet.airframe.launcher.Launcher
import wvlet.log.{LogLevel, LogSupport, Logger}

case class HttpClientGeneratorConfig(
// A package name to search for airframe-http interfaces
Expand All @@ -26,7 +30,7 @@ case class HttpClientGeneratorConfig(
// [optional] Which package to use for the generating client code?
targetPackageName: String
) {
def className = clientType.defaultClassName
def fileName = clientType.defaultFileName
}

object HttpClientGeneratorConfig {
Expand Down Expand Up @@ -71,9 +75,91 @@ object HttpClientGenerator extends LogSupport {
code
}

def generate(config: HttpClientGeneratorConfig, cl: URLClassLoader): String = {
def generate(config: HttpClientGeneratorConfig, cl: ClassLoader): String = {
val router = RouteScanner.buildRouter(Seq(config.apiPackageName), cl)
val code = generate(router, config)
code
}

def main(args: Array[String]): Unit = {
Launcher.of[HttpClientGenerator].execute(args)
}

case class Artifacts(file: Seq[File])
}

import wvlet.airframe.launcher._

class HttpClientGenerator(
@option(prefix = "-h,--help", description = "show help message", isHelp = true)
isHelp: Boolean = false,
@option(prefix = "-l,--loglevel", description = "log level")
logLevel: Option[LogLevel] = None
) extends LogSupport {
Logger.init

logLevel.foreach { x => Logger.setDefaultLogLevel(x) }

@command(isDefault = true)
def default = {
info(s"Type --help for the available options")
}

@command(description = "Generate HTTP client codes")
def generate(
@option(prefix = "-cp", description = "semi-colon separated application classpaths")
classpath: String = "",
@option(prefix = "-o", description = "output base directory")
outDir: File,
@option(prefix = "-t", description = "target directory")
targetDir: File,
@argument(description = "client code generation targets: (package):(type)(:(targetPackage))?")
targets: Seq[String] = Seq.empty
): Unit = {
try {
val cp = classpath.split(":").map(x => new File(x).toURI.toURL).toArray
val cl = new URLClassLoader(cp)
val artifacts = for (x <- targets) yield {
val config = HttpClientGeneratorConfig(x)
debug(config)
if (!targetDir.exists()) {
targetDir.mkdirs()
}
val path = s"${config.targetPackageName.replaceAll("\\.", "/")}/${config.fileName}"
val outputFile = new File(outDir, path)

val router = RouteScanner.buildRouter(Seq(config.apiPackageName), cl)
val routerStr = router.toString
val routerHash = routerStr.hashCode
val routerHashFile = new File(targetDir, f"router-${routerHash}%07x.update")
if (!(outputFile.exists() && routerHashFile.exists())) {
outputFile.getParentFile.mkdirs()
info(f"Router for package ${config.apiPackageName}:\n${routerStr}")
info(s"Generating a ${config.clientType.name} client code: ${path}")
val code = HttpClientGenerator.generate(router, config)
writeFile(outputFile, code)
touch(routerHashFile)
} else {
info(s"${path} is up-to-date")
}
outputFile
}
println(MessageCodec.of[Seq[File]].toJson(artifacts))
} catch {
case e: Throwable =>
warn(e)
println("[]") // empty result
}
}

private def touch(f: File): Unit = {
if (!f.createNewFile()) {
f.setLastModified(System.currentTimeMillis())
}
}

private def writeFile(outputFile: File, data: String): Unit = {
Control.withResource(new FileWriter(outputFile)) { out => out.write(data); out.flush() }
}

}
Expand Up @@ -14,10 +14,10 @@
package wvlet.airframe.http.codegen
import java.util.Locale

import wvlet.airframe.http.Router
import wvlet.airframe.http.codegen.RouteAnalyzer.RouteAnalysisResult
import wvlet.airframe.http.router.Route
import wvlet.airframe.http.{HttpMethod, HttpRequest, Router}
import wvlet.airframe.surface.{CName, MethodParameter, Surface}
import wvlet.airframe.surface.{MethodParameter, Parameter, Surface}
import wvlet.log.LogSupport

/**
Expand All @@ -34,18 +34,30 @@ object HttpClientIR extends LogSupport {
// Collect all Surfaces used in the generated code
def loop(s: Any): Seq[Surface] = {
s match {
case s: Surface =>
Seq(s) ++ s.typeArgs.flatMap(loop)
case m: Parameter =>
loop(m.surface)
case c: ClientClassDef => c.services.flatMap(loop)
case x: ClientServiceDef => x.methods.flatMap(loop)
case m: ClientMethodDef => Seq(m.returnType) ++ m.inputParameters.map(_.surface)
case m: ClientMethodDef =>
loop(m.returnType) ++ m.inputParameters.flatMap(loop)
case _ =>
Seq.empty
}
}

def requireImports(surface: Surface): Boolean = {
val fullName = surface.fullName
!(fullName.startsWith("scala.") || fullName.startsWith("wvlet.airframe.http.") || surface.isPrimitive)
// Primitive Scala collections can be found in scala.Predef. No need to include them
!(surface.rawType.getPackageName == "scala.collection" ||
fullName.startsWith("wvlet.airframe.http.") ||
surface.isPrimitive ||
// Within the same package
surface.rawType.getPackageName == packageName)
}

loop(classDef).filter(requireImports).distinct
loop(classDef).filter(requireImports).distinct.sortBy(_.name)
}
}
case class ClientClassDef(clsName: String, services: Seq[ClientServiceDef]) extends ClientCodeIR
Expand Down Expand Up @@ -75,7 +87,7 @@ object HttpClientIR extends LogSupport {
}

ClientClassDef(
clsName = config.className,
clsName = config.clientType.defaultClassName,
services = services.toIndexedSeq
)
}
Expand Down
Expand Up @@ -12,8 +12,6 @@
* limitations under the License.
*/
package wvlet.airframe.http.codegen
import java.net.URLClassLoader

import wvlet.airframe.http.{Endpoint, Router}
import wvlet.log.LogSupport

Expand Down Expand Up @@ -46,11 +44,10 @@ object RouteScanner extends LogSupport {
* @param targetPackages
* @param classLoader
*/
def buildRouter(targetPackages: Seq[String], classLoader: URLClassLoader): Router = {
trace(s"buildRouter: ${targetPackages}\n${classLoader.getURLs.mkString("\n")}")
def buildRouter(targetPackages: Seq[String], classLoader: ClassLoader): Router = {
trace(s"buildRouter: ${targetPackages}")

// We need to use our own class loader as sbt's layered classloader cannot find application classes
val currentClassLoader = Thread.currentThread().getContextClassLoader
withClassLoader(classLoader) {
val lst = ClassScanner.scanClasses(classLoader, targetPackages)
val classes = Seq.newBuilder[Class[_]]
Expand All @@ -67,7 +64,7 @@ object RouteScanner extends LogSupport {
private[codegen] def buildRouter(classes: Seq[Class[_]]): Router = {
var router = Router.empty
for (cl <- classes) yield {
debug(f"Searching ${cl} for HTTP endpoints")
trace(f"Searching ${cl} for HTTP endpoints")
import wvlet.airframe.surface.reflect._
val s = ReflectSurfaceFactory.ofClass(cl)
val methods = ReflectSurfaceFactory.methodsOfClass(cl)
Expand Down
Expand Up @@ -19,6 +19,7 @@ import wvlet.airframe.http.codegen.HttpClientIR.ClientSourceDef
*/
trait HttpClientType {
def name: String
def defaultFileName: String
def defaultClassName: String
def generate(src: ClientSourceDef): String
}
Expand Down
Expand Up @@ -39,6 +39,7 @@ import ScalaHttpClient._

object AsyncClient extends HttpClientType {
override def name: String = "async"
override def defaultFileName: String = "ServiceClient.scala"
override def defaultClassName: String = "ServiceClient"
override def generate(src: ClientSourceDef): String = {
def code = s"""${header(src.packageName)}
Expand Down Expand Up @@ -92,6 +93,7 @@ object AsyncClient extends HttpClientType {

object SyncClient extends HttpClientType {
override def name: String = "sync"
override def defaultFileName: String = "ServiceSyncClient.scala"
override def defaultClassName: String = "ServiceSyncClient"
override def generate(src: ClientSourceDef): String = {
def code =
Expand Down Expand Up @@ -150,6 +152,7 @@ object SyncClient extends HttpClientType {
*/
object ScalaJSClient extends HttpClientType {
override def name: String = "scalajs"
override def defaultFileName: String = "ServiceJSClient.scala"
override def defaultClassName: String = "ServiceJSClient"
override def generate(src: ClientSourceDef): String = {
def code =
Expand Down
Expand Up @@ -42,6 +42,7 @@ class HttpClientGeneratorTest extends AirSpec {
HttpClientGeneratorConfig("example.api:async:example.api.client")
)
code.contains("package example.api.client") shouldBe true
code.contains("import example.Query") shouldBe true
code.contains("class ServiceClient[F[_], Req, Resp]")
}

Expand Down
Expand Up @@ -18,6 +18,8 @@ import wvlet.airframe.http.SimpleHttpRequest.SimpleHttpRequestAdapter
import wvlet.airframe.http.SimpleHttpResponse.SimpleHttpResponseAdapter

/**
* Http Request
*
* @deprecated(message = "Use Http.request(...) instead")
*/
case class SimpleHttpRequest(
Expand Down Expand Up @@ -50,6 +52,7 @@ object SimpleHttpRequest {
}

/**
* Http Response
* @deprecated(message = "Use Http.response(...) instead")
*/
case class SimpleHttpResponse(
Expand Down
25 changes: 19 additions & 6 deletions build.sbt
@@ -1,4 +1,5 @@
import sbtcrossproject.{CrossType, crossProject}
import xerial.sbt.pack.PackPlugin.publishPackArchiveTgz

val SCALA_2_11 = "2.11.12"
val SCALA_2_12 = "2.12.11"
Expand Down Expand Up @@ -535,9 +536,7 @@ lazy val http =
.settings(buildSettings)
.settings(
name := "airframe-http",
description := "REST API Framework",
libraryDependencies ++= Seq(
)
description := "REST API Framework"
)
.jsSettings(
jsBuildSettings,
Expand All @@ -549,7 +548,14 @@ lazy val http =
.dependsOn(airframe, airframeMacrosRef, control, surface, json, codec, airspecRef % "test")

lazy val httpJVM = http.jvm
lazy val httpJS = http.js
.enablePlugins(PackPlugin)
.settings(
packMain := Map("airframe-http-client-generator" -> "wvlet.airframe.http.codegen.HttpClientGenerator"),
packExcludeLibJars := Seq("airspec_2.12"),
publishPackArchiveTgz
).dependsOn(launcher)

lazy val httpJS = http.js

lazy val finagle =
project
Expand Down Expand Up @@ -994,23 +1000,30 @@ lazy val airspecRefJS = airspecRef.js
lazy val sbtAirframe =
project
.in(file("sbt-airframe"))
.enablePlugins(SbtPlugin)
.enablePlugins(SbtPlugin, BuildInfoPlugin)
.settings(
buildSettings,
buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion),
buildInfoPackage := "wvlet.airframe.sbt",
name := "sbt-airframe",
description := "sbt plugin for helping programming with Airframe",
scalaVersion := SCALA_2_12,
crossSbtVersions := Vector("1.3.8"),
libraryDependencies ++= Seq(
"io.get-coursier" %% "coursier" % "2.0.0-RC5-6",
"org.apache.commons" % "commons-compress" % "1.2"
),
scriptedLaunchOpts := {
scriptedLaunchOpts.value ++
Seq("-Xmx1024M", "-Dplugin.version=" + version.value)
},
scriptedDependencies := {
// Publish all dependencies necessary for running the scripted tests
scriptedDependencies.value
publishLocal.in(httpJVM, packArchiveTgz).value
publishLocal.all(ScopeFilter(inDependencies(finagle))).value
publishLocal.all(ScopeFilter(inDependencies(httpJS))).value
},
scriptedBufferLog := false
)
.dependsOn(httpJVM, airspecRefJVM % "test")
.dependsOn(controlJVM, codecJVM, logJVM, httpJVM % "test", airspecRefJVM % "test")
1 change: 1 addition & 0 deletions project/plugin.sbt
Expand Up @@ -3,6 +3,7 @@ addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.1")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.3.2")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0")
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0")

// For Scala.js
val SCALAJS_VERSION = sys.env.getOrElse("SCALAJS_VERSION", "1.0.1")
Expand Down

0 comments on commit a893a26

Please sign in to comment.