Skip to content

Commit

Permalink
airframe-http: #989 Add @rpc Annotation (#1012)
Browse files Browse the repository at this point in the history
* Add RPC router test
* Add scripted test for RPC
* Fix scanner
* Fix endpoint finder
* Send empty JSON for POST
* Resolve RPC package path from the owner
* Fix deprecation warnings
* Use full package path for RPC endpoints
  • Loading branch information
xerial committed Mar 26, 2020
1 parent 0f4614a commit cdd626d
Show file tree
Hide file tree
Showing 16 changed files with 257 additions and 75 deletions.
54 changes: 39 additions & 15 deletions airframe-http/.jvm/src/main/scala/wvlet/airframe/http/Router.scala
Expand Up @@ -124,21 +124,45 @@ case class Router(
// Import ReflectSurface to find method annotations (Endpoint)
import wvlet.airframe.surface.reflect._

// Get a common prefix of Endpoints if exists
val prefixPath =
controllerSurface
.findAnnotationOf[Endpoint]
.map(_.path())
.getOrElse("")

// Add methods annotated with @Endpoint
val newRoutes =
controllerMethodSurfaces
.map { m => (m, m.findAnnotationOf[Endpoint]) }
.collect {
case (m: ReflectMethodSurface, Some(endPoint)) =>
ControllerRoute(controllerSurface, endPoint.method(), prefixPath + endPoint.path(), m)
}
val endpointOpt = controllerSurface.findAnnotationOf[Endpoint]
val rpcOpt = controllerSurface.findAnnotationOf[RPC]

val newRoutes: Seq[Route] = {
(endpointOpt, rpcOpt) match {
case (Some(endpoint), Some(rpcOpt)) =>
throw new IllegalArgumentException(
s"Cannot define both of @Endpoint and @RPC annotations: ${controllerSurface}"
)
case (_, None) =>
val prefixPath = endpointOpt.map(_.path()).getOrElse("")
// Add methods annotated with @Endpoint
controllerMethodSurfaces
.map { m => (m, m.findAnnotationOf[Endpoint]) }
.collect {
case (m: ReflectMethodSurface, Some(endPoint)) =>
ControllerRoute(controllerSurface, endPoint.method(), prefixPath + endPoint.path(), m)
}
case (None, Some(rpc)) =>
// We need to find the owner class of the RPC interface because the controller might be extending the RPC interface (e.g., RPCImpl)
val rpcInterfaceCls = controllerSurface.findAnnotationOwnerOf[RPC].getOrElse(controllerSurface.rawType)
val serviceFullName = rpcInterfaceCls.getName.replaceAll("\\$anon\\$", "").replaceAll("\\$", ".")
val prefixPath = if (rpc.path().isEmpty) {
s"/${serviceFullName}"
} else {
s"${rpc.path()}/${serviceFullName}"
}
controllerMethodSurfaces
.filter(_.isPublic)
.map { m => (m, m.findAnnotationOf[RPC]) }
.collect {
case (m: ReflectMethodSurface, Some(rpc)) =>
val path = if (rpc.path().nonEmpty) rpc.path() else s"/${m.name}"
ControllerRoute(controllerSurface, HttpMethod.POST, prefixPath + path, m)
case (m: ReflectMethodSurface, None) =>
ControllerRoute(controllerSurface, HttpMethod.POST, prefixPath + s"/${m.name}", m)
}
}
}

val newRouter = new Router(surface = Some(controllerSurface), localRoutes = newRoutes)
if (this.isEmpty) {
Expand Down
Expand Up @@ -131,14 +131,14 @@ class HttpClientGenerator(
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())) {
val routerHashFile = new File(targetDir, f"router-${config.clientType.name}-${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)
writeFile(outputFile, code)
} else {
info(s"${outputFile} is up-to-date")
}
Expand Down
Expand Up @@ -14,7 +14,7 @@
package wvlet.airframe.http.codegen
import java.util.Locale

import wvlet.airframe.http.Router
import wvlet.airframe.http.{HttpMethod, Router}
import wvlet.airframe.http.codegen.RouteAnalyzer.RouteAnalysisResult
import wvlet.airframe.http.router.Route
import wvlet.airframe.surface.{GenericSurface, HigherKindedTypeSurface, MethodParameter, Parameter, Surface}
Expand Down Expand Up @@ -148,18 +148,25 @@ object HttpClientIR extends LogSupport {
clientCallParams += s"Map(${params.result.mkString(", ")})"
typeArgBuilder += Surface.of[Map[String, Any]]
} else {
httpClientCallInputs.headOption.map { x =>
clientCallParams += x.name
typeArgBuilder += x.surface
if (httpClientCallInputs.isEmpty && route.method == HttpMethod.POST) {
// For RPC calls without any input, embed an empty json
clientCallParams += "Map.empty"
typeArgBuilder += Surface.of[Map[String, Any]]
} else {
httpClientCallInputs.headOption.map { x =>
clientCallParams += x.name
typeArgBuilder += x.surface
}
}
}
typeArgBuilder += unwrapFuture(route.returnTypeSurface)
val typeArgs = typeArgBuilder.result()

ClientMethodDef(
httpMethod = route.method,
isOpsRequest = httpClientCallInputs.nonEmpty,
isOpsRequest = typeArgs.size > 1,
name = name,
typeArgs = typeArgBuilder.result(),
typeArgs = typeArgs,
inputParameters = analysis.userInputParameters,
clientCallParameters = clientCallParams.result(),
path = analysis.pathString,
Expand Down
Expand Up @@ -12,7 +12,7 @@
* limitations under the License.
*/
package wvlet.airframe.http.codegen
import wvlet.airframe.http.{Endpoint, Router}
import wvlet.airframe.http.{Endpoint, RPC, Router}
import wvlet.log.LogSupport

import scala.util.{Success, Try}
Expand Down Expand Up @@ -68,7 +68,7 @@ object RouteScanner extends LogSupport {
import wvlet.airframe.surface.reflect._
val s = ReflectSurfaceFactory.ofClass(cl)
val methods = ReflectSurfaceFactory.methodsOfClass(cl)
if (methods.exists(_.findAnnotationOf[Endpoint].isDefined)) {
if (s.findAnnotationOf[RPC].isDefined || methods.exists(m => m.findAnnotationOf[Endpoint].isDefined)) {
debug(s"Found an Airframe HTTP interface: ${s.fullName}")
router = router.addInternal(s, methods)
}
Expand Down
Expand Up @@ -26,14 +26,14 @@ import wvlet.airspec.AirSpec
class HttpClientTest extends AirSpec {
import HttpClient._
abstract class RetryTest(expectedRetryCount: Int, expectedExecCount: Int) {
val retryer = defaultHttpClientRetry[SimpleHttpRequest, SimpleHttpResponse]
val retryer = defaultHttpClientRetry[HttpMessage.Request, HttpMessage.Response]
.withBackOff(initialIntervalMillis = 0)
var retryCount = 0
var execCount = 0

def body: SimpleHttpResponse
def body: HttpMessage.Response

def run = {
def run: HttpMessage.Response = {
retryer.run {
if (execCount > 0) {
retryCount += 1
Expand All @@ -42,7 +42,7 @@ class HttpClientTest extends AirSpec {
val ret = if (retryCount == 0) {
body
} else {
SimpleHttpResponse(HttpStatus.Ok_200)
Http.response(HttpStatus.Ok_200)
}
ret
}
Expand All @@ -59,14 +59,15 @@ class HttpClientTest extends AirSpec {
}

def `retry on failed http requests`: Unit = {
val retryableResponses: Seq[SimpleHttpResponse] = Seq(
SimpleHttpResponse(HttpStatus.ServiceUnavailable_503),
SimpleHttpResponse(HttpStatus.TooManyRequests_429),
SimpleHttpResponse(HttpStatus.InternalServerError_500),
SimpleHttpResponse(
HttpStatus.BadRequest_400,
"Your socket connection to the server was not read from or written to within the timeout period. Idle connections will be closed."
)
val retryableResponses: Seq[HttpMessage.Response] = Seq(
Http.response(HttpStatus.ServiceUnavailable_503),
Http.response(HttpStatus.TooManyRequests_429),
Http.response(HttpStatus.InternalServerError_500),
Http
.response(
HttpStatus.BadRequest_400,
"Your socket connection to the server was not read from or written to within the timeout period. Idle connections will be closed."
)
)
retryableResponses.foreach { r =>
new RetryTest(expectedRetryCount = 1, expectedExecCount = 2) {
Expand All @@ -75,12 +76,12 @@ class HttpClientTest extends AirSpec {
}
}
def `never retry on deterministic http request failrues`: Unit = {
val nonRetryableResponses: Seq[SimpleHttpResponse] = Seq(
SimpleHttpResponse(HttpStatus.BadRequest_400, "bad request"),
SimpleHttpResponse(HttpStatus.Unauthorized_401, "permission deniend"),
SimpleHttpResponse(HttpStatus.Forbidden_403, "forbidden"),
SimpleHttpResponse(HttpStatus.NotFound_404, "not found"),
SimpleHttpResponse(HttpStatus.Conflict_409, "conflict")
val nonRetryableResponses: Seq[HttpMessage.Response] = Seq(
Http.response(HttpStatus.BadRequest_400, "bad request"),
Http.response(HttpStatus.Unauthorized_401, "permission deniend"),
Http.response(HttpStatus.Forbidden_403, "forbidden"),
Http.response(HttpStatus.NotFound_404, "not found"),
Http.response(HttpStatus.Conflict_409, "conflict")
)

nonRetryableResponses.foreach { r =>
Expand Down
Expand Up @@ -24,18 +24,18 @@ class LongPathTest extends AirSpec {
val r = Router.add[LongPathExample]

{
val m = r.findRoute(SimpleHttpRequest(HttpMethod.GET, "/v1/config/entry"))
val m = r.findRoute(Http.GET("/v1/config/entry"))
m shouldBe defined
}

{
val m = r.findRoute(SimpleHttpRequest(HttpMethod.GET, "/v1/config/entry/myapp"))
val m = r.findRoute(Http.GET("/v1/config/entry/myapp"))
m shouldBe defined
m.get.params("scope") shouldBe "myapp"
}

{
val m = r.findRoute(SimpleHttpRequest(HttpMethod.GET, "/v1/config/entry/myapp/long/path/entry"))
val m = r.findRoute(Http.GET("/v1/config/entry/myapp/long/path/entry"))
m shouldBe defined
val p = m.get.params
p("scope") shouldBe "myapp"
Expand All @@ -45,7 +45,7 @@ class LongPathTest extends AirSpec {
{
val m =
r.findRoute(
SimpleHttpRequest(HttpMethod.GET, "/v1/config/entry/config/autoscaling/clusters/default/maxCapacity")
Http.GET("/v1/config/entry/config/autoscaling/clusters/default/maxCapacity")
)

m shouldBe defined
Expand Down
Expand Up @@ -35,20 +35,20 @@ object PathSequenceTest extends AirSpec {
private val r = Router.of[MyService]

def `handle empty path`: Unit = {
val m = r.findRoute(SimpleHttpRequest(HttpMethod.GET, "/html/"))
val m = r.findRoute(Http.GET("/html/"))
m shouldBe defined
m.get.params("path") shouldBe ""
}

def `handle long paths`: Unit = {
val m = r.findRoute(SimpleHttpRequest(HttpMethod.GET, "/html/long/path"))
val m = r.findRoute(Http.GET("/html/long/path"))
m shouldBe defined
m.get.params("path") shouldBe "long/path"
}

def `handle the root path for path sequence`: Unit = {
val r2 = Router.of[MyHTTPService]
val m = r2.findRoute(SimpleHttpRequest(HttpMethod.GET, "/"))
val m = r2.findRoute(Http.GET("/"))
m shouldBe defined
m.get.route match {
case c: ControllerRoute =>
Expand All @@ -57,7 +57,7 @@ object PathSequenceTest extends AirSpec {
fail("failed")
}

val m2 = r2.findRoute(SimpleHttpRequest(HttpMethod.GET, "/css/main.css"))
val m2 = r2.findRoute(Http.GET("/css/main.css"))
m2 shouldBe defined
m2.get.route match {
case c: ControllerRoute =>
Expand Down
@@ -0,0 +1,73 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package wvlet.airframe.http
import wvlet.airspec.AirSpec

/**
*
*/
object RPCTest extends AirSpec {

case class BookRequest(id: String)
case class Book(id: String, name: String)

@RPC(path = "/v1", description = "My RPC service interface")
trait MyRPCService {
def hello: String
def book(request: BookRequest): Seq[Book]
}

test("Create a router from RPC annotation") {
val r = Router.add[MyRPCService]
debug(r)
val m1 = r.routes.find(_.path == "/v1/wvlet.airframe.http.RPCTest.MyRPCService/hello")
m1 shouldBe defined
m1.get.method shouldBe HttpMethod.POST
m1.get.methodSurface.name shouldBe "hello"

val m2 = r.routes.find(_.path == "/v1/wvlet.airframe.http.RPCTest.MyRPCService/book")
m2 shouldBe defined
m2.get.method shouldBe HttpMethod.POST
m2.get.methodSurface.name shouldBe "book"
}

@RPC(description = "My RPC service interface")
trait MyRPCService2 {
def hello: String
}

test("Create RPC interface without any path") {
val r = Router.add[MyRPCService2]
debug(r)
val m = r.routes.find(_.path == "/wvlet.airframe.http.RPCTest.MyRPCService2/hello")
m shouldBe defined
m.get.method shouldBe HttpMethod.POST
m.get.methodSurface.name shouldBe "hello"
}

@RPC(path = "/v1", description = "My RPC service interface")
trait MyRPCService3 {
@RPC(path = "/hello_world")
def hello: String
}

test("Create RPC interface with full paths") {
val r = Router.add[MyRPCService3]
debug(r)
val m = r.routes.find(_.path == "/v1/wvlet.airframe.http.RPCTest.MyRPCService3/hello_world")
m shouldBe defined
m.get.method shouldBe HttpMethod.POST
m.get.methodSurface.name shouldBe "hello"
}
}

0 comments on commit cdd626d

Please sign in to comment.