From 193dda6e01dfad160682e804d15d6f40c536c84e Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 12 Nov 2014 14:37:28 +0100 Subject: [PATCH] !htp #15927 port `MarshallingDirectives` from spray --- .../MarshallingDirectivesSpec.scala | 157 ++++++++++++++++++ .../scala/akka/http/server/Directives.scala | 2 +- .../scala/akka/http/server/Rejection.scala | 9 +- .../akka/http/server/RejectionHandler.scala | 10 +- .../akka/http/server/RequestContextImpl.scala | 8 +- .../directives/MarshallingDirectives.scala | 64 +++++++ 6 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 akka-http-tests/src/test/scala/akka/http/server/directives/MarshallingDirectivesSpec.scala create mode 100644 akka-http/src/main/scala/akka/http/server/directives/MarshallingDirectives.scala diff --git a/akka-http-tests/src/test/scala/akka/http/server/directives/MarshallingDirectivesSpec.scala b/akka-http-tests/src/test/scala/akka/http/server/directives/MarshallingDirectivesSpec.scala new file mode 100644 index 0000000000..00334d0282 --- /dev/null +++ b/akka-http-tests/src/test/scala/akka/http/server/directives/MarshallingDirectivesSpec.scala @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2009-2014 Typesafe Inc. + */ + +package akka.http.server +package directives + +import scala.xml.NodeSeq +import akka.http.marshallers.xml.ScalaXmlSupport +import akka.http.unmarshalling._ +import akka.http.marshalling._ +import akka.http.model._ +import akka.http.marshallers.sprayjson.SprayJsonSupport._ +import MediaTypes._ +import HttpCharsets._ +import headers._ +import spray.json.DefaultJsonProtocol._ + +class MarshallingDirectivesSpec extends RoutingSpec { + import ScalaXmlSupport._ + + private val iso88592 = HttpCharsets.getForKey("iso-8859-2").get + implicit val IntUnmarshaller: FromEntityUnmarshaller[Int] = + nodeSeqUnmarshaller(ContentTypeRange(`text/xml`, iso88592), `text/html`) map { + case NodeSeq.Empty ⇒ throw Unmarshaller.NoContentException + case x ⇒ x.text.toInt + } + + implicit val IntMarshaller: ToEntityMarshaller[Int] = + Marshaller.oneOf(ContentType(`application/xhtml+xml`), ContentType(`text/xml`, `UTF-8`)) { contentType ⇒ + nodeSeqMarshaller(contentType).wrap(contentType) { (i: Int) ⇒ { i } } + } + + "The 'entityAs' directive" should { + "extract an object from the requests entity using the in-scope Unmarshaller" in { + Put("/",

cool

) ~> { + entity(as[NodeSeq]) { echoComplete } + } ~> check { responseAs[String] shouldEqual "

cool

" } + } + "return a RequestEntityExpectedRejection rejection if the request has no entity" in { + Put() ~> { + entity(as[Int]) { echoComplete } + } ~> check { rejection shouldEqual RequestEntityExpectedRejection } + } + "return an UnsupportedRequestContentTypeRejection if no matching unmarshaller is in scope" in { + Put("/", HttpEntity(`text/css`, "

cool

")) ~> { + entity(as[NodeSeq]) { echoComplete } + } ~> check { + rejection shouldEqual UnsupportedRequestContentTypeRejection(Set(`text/xml`, `application/xml`, `text/html`, `application/xhtml+xml`)) + } + Put("/", HttpEntity(ContentType(`text/xml`, `UTF-16`), "26")) ~> { + entity(as[Int]) { echoComplete } + } ~> check { + rejection shouldEqual UnsupportedRequestContentTypeRejection(Set(ContentTypeRange(`text/xml`, iso88592), `text/html`)) + } + } + "cancel UnsupportedRequestContentTypeRejections if a subsequent `entity` directive succeeds" in { + Put("/", HttpEntity(`text/plain`, "yeah")) ~> { + entity(as[NodeSeq]) { _ ⇒ completeOk } ~ + entity(as[String]) { _ ⇒ validate(false, "Problem") { completeOk } } + } ~> check { rejection shouldEqual ValidationRejection("Problem") } + } + "extract an Option[T] from the requests entity using the in-scope Unmarshaller" in { + Put("/",

cool

) ~> { + entity(as[Option[NodeSeq]]) { echoComplete } + } ~> check { responseAs[String] shouldEqual "Some(

cool

)" } + } + "extract an Option[T] as None if the request has no entity" in { + Put() ~> { + entity(as[Option[Int]]) { echoComplete } + } ~> check { responseAs[String] shouldEqual "None" } + } + "return an UnsupportedRequestContentTypeRejection if no matching unmarshaller is in scope (for Option[T]s)" in { + Put("/", HttpEntity(`text/css`, "

