-
Notifications
You must be signed in to change notification settings - Fork 596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WIP] provide inspectable routes with minimal changes #2966
Changes from all commits
a93b04b
d18c8eb
ee07977
9f86e0c
f6061d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
/* | ||
* Copyright (C) 2019-2020 Lightbend Inc. <https://www.lightbend.com> | ||
*/ | ||
|
||
package akka.http.scaladsl.server | ||
|
||
import akka.http.impl.util.AkkaSpecWithMaterializer | ||
import Directives._ | ||
import DynamicDirective._ | ||
import DirectiveRoute.addByNameNullaryApply | ||
|
||
class InspectableRouteSpec extends AkkaSpecWithMaterializer { | ||
"Routes" should { | ||
"be inspectable" should { | ||
"routes from method directives" in { | ||
val route = get { post { complete("ok") } } | ||
route shouldBe an[InspectableRoute] | ||
println(route) | ||
} | ||
"route alternatives with ~" in { | ||
val route = get { complete("ok") } ~ post { complete("ok") } | ||
route shouldBe an[InspectableRoute] | ||
println(route) | ||
} | ||
"route alternatives with concat" in { | ||
val route = | ||
// semantic change here: the whole routing tree is evaluated eagerly because of the alternative | ||
// `DirectiveRoute.addByNameNullaryApply` imported above | ||
concat( | ||
get { complete("ok") }, | ||
post { complete("ok") } | ||
) | ||
route shouldBe an[InspectableRoute] | ||
} | ||
"for routes with extractions" in { | ||
val route = | ||
// if you use static, it lifts the value into a token, that's the main API change for users | ||
parameters("name").static { name: ExtractionToken[String] => | ||
get { | ||
// you can only access token values inside of dynamic blocks | ||
// it's not possible to inspect inner routes of dynamic blocks | ||
dynamic { implicit extractionCtx => | ||
// with an implicit ExtractionContext in scope you can access token values using an implicit conversion | ||
complete(s"Hello ${name: String}") | ||
} | ||
} | ||
} | ||
route shouldBe an[InspectableRoute] | ||
println(route) | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,11 +14,96 @@ import akka.stream._ | |
import akka.stream.scaladsl._ | ||
import akka.http.scaladsl.Http | ||
import akka.http.scaladsl.common.EntityStreamingSupport | ||
import akka.http.scaladsl.model.headers.{ HttpOrigin, `Access-Control-Allow-Methods`, `Access-Control-Allow-Origin` } | ||
import akka.http.scaladsl.server.util.ApplyConverter | ||
|
||
import scala.concurrent.ExecutionContext | ||
import scala.concurrent.duration._ | ||
import scala.io.StdIn | ||
|
||
object SwaggerRoute { | ||
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ | ||
|
||
import spray.json.DefaultJsonProtocol._ | ||
|
||
case class ParameterSpec( | ||
name: String, | ||
in: String | ||
) | ||
case class ResponseSpec( | ||
summary: String | ||
) | ||
case class OperationSpec( | ||
summary: String, | ||
parameters: Seq[ParameterSpec] = Vector.empty, | ||
responses: Map[String, ResponseSpec] = Map.empty | ||
) | ||
case class PathSpec( | ||
get: Option[OperationSpec] = None, | ||
post: Option[OperationSpec] = None, | ||
put: Option[OperationSpec] = None | ||
) | ||
|
||
case class OpenApi( | ||
title: String, | ||
openapi: String, | ||
paths: Map[String, PathSpec] | ||
) | ||
object OpenApi { | ||
implicit val paramaterSpecFormat = jsonFormat2(ParameterSpec.apply _) | ||
implicit val responseSpecFormat = jsonFormat1(ResponseSpec.apply _) | ||
implicit val operationSpecFormat = jsonFormat3(OperationSpec.apply _) | ||
implicit val pathSpecFormat = jsonFormat3(PathSpec.apply _) | ||
implicit val openApiFormat = jsonFormat3(OpenApi.apply _) | ||
} | ||
|
||
import Directives._ | ||
def route(forRoute: Route): Route = | ||
complete(apiDescForRoute(forRoute)) | ||
|
||
private def apiDescForRoute(route: Route): OpenApi = { | ||
def formatInfo(info: Any): String = info match { | ||
case p: Product => | ||
s"${p.productPrefix}${if (p.productArity > 0) s"(${(0 until p.productArity).map(idx => formatInfo(p.productElement(idx))).mkString(" ,")})}" else ""}" | ||
case x => x.toString | ||
} | ||
|
||
case class RoutePath(segments: Seq[DirectiveRoute], last: Route) { | ||
override def toString: String = segments.map(s => s"${s.directiveName}${s.directiveInfo.fold("")(i => s"(${formatInfo(i)})")}").mkString(" -> ") + " -> " + last.getClass.toString | ||
} | ||
def leaves(route: Route, prefix: Seq[DirectiveRoute]): Seq[RoutePath] = route match { | ||
case AlternativeRoutes(alternatives) => | ||
alternatives.flatMap(leaves(_, prefix)) | ||
case dr: DirectiveRoute => leaves(dr.child, prefix :+ dr) | ||
case last => Vector(RoutePath(prefix, last)) | ||
} | ||
leaves(route, Vector.empty).foreach(println) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What this prints currently is this:
That means, we can already somewhat enumerate the chain of directives per leaf route. We don't have enough metadata available to look into the actual path matchers. That should be the next step. |
||
|
||
// This is a static working example for the sample API. Ultimately, it should be possible to generate that directly from | ||
// inspecting the routes | ||
OpenApi( | ||
"Test API", | ||
"3.0.3", | ||
Map( | ||
"/pet" -> | ||
PathSpec( | ||
post = Some(OperationSpec("Add a new pet", responses = Map("200" -> ResponseSpec("Successfully added pet")))), | ||
put = Some(OperationSpec("Update an existing pet", responses = Map("200" -> ResponseSpec("Successfully updated pet")))) | ||
), | ||
"/pet/{petId}" -> | ||
PathSpec( | ||
get = Some( | ||
OperationSpec( | ||
"Lookup a pet by id", | ||
parameters = Vector(ParameterSpec("petId", in = "path")), | ||
responses = Map("200" -> ResponseSpec("Successfully found pet"))) | ||
) | ||
) | ||
) | ||
) | ||
} | ||
} | ||
|
||
object TestServer extends App { | ||
val testConf: Config = ConfigFactory.parseString(""" | ||
akka.loglevel = INFO | ||
|
@@ -45,7 +130,7 @@ object TestServer extends App { | |
} | ||
|
||
// format: OFF | ||
val routes = { | ||
val mainRoutes = { | ||
get { | ||
path("") { | ||
withRequestTimeout(1.milli, _ => HttpResponse( | ||
|
@@ -95,6 +180,53 @@ object TestServer extends App { | |
} | ||
// format: ON | ||
|
||
val petStoreRoutes = { | ||
import DirectiveRoute._ | ||
import DynamicDirective.dynamic | ||
|
||
pathPrefix("pet") { | ||
concat( | ||
pathEnd { | ||
concat( | ||
post { | ||
complete("posted") | ||
}, | ||
put { | ||
complete("put") | ||
} | ||
) | ||
}, | ||
path(IntNumber) { petId => | ||
concat( | ||
get { | ||
dynamic { implicit ctx => | ||
complete(s"Got [${petId}]") | ||
} | ||
}, | ||
delete { | ||
dynamic { implicit ctx => | ||
complete(s"Deleted [${petId.value}]") | ||
} | ||
} | ||
) | ||
} | ||
) | ||
} | ||
} | ||
|
||
val routes = /*mainRoutes ~ */ | ||
respondWithHeader(`Access-Control-Allow-Origin`(HttpOrigin("http://localhost"))) { | ||
petStoreRoutes ~ path("openapi") { | ||
SwaggerRoute.route(petStoreRoutes) | ||
} ~ options { | ||
import akka.http.scaladsl.model.HttpMethods._ | ||
complete(HttpResponse( | ||
status = 204, | ||
headers = `Access-Control-Allow-Methods`(POST, PUT, DELETE, GET) :: Nil | ||
)) | ||
} | ||
} | ||
|
||
val bindingFuture = Http().bindAndHandle(routes, interface = "0.0.0.0", port = 8080) | ||
|
||
println(s"Server online at http://0.0.0.0:8080/\nPress RETURN to stop...") | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
# New API in @DoNotInherit class | ||
ProblemFilters.exclude[ReversedMissingMethodProblem]("akka.http.scaladsl.server.RequestContext.tokenValue") | ||
ProblemFilters.exclude[ReversedMissingMethodProblem]("akka.http.scaladsl.server.RequestContext.addTokenValue") | ||
|
||
# Internal API | ||
ProblemFilters.exclude[DirectMissingMethodProblem]("akka.http.scaladsl.server.RequestContextImpl.this") | ||
|
||
# FIXME: need to check if that's a real problem | ||
ProblemFilters.exclude[IncompatibleSignatureProblem]("akka.http.scaladsl.server.directives.ParameterDirectives#ParamMagnet.apply") | ||
|
||
# FIXME: unclear | ||
ProblemFilters.exclude[IncompatibleSignatureProblem]("akka.http.scaladsl.server.PathMatcher.compose") | ||
ProblemFilters.exclude[IncompatibleSignatureProblem]("akka.http.scaladsl.server.PathMatcher.andThen") | ||
|
||
# Addition to @DoNotExtend | ||
ProblemFilters.exclude[ReversedMissingMethodProblem]("akka.http.scaladsl.server.PathMatchers.Append") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
/* | ||
* Copyright (C) 2019-2020 Lightbend Inc. <https://www.lightbend.com> | ||
*/ | ||
|
||
package akka.http.scaladsl.server | ||
|
||
import akka.http.scaladsl.util.FastFuture | ||
import FastFuture._ | ||
|
||
import scala.concurrent.Future | ||
import scala.runtime.ScalaRunTime | ||
|
||
sealed trait InspectableRoute extends Route with Product { | ||
def name: String | ||
def children: Seq[Route] | ||
|
||
// FIXME: only there to help intellij | ||
def apply(ctx: RequestContext): Future[RouteResult] | ||
|
||
override def toString(): String = ScalaRunTime._toString(this) | ||
} | ||
final case class AlternativeRoutes(alternatives: Seq[Route]) extends InspectableRoute { | ||
def name: String = "concat" | ||
def children: Seq[Route] = alternatives | ||
|
||
def apply(ctx: RequestContext): Future[RouteResult] = { | ||
import ctx.executionContext | ||
def tryNext(remaining: List[Route], rejections: Vector[Rejection]): Future[RouteResult] = remaining match { | ||
case head :: tail => | ||
head(ctx).fast.flatMap { | ||
case x: RouteResult.Complete => FastFuture.successful(x) | ||
case RouteResult.Rejected(newRejections) => tryNext(tail, rejections ++ newRejections) | ||
} | ||
case Nil => FastFuture.successful(RouteResult.Rejected(rejections)) | ||
} | ||
tryNext(alternatives.toList, Vector.empty) | ||
} | ||
} | ||
sealed trait DirectiveRoute extends InspectableRoute { | ||
def implementation: Route | ||
def directiveName: String | ||
def directiveInfo: Option[AnyRef] | ||
def child: Route | ||
|
||
def apply(ctx: RequestContext): Future[RouteResult] = implementation(ctx) | ||
def children: Seq[Route] = child :: Nil | ||
def name: String = s"Directive($directiveName)" | ||
} | ||
|
||
object DirectiveRoute { | ||
def wrap(implementation: Route, child: Route, metaInfo: Option[DirectiveMetaInformation]): DirectiveRoute = implementation match { | ||
case i: Impl => i.copy(child = child, info = metaInfo) | ||
case x => Impl(x, child, metaInfo) | ||
} | ||
|
||
implicit def addByNameNullaryApply(directive: Directive0): Route => Route = | ||
inner => { | ||
val impl = directive.tapply(_ => inner) | ||
wrap(impl, inner, directive.metaInformation) | ||
} | ||
|
||
// for some reason these seem to take precendence before Directive.addDirectiveApply | ||
implicit def addDirective1Apply[T](directive: Directive1[T]): (ExtractionToken[T] => Route) => Route = | ||
{ innerCons => | ||
val tok = ExtractionToken.create[T] | ||
val inner = innerCons(tok) | ||
val real = directive.tapply { | ||
case Tuple1(t) => ctx => | ||
inner(ctx.addTokenValue(tok, t)) | ||
} | ||
DirectiveRoute.wrap(real, inner, directive.metaInformation) | ||
} | ||
// TODO: add for more parameters with sbt-boilerplate | ||
|
||
private final case class Impl( | ||
implementation: Route, | ||
child: Route, | ||
info: Option[DirectiveMetaInformation]) extends DirectiveRoute { | ||
override def directiveName: String = info.fold("<anon>")(_.name) | ||
override def directiveInfo: Option[AnyRef] = info.flatMap(_.info) | ||
} | ||
} | ||
|
||
sealed trait ExtractionToken[+T] { | ||
def value(implicit ctx: ExtractionContext): T = ctx.extract(this) | ||
} | ||
object ExtractionToken { | ||
// syntax sugar | ||
implicit def autoExtract[T](token: ExtractionToken[T])(implicit ctx: ExtractionContext): T = ctx.extract(token) | ||
|
||
def create[T]: ExtractionToken[T] = new ExtractionToken[T] {} | ||
} | ||
sealed trait ExtractionContext { | ||
def extract[T](token: ExtractionToken[T]): T | ||
} | ||
object DynamicDirective { | ||
def dynamic: (ExtractionContext => Route) => Route = | ||
inner => ctx => inner { | ||
new ExtractionContext { | ||
override def extract[T](token: ExtractionToken[T]): T = ctx.tokenValue(token) | ||
} | ||
}(ctx) | ||
|
||
implicit class AddStatic[T](d: Directive1[T]) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems, this one isn't necessary at all. Adding implicits like the above |
||
def static: Directive1[ExtractionToken[T]] = | ||
Directive { innerCons => | ||
val tok = ExtractionToken.create[T] | ||
val inner = innerCons(Tuple1(tok)) | ||
val real = d { t => ctx => | ||
inner(ctx.addTokenValue(tok, t)) | ||
} | ||
DirectiveRoute.wrap(real, inner, d.metaInformation) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, what we get here per leaf route is an ordered chain of all (the metadata of) the directives that are applied for that leaf route. Once we expose enough metadata, we should be able to generate openapi specifications but also do "static" (= at route building time) analytics of the routing tree. Even with this simple prototype we could prevent that people would be accidentally nesting
path
or method directives.