Permalink
Browse files

Content negotiation support - REST

  • Loading branch information...
1 parent f22f825 commit d6b25dc5ed8c346ddc739653d88afe36ccab29fe @ssuravarapu ssuravarapu committed Dec 14, 2010
@@ -22,6 +22,7 @@ package rest {
import net.liftweb.json._
import net.liftweb.common._
import net.liftweb.util.Props
+import net.liftweb.util.Helpers
import scala.xml.{Elem, Node, Text}
/**
@@ -453,30 +454,140 @@ trait RestHelper extends LiftRules.DispatchPF {
def unapply[A, B](s: (A, B)): Option[(A, B)] = Some(s._1 -> s._2)
}
- @volatile private var _dispatch: List[LiftRules.DispatchPF] = Nil
-
+ @volatile private var _dispatch: List[Either[LiftRules.DispatchPF,
+ (List[(String, String)], LiftRules.DispatchPF)]] = Nil
+
private lazy val nonDevDispatch = _dispatch.reverse
- private def dispatch: List[LiftRules.DispatchPF] =
+ private def dispatch: List[Either[LiftRules.DispatchPF,
+ (List[(String, String)], LiftRules.DispatchPF)]] =
if (Props.devMode) _dispatch.reverse else nonDevDispatch
/**
* Is the Rest helper defined for a given request
*/
- def isDefinedAt(in: Req) = dispatch.find(_.isDefinedAt(in)).isDefined
+ def isDefinedAt(in: Req) = {
+ dispatch.find{
+ case Left(x) => x.isDefinedAt(in)
+ case Right(x) => x._2.isDefinedAt(in)
+ }.isDefined
+ }
/**
* Apply the Rest helper
+ *
+ * If the accept header is not provided, first available URI match is used. As per RFC 2616, Accept header is not
+ * required. When it's not provided the client implies any representation is fine for it.
+ */
+ def apply(in: Req): () => Box[LiftResponse] = {
+ dispatch.find {
+ case Left(x) => x.isDefinedAt(in)
+ case Right(x) => {
+ in.weightedAccept match {
+ case Nil => x._2.isDefinedAt(in)
+ case _ => {
+ ContentNegotiator.selectedContentType(in) match {
+ case Some(c) => x._1.contains((c.theType, c.subtype)) && x._2.isDefinedAt(in)
+ case None => false
+ }
+ }
+ }
+ }
+ } match {
+ case Some(Left(x)) => {
+ x.apply(in)
+ }
+ case Some(Right(x)) => {
+ x._2.apply(in)
+ }
+ case None => net.liftweb.http.NotAcceptableResponse(
+ "An appropriate representation of the requested resource could not be found.")
+ }
+ }
+
+ /**
+ * Add request handler for each content type representation.
+ *
+ * @param contentType -- defines what mime types are accepted by this content-type and provides conversion from the
+ * intermediate response to LiftResponse.
+ * @param handler -- a partial function that matches the requested URI and returns LiftResponse
*/
- def apply(in: Req): () => Box[LiftResponse] =
- dispatch.find(_.isDefinedAt(in)).get.apply(in)
+ def serveContent[T](contentType: ContentTypeAndConverter[T])
+ (handler: PartialFunction[Req, () => Box[LiftResponse]]):Unit =
+ _dispatch ::= Right(contentType.accepts, handler)
+
+
+ private object contentTypeMemo extends RequestMemoize[Req, Box[ContentType]] {
+ override protected def __nameSalt = Helpers.randomString(20)
+ }
+
+
+ /**
+ * Walks through the content types in the client's preferential order (see Accept header in RFC 2616),
+ * and picks the first content type that the server supports.
+ */
+ protected object ContentNegotiator {
+ private def matchedContentType(in: Req): Box[ContentType] = {
+ in.weightedAccept find {
+ case c => dispatch.find {
+ case Right(x) => x._1.contains((c.theType, c.subtype)) && x._2.isDefinedAt(in)
+ case Left(x) => false
+ }.isDefined
+ }
+ }
+
+ /**
+ * Selects the content type based on the Request's Accept header preferences. The first implementation that has a
+ * handler on the server side is returned.
+ */
+ def selectedContentType(in: Req): Option[ContentType] = {
+ contentTypeMemo(in, matchedContentType(in))
+ }
+ }
+
+ /**
+ * Defines what Mime types are accepted for a given content type, and provides implementation to convert the
+ * representation into a LiftResponse
+ */
+ protected trait ContentTypeAndConverter[T] {
+ /**
+ * A list of Mime-types that this content type accepts. Mime types are represented as type and sub-type.
+ * e.g: List("text" -> "xml", "application" -> "xml")
+ */
+ def accepts: List[(String, String)]
+
+ /**
+ * Convert representation to a LiftResponse
+ */
+ def toLiftResponse: T => LiftResponse
+ }
+
+ /**
+ * Definition and converter for XML representation
+ */
+ protected object XmlType extends ContentTypeAndConverter[Elem] {
+ lazy val accepts: List[(String, String)] = List("text" -> "xml", "application" -> "xml")
+ def toLiftResponse: Elem => LiftResponse = this.internalToLiftResponse
+ private def internalToLiftResponse(implicit f: Elem => LiftResponse) = f
+
+ }
+
+ /**
+ * Definition and converter for JSON representation
+ */
+ object JsonType extends ContentTypeAndConverter[JsonAST.JValue] {
+ lazy val accepts: List[(String, String)] = List("text" -> "json", "application" -> "json")
+ def toLiftResponse: JsonAST.JValue => LiftResponse = this.internalToLiftResponse
+ private def internalToLiftResponse(implicit f: JsonAST.JValue => LiftResponse) = f
+
+ }
/**
* Add request handlers
*/
- protected def serve(handler: PartialFunction[Req, () => Box[LiftResponse]]):
- Unit = _dispatch ::= handler
-
+ protected def serve(handler: PartialFunction[Req, () => Box[LiftResponse]]): Unit =
+ _dispatch ::= Left(handler)
+
/**
* Turn T into the return type expected by DispatchPF as long
* as we can convert T to a LiftResponse.
@@ -21,6 +21,8 @@ import _root_.net.liftweb.http._
import _root_.net.liftweb.sitemap._
import _root_.net.liftweb.sitemap.Loc._
import Helpers._
+import _root_.net.liftweb.json.JsonAST.JString
+
/**
* A class that's instantiated early and run. It allows the application
@@ -32,6 +34,8 @@ class Boot {
LiftRules.addToPackages("net.liftweb.webapptest")
LiftRules.dispatch.append(ContainerVarTests)
+
+ LiftRules.dispatch.append(RestHelperTests)
}
}
@@ -60,3 +64,14 @@ object ContainerVarTests extends RestHelper {
}
}
}
+
+object RestHelperTests extends RestHelper {
+ serveContent(XmlType) {
+ case "webservices" :: "conneg" :: _ Get _ => <test>success</test>
+ }
+
+ serveContent(JsonType) {
+ case "webservices" :: "conneg" :: _ Get _ => JString("success")
+ }
+}
+
@@ -0,0 +1,53 @@
+package net.liftweb.http
+
+import org.specs.Specification
+import org.specs.runner.{Console, JUnit, Runner}
+import _root_.net.liftweb.common.{Full, Empty}
+
+class ReqSpecTest extends Runner(ReqSpec) with JUnit with Console
+
+object ReqSpec extends Specification {
+ def orderedMediaTypes(acceptHeader: String) = ContentType.parse(acceptHeader)
+
+ "Req's ContentType" should {
+ "order Accept header's content-type in the decreasing order of q value" in {
+ val acceptHeader = "text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c"
+ val orderedTypes = orderedMediaTypes(acceptHeader)
+ orderedTypes.head must beEqualTo(ContentType("text", "html", 1, Empty, List()))
+ orderedTypes.tail.head must beEqualTo(ContentType("text", "x-c", 3, Empty, List()))
+ orderedTypes.init.last must beEqualTo(ContentType("text", "x-dvi", 2, Full(0.8), List()))
+ orderedTypes.last must beEqualTo(ContentType("text", "plain", 0, Full(0.5), List()))
+ }
+
+ "have XML first in the list for XML-preferred Accept header" in {
+ val xmlPreferredAcceptHeader = "application/json;q=0.8, application/xml"
+ (orderedMediaTypes(xmlPreferredAcceptHeader) head) must beEqualTo(
+ ContentType("application", "xml", 1, Empty, List()))
+ }
+
+ "have JSON first in the list for JSON-preferred Accept header" in {
+ val jsonPreferredAcceptHeader = "application/json, application/xml; q=0.6"
+ (orderedMediaTypes(jsonPreferredAcceptHeader) head) must beEqualTo(
+ ContentType("application", "json", 0, Empty, List()))
+ }
+
+ "place more specific content types first in order" in {
+ val acceptHeader = "*/*, application/json, application/*"
+ orderedMediaTypes(acceptHeader) must beEqualTo(List(ContentType("application", "json", 1, Empty, List()),
+ ContentType("application", "*", 2, Empty, List()),
+ ContentType("*", "*", 0, Empty, List())))
+ }
+
+ "if q value is the same use the order in which they are specified" in {
+ val acceptHeader = "application/*, text/*"
+ orderedMediaTypes(acceptHeader) must beEqualTo(
+ List(ContentType("application", "*", 0, Empty, List()), ContentType("text", "*", 1, Empty, List())))
+ }
+
+ "order generic media types too in the preferential order based on q value" in {
+ val acceptHeader = "application/*;q=0.5, text/*"
+ orderedMediaTypes(acceptHeader) must beEqualTo(
+ List(ContentType("text", "*", 1, Empty, List()), ContentType("application", "*", 0, Full(0.5), List())))
+ }
+ }
+}
@@ -0,0 +1,45 @@
+package net.liftweb.http.rest
+
+import org.specs.runner.{Console, JUnit, Runner}
+import org.specs.Specification
+import net.liftweb.webapptest.JettyTestServer
+import net.liftweb.http._
+import testing.RequestKit
+
+class RestHelperSpecTest extends Runner(RestHelperSpec) with JUnit with Console
+
+object RestHelperSpec extends Specification with RequestKit {
+ doBeforeSpec(JettyTestServer.start)
+
+ def baseUrl = JettyTestServer.baseUrl
+
+ "RestHelper" should {
+ "respond with XML representation for XML request" in {
+ val resp = get("/webservices/conneg", theHttpClient, ("Accept", "application/xml") :: Nil)
+ resp.open_!.xml.open_! must beEqualTo(<test>success</test>)
+ }
+
+ "respond with JSON representation for JSON request" in {
+ val resp = get("/webservices/conneg", theHttpClient, ("Accept", "application/json") :: Nil)
+ resp.open_!.bodyAsString.open_! must beEqualTo("\"success\"")
+ }
+
+ "respond with the representation with highest q factor" in {
+ val resp = get("/webservices/conneg", theHttpClient,
+ ("Accept", "application/json;q=0.8, application/xml") :: Nil)
+ resp.open_!.xml.open_! must beEqualTo(<test>success</test>)
+ }
+
+ "respond with 406 status when the client's requested representation is not available at the server" in {
+ val resp = get("/webservices/conneg", theHttpClient, ("Accept", "text/plain") :: Nil)
+ resp.open_!.code must beEqualTo(406)
+ }
+
+ "respond with any available representation when there is no Accept header with client's preference" in {
+ val resp = get("/webservices/conneg")
+ println("resp.open_!.contentType: " + resp.open_!.contentType)
+ resp.open_!.contentType must beOneOf("text/xml; charset=utf-8", "application/xml; charset=utf-8",
+ "application/json; charset=utf-8")
+ }
+ }
+}
@@ -46,7 +46,7 @@ object JettyTestServer {
def urlFor(path: String) = baseUrl_ + path
- def start() = server_.start()
+ lazy val start = server_.start()
def stop() = {
server_.stop()
@@ -26,17 +26,15 @@ import _root_.net.liftweb.http._
import _root_.net.liftweb.http.testing._
import _root_.net.liftweb.util._
import Helpers._
-
-import net.liftweb.webapptest.snippet.Counter
-
+import _root_.net.liftweb.webapptest.snippet.Counter
class OneShotTest extends JUnit3(OneShot)
object OneShotRunner extends ConsoleRunner(OneShot)
object OneShot extends Specification with RequestKit {
- doBeforeSpec(JettyTestServer.start())
- doAfterSpec(JettyTestServer.stop())
+ doBeforeSpec(JettyTestServer.start)
+// doAfterSpec(JettyTestServer.stop())
def baseUrl = JettyTestServer.baseUrl
@@ -29,8 +29,8 @@ object ToHeadUsagesRunner extends ConsoleRunner(ToHeadUsages)
object ToHeadUsages extends Specification {
- doBeforeSpec(JettyTestServer.start())
- doAfterSpec(JettyTestServer.stop())
+ doBeforeSpec(JettyTestServer.start)
+// doAfterSpec(JettyTestServer.stop())
"lift <head> merger" should {
"merge <head> from html fragment" >> {
@@ -147,7 +147,7 @@ object ToHeadUsages extends Specification {
}
"Exclude from context rewriting" >> {
- val first = net.liftweb.http.Req.fixHtml("/wombat",
+ val first = _root_.net.liftweb.http.Req.fixHtml("/wombat",
<span>
<a href="/foo" id="foo">foo</a>
<a href="/bar" id="bar">bar</a>
@@ -156,8 +156,8 @@ object ToHeadUsages extends Specification {
def excludeBar(in: String): Boolean = in.startsWith("/bar")
- val second = net.liftweb.http.LiftRules.excludePathFromContextPathRewriting.doWith(excludeBar _) {
- net.liftweb.http.Req.fixHtml("/wombat",
+ val second = _root_.net.liftweb.http.LiftRules.excludePathFromContextPathRewriting.doWith(excludeBar _) {
+ _root_.net.liftweb.http.Req.fixHtml("/wombat",
<span>
<a href="/foo" id="foo">foo</a>
<a href="/bar" id="bar">bar</a>
@@ -19,7 +19,7 @@ package webapptest {
package snippet {
import net.liftweb.http._
-import net.liftweb.util.Helpers._
+import _root_.net.liftweb.util.Helpers._
import scala.xml._
object Counter {

0 comments on commit d6b25dc

Please sign in to comment.