cool

")) ~> { + entity(as[Option[NodeSeq]]) { echoComplete } + } ~> check { + rejection shouldEqual UnsupportedRequestContentTypeRejection(Set(`text/xml`, `application/xml`, `text/html`, `application/xhtml+xml`)) + } + } + "properly extract with a super-unmarshaller" in { + case class Person(name: String) + val jsonUnmarshaller: FromEntityUnmarshaller[Person] = jsonFormat1(Person) + val xmlUnmarshaller: FromEntityUnmarshaller[Person] = + ScalaXmlSupport.nodeSeqUnmarshaller(`text/xml`).map(seq ⇒ Person(seq.text)) + + implicit val unmarshaller = Unmarshaller.firstOf(jsonUnmarshaller, xmlUnmarshaller) + + val route = entity(as[Person]) { echoComplete } + + Put("/", HttpEntity(`text/xml`, "Peter Xml")) ~> route ~> check { + responseAs[String] shouldEqual "Person(Peter Xml)" + } + Put("/", HttpEntity(`application/json`, """{ "name": "Paul Json" }""")) ~> route ~> check { + responseAs[String] shouldEqual "Person(Paul Json)" + } + Put("/", HttpEntity(`text/plain`, """name = Sir Text }""")) ~> route ~> check { + rejection shouldEqual UnsupportedRequestContentTypeRejection(Set(`application/json`, `text/xml`)) + } + } + } + + "The 'completeWith' directive" should { + "provide a completion function converting custom objects to an HttpEntity using the in-scope marshaller" in { + Get() ~> completeWith(instanceOf[Int]) { prod ⇒ prod(42) } ~> check { + responseEntity shouldEqual HttpEntity(ContentType(`application/xhtml+xml`, `UTF-8`), "42") + } + } + "return a UnacceptedResponseContentTypeRejection rejection if no acceptable marshaller is in scope" in { + Get() ~> Accept(`text/css`) ~> completeWith(instanceOf[Int]) { prod ⇒ prod(42) } ~> check { + rejection shouldEqual UnacceptedResponseContentTypeRejection(Set(`application/xhtml+xml`, ContentType(`text/xml`, `UTF-8`))) + } + } + "convert the response content to an accepted charset" in { + Get() ~> `Accept-Charset`(`UTF-8`) ~> completeWith(instanceOf[String]) { prod ⇒ prod("Hällö") } ~> check { + responseEntity shouldEqual HttpEntity(ContentType(`text/plain`, `UTF-8`), "Hällö") + } + } + } + + "The 'handleWith' directive" should { + def times2(x: Int) = x * 2 + + "support proper round-trip content unmarshalling/marshalling to and from a function" in ( + Put("/", HttpEntity(`text/html`, "42")) ~> Accept(`text/xml`) ~> handleWith(times2) + ~> check { responseEntity shouldEqual HttpEntity(ContentType(`text/xml`, `UTF-8`), "84") }) + + "result in UnsupportedRequestContentTypeRejection rejection if there is no unmarshaller supporting the requests charset" in ( + Put("/", HttpEntity(`text/xml`, "42")) ~> Accept(`text/xml`) ~> handleWith(times2) + ~> check { + rejection shouldEqual UnsupportedRequestContentTypeRejection(Set(ContentTypeRange(`text/xml`, iso88592), `text/html`)) + }) + + "result in an UnacceptedResponseContentTypeRejection rejection if there is no marshaller supporting the requests Accept-Charset header" in ( + Put("/", HttpEntity(`text/html`, "42")) ~> addHeaders(Accept(`text/xml`), `Accept-Charset`(`UTF-16`)) ~> + handleWith(times2) ~> check { + rejection shouldEqual UnacceptedResponseContentTypeRejection(Set(`application/xhtml+xml`, ContentType(`text/xml`, `UTF-8`))) + }) + } + + "The marshalling infrastructure for JSON" should { + import spray.json._ + case class Foo(name: String) + implicit val fooFormat = jsonFormat1(Foo) + val foo = Foo("Hällö") + + "render JSON with UTF-8 encoding if no `Accept-Charset` request header is present" in { + Get() ~> complete(foo) ~> check { + responseEntity shouldEqual HttpEntity(ContentType(`application/json`, `UTF-8`), foo.toJson.prettyPrint) + } + } + "reject JSON rendering if an `Accept-Charset` request header requests a non-UTF-8 encoding" in { + Get() ~> `Accept-Charset`(`ISO-8859-1`) ~> complete(foo) ~> check { + rejection shouldEqual UnacceptedResponseContentTypeRejection(Set(ContentType(`application/json`, `UTF-8`))) + } + } + } +} diff --git a/akka-http/src/main/scala/akka/http/server/Directives.scala b/akka-http/src/main/scala/akka/http/server/Directives.scala index 9306135e1d..29a0e84302 100644 --- a/akka-http/src/main/scala/akka/http/server/Directives.scala +++ b/akka-http/src/main/scala/akka/http/server/Directives.scala @@ -22,7 +22,7 @@ trait Directives extends RouteConcatenation with FutureDirectives with HeaderDirectives with HostDirectives - //with MarshallingDirectives + with MarshallingDirectives with MethodDirectives with MiscDirectives with ParameterDirectives diff --git a/akka-http/src/main/scala/akka/http/server/Rejection.scala b/akka-http/src/main/scala/akka/http/server/Rejection.scala index 6ce710c05e..b2556fde04 100644 --- a/akka-http/src/main/scala/akka/http/server/Rejection.scala +++ b/akka-http/src/main/scala/akka/http/server/Rejection.scala @@ -4,10 +4,9 @@ package akka.http.server -import akka.http.model._ -import akka.http.model.headers.{ HttpChallenge, ByteRange, HttpEncoding } - import scala.collection.immutable +import akka.http.model._ +import headers._ /** * A rejection encapsulates a specific reason why a Route was not able to handle a request. Rejections are gathered @@ -71,7 +70,7 @@ case class MalformedHeaderRejection(headerName: String, errorMsg: String, * Rejection created by unmarshallers. * Signals that the request was rejected because the requests content-type is unsupported. */ -case class UnsupportedRequestContentTypeRejection(errorMsg: String) extends Rejection +case class UnsupportedRequestContentTypeRejection(supported: Set[ContentTypeRange]) extends Rejection /** * Rejection created by decoding filters. @@ -110,7 +109,7 @@ case object RequestEntityExpectedRejection extends Rejection * Signals that the request was rejected because the service is not capable of producing a response entity whose * content type is accepted by the client */ -case class UnacceptedResponseContentTypeRejection(supported: Seq[ContentType]) extends Rejection +case class UnacceptedResponseContentTypeRejection(supported: Set[ContentType]) extends Rejection /** * Rejection created by encoding filters. diff --git a/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala b/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala index 2ad4615194..bec186da84 100644 --- a/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala +++ b/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala @@ -100,16 +100,16 @@ object RejectionHandler { complete(NotAcceptable, "Resource representation is only available with these Content-Types:\n" + supported.map(_.value).mkString("\n")) case rejections @ (UnacceptedResponseEncodingRejection(_) +: _) ⇒ - val supported = rejections.collect { case UnacceptedResponseEncodingRejection(supported) ⇒ supported } + val supported = rejections.collect { case UnacceptedResponseEncodingRejection(x) ⇒ x } complete(NotAcceptable, "Resource representation is only available with these Content-Encodings:\n" + supported.map(_.value).mkString("\n")) case rejections @ (UnsupportedRequestContentTypeRejection(_) +: _) ⇒ - val supported = rejections.collect { case UnsupportedRequestContentTypeRejection(supported) ⇒ supported } - complete(UnsupportedMediaType, "There was a problem with the requests Content-Type:\n" + supported.mkString(" or ")) + val supported = rejections.collect { case UnsupportedRequestContentTypeRejection(x) ⇒ x } + complete(UnsupportedMediaType, "The request's Content-Type is not supported. Expected:\n" + supported.mkString(" or ")) case rejections @ (UnsupportedRequestEncodingRejection(_) +: _) ⇒ - val supported = rejections.collect { case UnsupportedRequestEncodingRejection(supported) ⇒ supported } - complete(BadRequest, "The requests Content-Encoding must be one the following:\n" + supported.map(_.value).mkString("\n")) + val supported = rejections.collect { case UnsupportedRequestEncodingRejection(x) ⇒ x } + complete(BadRequest, "The request's Content-Encoding is not supported. Expected:\n" + supported.map(_.value).mkString(" or ")) case ValidationRejection(msg, _) +: _ ⇒ complete(BadRequest, msg) diff --git a/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala b/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala index 7d90e7de33..1388cfc9b8 100644 --- a/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala +++ b/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala @@ -8,7 +8,7 @@ import akka.stream.FlowMaterializer import scala.concurrent.{ Future, ExecutionContext } import akka.event.LoggingAdapter -import akka.http.marshalling.ToResponseMarshallable +import akka.http.marshalling.{ Marshal, ToResponseMarshallable } import akka.http.util.FastFuture import akka.http.model._ import FastFuture._ @@ -33,7 +33,11 @@ private[http] class RequestContextImpl( override def complete(trm: ToResponseMarshallable): Future[RouteResult] = trm(request)(executionContext) .fast.map(res ⇒ RouteResult.Complete(res))(executionContext) - .fast.recover { case RejectionError(rej) ⇒ RouteResult.Rejected(rej :: Nil) }(executionContext) + .fast.recover { + case Marshal.UnacceptableResponseContentTypeException(supported) ⇒ + RouteResult.Rejected(UnacceptedResponseContentTypeRejection(supported) :: Nil) + case RejectionError(rej) ⇒ RouteResult.Rejected(rej :: Nil) + }(executionContext) override def reject(rejections: Rejection*): Future[RouteResult] = FastFuture.successful(RouteResult.Rejected(rejections.toVector)) diff --git a/akka-http/src/main/scala/akka/http/server/directives/MarshallingDirectives.scala b/akka-http/src/main/scala/akka/http/server/directives/MarshallingDirectives.scala new file mode 100644 index 0000000000..402d45fcfc --- /dev/null +++ b/akka-http/src/main/scala/akka/http/server/directives/MarshallingDirectives.scala @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2009-2014 Typesafe Inc. + */ + +package akka.http.server +package directives + +import scala.concurrent.Promise +import scala.util.{ Failure, Success } +import akka.http.marshalling.ToResponseMarshaller +import akka.http.unmarshalling.{ Unmarshaller, FromRequestUnmarshaller } +import akka.http.util._ + +trait MarshallingDirectives { + import BasicDirectives._ + import FutureDirectives._ + import RouteDirectives._ + + /** + * Unmarshalls the requests entity to the given type passes it to its inner Route. + * If there is a problem with unmarshalling the request is rejected with the [[Rejection]] + * produced by the unmarshaller. + */ + def entity[T](um: FromRequestUnmarshaller[T]): Directive1[T] = + extractRequest.flatMap[Tuple1[T]] { request ⇒ + onComplete(um(request)) flatMap { + case Success(value) ⇒ provide(value) + case Failure(Unmarshaller.NoContentException) ⇒ reject(RequestEntityExpectedRejection) + case Failure(Unmarshaller.UnsupportedContentTypeException(x)) ⇒ reject(UnsupportedRequestContentTypeRejection(x)) + case Failure(x) ⇒ reject(MalformedRequestContentRejection(x.getMessage.nullAsEmpty, Option(x.getCause))) + } + } & cancelRejections(RequestEntityExpectedRejection.getClass, classOf[UnsupportedRequestContentTypeRejection]) + + /** + * Returns the in-scope [[FromRequestUnmarshaller]] for the given type. + */ + def as[T](implicit um: FromRequestUnmarshaller[T]) = um + + /** + * Uses the marshaller for the given type to produce a completion function that is passed to its inner function. + * You can use it do decouple marshaller resolution from request completion. + */ + def completeWith[T](marshaller: ToResponseMarshaller[T])(inner: (T ⇒ Unit) ⇒ Unit): Route = { ctx ⇒ + import ctx.executionContext + implicit val m = marshaller + val promise = Promise[T]() + inner(promise.success(_)) + ctx.complete(promise.future) + } + + /** + * Returns the in-scope Marshaller for the given type. + */ + def instanceOf[T](implicit m: ToResponseMarshaller[T]): ToResponseMarshaller[T] = m + + /** + * Completes the request using the given function. The input to the function is produced with the in-scope + * entity unmarshaller and the result value of the function is marshalled with the in-scope marshaller. + */ + def handleWith[A, B](f: A ⇒ B)(implicit um: FromRequestUnmarshaller[A], m: ToResponseMarshaller[B]): Route = + entity(um) { a ⇒ complete(f(a)) } +} + +object MarshallingDirectives extends MarshallingDirectives