Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[WIP] provide inspectable routes with minimal changes #2966

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand Up @@ -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) {
Copy link
Contributor Author

@jrudolph jrudolph Apr 30, 2020

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.

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)
Copy link
Contributor Author

@jrudolph jrudolph Apr 30, 2020

Choose a reason for hiding this comment

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

What this prints currently is this:

[info] pathPrefix(<function1>) -> pathEnd -> post -> class akka.http.scaladsl.server.StandardRoute$$anon$1
[info] pathPrefix(<function1>) -> pathEnd -> put -> class akka.http.scaladsl.server.StandardRoute$$anon$1
[info] pathPrefix(<function1>) -> path(<function1>) -> get -> class akka.http.scaladsl.server.DynamicDirective$$$Lambda$310/1221027335
[info] pathPrefix(<function1>) -> path(<function1>) -> delete -> class akka.http.scaladsl.server.DynamicDirective$$$Lambda$310/1221027335

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
Expand All @@ -45,7 +130,7 @@ object TestServer extends App {
}

// format: OFF
val routes = {
val mainRoutes = {
get {
path("") {
withRequestTimeout(1.milli, _ => HttpResponse(
Expand Down Expand Up @@ -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...")
Expand Down
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")
18 changes: 18 additions & 0 deletions akka-http/src/main/scala/akka/http/scaladsl/server/Directive.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import akka.http.scaladsl.util.FastFuture
import akka.http.scaladsl.util.FastFuture._
import akka.http.impl.util._

final case class DirectiveMetaInformation(name: String, info: Option[AnyRef])

// FIXME: in the best case we can move that into Directive.apply for less indirection
private class DirectiveWithChangedMetaInformation[L](original: Directive[L], _metaInformation: DirectiveMetaInformation)(implicit ev: Tuple[L]) extends Directive[L] {
override def tapply(f: L => Route): Route = original.tapply(f)
override def metaInformation: Option[DirectiveMetaInformation] = Some(_metaInformation)
override def withMetaInformation(newInformation: DirectiveMetaInformation): Directive[L] = new DirectiveWithChangedMetaInformation[L](original, newInformation)
}

/**
* A directive that provides a tuple of values of type `L` to create an inner route.
*/
Expand All @@ -27,6 +36,15 @@ abstract class Directive[L](implicit val ev: Tuple[L]) {
*/
def tapply(f: L => Route): Route
//#basic

def metaInformation: Option[DirectiveMetaInformation] = None
def withMetaInformation(newInformation: DirectiveMetaInformation): Directive[L] =
new DirectiveWithChangedMetaInformation[L](this, newInformation)
def named(name: String): Directive[L] = mapMetaInformation(_.copy(name = name))
def info(info: AnyRef): Directive[L] = mapMetaInformation(_.copy(info = Some(info)))
def mapMetaInformation(f: DirectiveMetaInformation => DirectiveMetaInformation): Directive[L] =
withMetaInformation(f(metaInformation.getOrElse(DirectiveMetaInformation("<anon>", None))))

/**
* Joins two directives into one which runs the second directive if the first one rejects.
*
Expand Down
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]) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 addByNameNullaryApply and addDirective1Apply means that we can change the semantics of route building with a single import that brings those implicits into scope.

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)
}
}
}
Loading