Permalink
Browse files

Adds HeadAction class to play.api.mvc.controllers. Closes #2280

* This is an RFC2616 compliant impementation of the HEAD verb (Provides headers for a GET route)

* Adds HttpVerbs object to StandardValues, providing non-MagicStrings lookup of GET/POST/HEAD/etc.

* Update "GET" and "HEAD" references in ContentTypes to use HttpVerbs instead
  • Loading branch information...
1 parent 6e5f5a4 commit b79d4d80e56bea4e63f9de9f4a7046afd4250645 @Damiya Damiya committed Feb 10, 2014
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2009-2013 Typesafe Inc. <http://www.typesafe.com>
+ */
+
+package play.it.action
+
+import play.api.test._
+import scala.concurrent.ExecutionContext.Implicits.global
+import play.it.tools.HttpBinApplication._
+import play.api.libs.ws.WSResponse
+import com.ning.http.client.providers.netty.NettyResponse
+import play.api.mvc._
+import play.api.http.HeaderNames
+import play.api.libs.iteratee.Enumerator
+import java.util.concurrent.atomic.AtomicBoolean
+import play.api.test.TestServer
+import play.api.test.FakeApplication
+
+
+object HeadActionSpec extends PlaySpecification with WsTestClient with Results with HeaderNames {
+ def route(verb: String, path: String)(handler: EssentialAction): PartialFunction[(String, String), Handler] = {
+ case (v, p) if v == verb && p == path => handler
+ }
+
+ "HEAD requests" should {
+ implicit val port: Port = testServerPort
+
+ val manualContentSize = route("GET", "/manualContentSize") {
+ Action { request =>
+ Ok("The Itsy Bitsy Spider Went Up the Water Spout").withHeaders(CONTENT_LENGTH -> "5")
+ }
+ }
+
+ val chunkedResponse = route("GET", "/chunked") {
+ Action { request =>
+ Ok.chunked(Enumerator("a", "b", "c"))
+ }
+ }
+
+ def withServer[T](block: => T): T = {
+ // Routes from HttpBinApplication
+ val routes =
+ get // GET /get
+ .orElse(patch) // PATCH /patch
+ .orElse(post) // POST /post
+ .orElse(put) // PUT /put
+ .orElse(delete) // DELETE /delete
+ .orElse(stream) // GET /stream/0
+ .orElse(manualContentSize) // GET /manualContentSize
+ .orElse(chunkedResponse) // GET /chunked
+ running(TestServer(port, FakeApplication(withRoutes = routes)))(block)
+ }
+
+ def serverWithAction[T](action: EssentialAction)(block: => T): T = {
+ running(TestServer(port, FakeApplication(
+ withRoutes = {
+ case _ => action
+ })))(block)
+ }
+
+ "return 200 in response to a URL with a GET handler" in withServer {
+ val result = await(wsUrl("/get").head())
+
+ result.status must_== OK
+ }
+
+ "return an empty body" in withServer {
+ val result = await(wsUrl("/get").head())
+
+ result.body.length must_== 0
+ }
+
+ "match the headers of an equivalent GET" in withServer {
+ val collectedFutures = for {
+ headResponse <- wsUrl("/get").head()
+ getResponse <- wsUrl("/get").get()
+ } yield List(headResponse, getResponse)
+
+ val responses = await(collectedFutures)
+
+ val headHeaders = responses(0).underlying[NettyResponse].getHeaders
+ val getHeaders = responses(1).underlying[NettyResponse].getHeaders
+
+ headHeaders must_== getHeaders
+ }
+
+ "return 404 in response to a URL without an associated GET handler" in withServer {
+ val collectedFutures = for {
+ putRoute <- wsUrl("/put").head()
+ patchRoute <- wsUrl("/patch").head()
+ postRoute <- wsUrl("/post").head()
+ deleteRoute <- wsUrl("/delete").head()
+ } yield List(putRoute, patchRoute, postRoute, deleteRoute)
+
+ val responseList = await(collectedFutures)
+
+ foreach(responseList)((_: WSResponse).status must_== NOT_FOUND)
+ }
+
+ "clean up any onDoneEnumerating callbacks" in {
+ val wasCalled = new AtomicBoolean()
+
+ val action = Action {
+ Ok.chunked(Enumerator("a", "b", "c").onDoneEnumerating(wasCalled.set(true)))
+ }
+ serverWithAction(action) {
+ await(wsUrl("/get").head())
+ wasCalled.get() must be_==(true).eventually
+ }
+ }
+
+ "respect deliberately set Content-Length headers" in withServer {
+ val result = await(wsUrl("/manualContentSize").head())
+
+ result.header(CONTENT_LENGTH) must beSome("5")
+ }
+
+ "omit Content-Length for chunked responses" in withServer {
+ val response = await(wsUrl("/chunked").head())
+
+ response.body must_== ""
+ response.header(CONTENT_LENGTH) must beNone
+ }
+
+ }
+}
@@ -7,6 +7,8 @@ import play.api.mvc._
import java.io.File
import scala.util.control.NonFatal
import scala.concurrent.Future
+import play.api.controllers.HeadAction
+import play.api.http.HttpVerbs
/**
* Defines an application’s global settings.
@@ -75,11 +77,20 @@ trait GlobalSettings {
* Default is: route, tag request, then apply filters
*/
def onRequestReceived(request: RequestHeader): (RequestHeader, Handler) = {
+ val notFoundHandler = Action.async(BodyParsers.parse.empty)(_ => this.onHandlerNotFound(request))
val (routedRequest, handler) = onRouteRequest(request) map {
case handler: RequestTaggingHandler => (handler.tagRequest(request), handler)
case otherHandler => (request, otherHandler)
} getOrElse {
- (request, Action.async(BodyParsers.parse.empty)(_ => this.onHandlerNotFound(request)))
+ // We automatically permit HEAD requests against any GETs without the need to
+ // add an explicit mapping in Routes
+ val missingHandler: Handler = request.method match {
+ case HttpVerbs.HEAD =>
+ new HeadAction(onRouteRequest(request.copy(method = HttpVerbs.GET)).getOrElse(notFoundHandler))
+ case _ =>
+ notFoundHandler
+ }
+ (request, missingHandler)
}
(routedRequest, doFilter(rh => handler)(routedRequest))
@@ -112,8 +123,9 @@ trait GlobalSettings {
* @return an action to handle this request - if no action is returned, a 404 not found result will be sent to client
* @see onHandlerNotFound
*/
- def onRouteRequest(request: RequestHeader): Option[Handler] = Play.maybeApplication.flatMap(_.routes.flatMap { router =>
- router.handlerFor(request)
+ def onRouteRequest(request: RequestHeader): Option[Handler] = Play.maybeApplication.flatMap(_.routes.flatMap {
+ router =>
+ router.handlerFor(request)
})
/**
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2009-2013 Typesafe Inc. <http://www.typesafe.com>
+ */
+
+package play.api.controllers
+
+import play.api.mvc._
+import play.api.libs.iteratee._
+import play.api.http.{ HttpProtocol, HeaderNames, DefaultWriteables }
+import play.core.server.netty.NettyResultStreamer.UsesTransferEncoding
+import org.jboss.netty.buffer.ChannelBuffers
+import scala.Some
+import play.api.mvc.SimpleResult
+import scala.concurrent.Future
+
+/**
+ * RFC2616-compatible HEAD implementation: provides a full header set and empty body for a given GET resource
+ *
+ * @param handler Action for the relevant GET path.
+ */
+class HeadAction(handler: Handler) extends EssentialAction with DefaultWriteables with HeaderNames with HttpProtocol {
+ def apply(requestHeader: RequestHeader): Iteratee[Array[Byte], SimpleResult] = {
+ def bodyIterator: Iteratee[Array[Byte], SimpleResult] = handler.asInstanceOf[EssentialAction](requestHeader)
+
+ def createHeadResult(result: SimpleResult): Future[SimpleResult] = result match {
+ // Respond immediately for bodies which have finished evaluating
+ case UsesTransferEncoding() | HasContentLength() =>
+ val newResult = SimpleResult(result.header, Enumerator(Array.emptyByteArray), result.connection)
+ Future.successful(newResult)
+ // We need to evaluate the body further to determine appropriate headers (Content-Length or Transfer-Encoding)
+ case _ =>
+ result.body |>>> singleChunkIteratee(result, requestHeader.version)
+ }
+
+ import play.core.Execution.Implicits.internalContext
+
+ bodyIterator.mapM(result =>
+ createHeadResult(result)
+ )
+ }
+
+ /**
+ * Creates an Iteratee that will evaluate at most one chunk of a given resource
+ * @param result Contains initial result information
+ * @param httpVersion HTTP Version from the RequestHeader to ensure proper response headers
+ * @return
+ */
+ def singleChunkIteratee(result: SimpleResult, httpVersion: String): Iteratee[Array[Byte], SimpleResult] = {
+ lazy val resultWithEmptyBody = SimpleResult(result.header, Enumerator(Array.emptyByteArray), result.connection)
+
+ def takeUpToOneChunk(chunk: Option[Array[Byte]]): Iteratee[Array[Byte], Either[Array[Byte], Option[Array[Byte]]]] = Cont {
+ // We have a second chunk, fail with left
+ case in @ Input.El(data) if chunk.isDefined => Done(Left(chunk.get), in)
+ // This is the first chunk
+ case Input.El(data) => takeUpToOneChunk(Some(data))
+ case Input.Empty => takeUpToOneChunk(chunk)
+ // We reached EOF, which means we either have one or zero chunks
+ case Input.EOF => Done(Right(chunk))
+ }
+
+ import play.api.libs.iteratee.Execution.Implicits.trampoline
+
+ takeUpToOneChunk(None).flatMap {
+ // Single chunk response
+ case Right(chunk) =>
+ // Push the chunk into a buffer to measure its length and use that as the correct Content-Length
+ // for the head request
+ val buffer = chunk.map(ChannelBuffers.wrappedBuffer).getOrElse(ChannelBuffers.EMPTY_BUFFER)
+
+ val newResult = resultWithEmptyBody.withHeaders(
+ CONTENT_LENGTH -> buffer.readableBytes().toString
+ )
+
+ Done[Array[Byte], SimpleResult](newResult)
+
+ case Left(chunk) =>
+ // The body is in multiple chunks.
+ val newResult = httpVersion match {
+ // HTTP 1.0 doesn't support chunked transfer
+ case HTTP_1_0 =>
+ resultWithEmptyBody
+ case HTTP_1_1 =>
+ resultWithEmptyBody.withHeaders(
+ TRANSFER_ENCODING -> CHUNKED
+ )
+ }
+
+ Done[Array[Byte], SimpleResult](newResult)
+ }
+ }
+}
+
+/**
+ * Extractor that determines whether a content-length has been set on a result
+ */
+object HasContentLength extends HeaderNames {
+ def unapply(result: SimpleResult): Boolean = result.header.headers.contains(CONTENT_LENGTH)
+}
@@ -70,6 +70,24 @@ trait ContentTypes {
}
+/**
+ * Standard HTTP Verbs
+ */
+object HttpVerbs extends HttpVerbs
+
+/**
+ * Standard HTTP Verbs
+ */
+trait HttpVerbs {
+ val GET = "GET"
+ val POST = "POST"
+ val PUT = "PUT"
+ val PATCH = "PATCH"
+ val DELETE = "DELETE"
+ val HEAD = "HEAD"
+ val OPTIONS = "OPTIONS"
+}
+
/** Common HTTP MIME types */
object MimeTypes extends MimeTypes
@@ -18,6 +18,7 @@ import scala.collection.mutable.ListBuffer
import scalax.io.Resource
import java.util.Locale
import scala.util.control.NonFatal
+import play.api.http.HttpVerbs
/**
* A request body that adapts automatically according the request Content-Type.
@@ -544,7 +545,7 @@ trait BodyParsers {
def anyContent: BodyParser[AnyContent] = BodyParser("anyContent") { request =>
import play.api.libs.iteratee.Execution.Implicits.trampoline
request.contentType.map(_.toLowerCase(Locale.ENGLISH)) match {
- case _ if request.method == "GET" || request.method == "HEAD" => {
+ case _ if request.method == HttpVerbs.GET || request.method == HttpVerbs.HEAD => {
Play.logger.trace("Parsing AnyContent as empty")
empty(request).map(_.right.map(_ => AnyContentAsEmpty))
}
@@ -280,7 +280,7 @@ case class SimpleResult(header: ResponseHeader, body: Enumerator[Array[Byte]],
* @param headers the headers to add to this result.
* @return the new result
*/
- def withHeaders(headers: (String, String)*) = {
+ def withHeaders(headers: (String, String)*): SimpleResult = {
copy(header = header.copy(headers = header.headers ++ headers))
}

0 comments on commit b79d4d8

Please sign in to comment.