69cfdf6a35061e0dac44454aca5b11781463637b
[aquarium] / src / main / scala / gr / grnet / aquarium / actor / service / rest / RESTActor.scala
1 /*
2  * Copyright 2011-2012 GRNET S.A. All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or
5  * without modification, are permitted provided that the following
6  * conditions are met:
7  *
8  *   1. Redistributions of source code must retain the above
9  *      copyright notice, this list of conditions and the following
10  *      disclaimer.
11  *
12  *   2. Redistributions in binary form must reproduce the above
13  *      copyright notice, this list of conditions and the following
14  *      disclaimer in the documentation and/or other materials
15  *      provided with the distribution.
16  *
17  * THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
18  * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19  * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
20  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
21  * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
24  * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
25  * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
26  * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
27  * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28  * POSSIBILITY OF SUCH DAMAGE.
29  *
30  * The views and conclusions contained in the software and
31  * documentation are those of the authors and should not be
32  * interpreted as representing official policies, either expressed
33  * or implied, of GRNET S.A.
34  */
35
36 package gr.grnet.aquarium.actor
37 package service
38 package rest
39
40 import cc.spray.can.HttpMethods.GET
41 import cc.spray.can._
42 import gr.grnet.aquarium.util.Loggable
43 import gr.grnet.aquarium.Aquarium
44 import akka.actor.Actor
45 import gr.grnet.aquarium.actor.{RESTRole, RoleableActor, RouterRole}
46 import RESTPaths.{UserBalancePath, UserStatePath, AdminPingAll}
47 import com.ckkloverdos.maybe.{NoVal, Just}
48 import gr.grnet.aquarium.util.date.TimeHelpers
49 import org.joda.time.format.ISODateTimeFormat
50 import gr.grnet.aquarium.actor.message.admin.PingAllRequest
51 import gr.grnet.aquarium.actor.message.{RouterResponseMessage, GetUserStateRequest, RouterRequestMessage, ActorMessage, GetUserBalanceRequest}
52
53 /**
54  * Spray-based REST service. This is the outer-world's interface to Aquarium functionality.
55  *
56  * @author Christos KK Loverdos <loverdos@gmail.com>.
57  */
58 class RESTActor private(_id: String) extends RoleableActor with Loggable {
59   def this() = this("spray-root-service")
60
61   self.id = _id
62
63   private[this] def aquarium = Aquarium.Instance
64
65   private def stringResponse(status: Int, stringBody: String, contentType: String = "application/json"): HttpResponse = {
66     HttpResponse(
67       status,
68       HttpHeader("Content-type", "%s;charset=utf-8".format(contentType)) :: Nil,
69       stringBody.getBytes("UTF-8")
70     )
71   }
72
73   private def stringResponse200(stringBody: String, contentType: String = "application/json"): HttpResponse = {
74     stringResponse(200, stringBody, contentType)
75   }
76
77   protected def receive = {
78     case RequestContext(HttpRequest(GET, "/ping", _, _, _), _, responder) ⇒
79       val now = TimeHelpers.nowMillis()
80       val nowFormatted = ISODateTimeFormat.dateTime().print(now)
81       responder.complete(stringResponse200("PONG\n%s\n%s".format(now, nowFormatted), "text/plain"))
82
83     case RequestContext(HttpRequest(GET, "/stats", _, _, _), _, responder) ⇒ {
84       (serverActor ? GetStats).mapTo[Stats].onComplete {
85         future =>
86           future.value.get match {
87             case Right(stats) => responder.complete {
88               stringResponse200(
89                 "Uptime              : " + (stats.uptime / 1000.0) + " sec\n" +
90                   "Requests dispatched : " + stats.requestsDispatched + '\n' +
91                   "Requests timed out  : " + stats.requestsTimedOut + '\n' +
92                   "Requests open       : " + stats.requestsOpen + '\n' +
93                   "Open connections    : " + stats.connectionsOpen + '\n'
94               )
95             }
96             case Left(ex) => responder.complete(stringResponse(500, "Couldn't get server stats due to " + ex, "text/plain"))
97           }
98       }
99     }
100
101     case RequestContext(HttpRequest(GET, uri, headers, body, protocol), remoteAddress, responder) ⇒
102       //+ Main business logic REST URIs are matched here
103       val millis = TimeHelpers.nowMillis()
104       uri match {
105         case UserBalancePath(userID) ⇒
106           // /user/(.+)/balance/?
107           callRouter(GetUserBalanceRequest(userID, millis), responder)
108
109         case UserStatePath(userId) ⇒
110           // /user/(.+)/state/?
111           callRouter(GetUserStateRequest(userId, millis), responder)
112
113         case AdminPingAll() ⇒
114           // /admin/ping/all/?
115           aquarium.adminCookie match {
116             case Just(adminCookie) ⇒
117               headers.find(_.name.toLowerCase == Aquarium.HTTP.RESTAdminHeaderNameLowerCase) match {
118                 case Some(cookieHeader) if(cookieHeader.value == adminCookie) ⇒
119                   callRouter(PingAllRequest(), responder)
120
121                 case Some(cookieHeader) ⇒
122                   logger.warn("Admin request with bad cookie '{}' from {}", cookieHeader.value, remoteAddress)
123                   responder.complete(stringResponse(401, "Unauthorized!", "text/plain"))
124
125                 case None ⇒
126                   logger.warn("Admin request with no cookie")
127                   responder.complete(stringResponse(401, "Unauthorized!", "text/plain"))
128               }
129
130             case NoVal ⇒
131               responder.complete(stringResponse(403, "Forbidden!", "text/plain"))
132           }
133
134         case _ ⇒
135           responder.complete(stringResponse(404, "Unknown resource!", "text/plain"))
136       }
137     //- Main business logic REST URIs are matched here
138
139     case RequestContext(HttpRequest(_, _, _, _, _), _, responder) ⇒
140       responder.complete(stringResponse(404, "Unknown resource!", "text/plain"))
141
142     case Timeout(method, uri, _, _, _, complete) ⇒ complete {
143       HttpResponse(status = 500).withBody("The " + method + " request to '" + uri + "' has timed out...")
144     }
145   }
146
147
148   private[this]
149   def callRouter(message: RouterRequestMessage, responder: RequestResponder): Unit = {
150     val aquarium = Aquarium.Instance
151     val actorProvider = aquarium.actorProvider
152     val router = actorProvider.actorForRole(RouterRole)
153     val futureResponse = router ask message
154
155     futureResponse onComplete {
156       future ⇒
157         future.value match {
158           case None ⇒
159             // TODO: Will this ever happen??
160             logger.warn("Future did not complete for %s".format(message))
161             responder.complete(stringResponse(500, "Internal Server Error", "text/plain"))
162
163           case Some(Left(error)) ⇒
164             logger.error("Error serving %s: %s".format(message, error))
165             responder.complete(stringResponse(500, "Internal Server Error", "text/plain"))
166
167           case Some(Right(actualResponse)) ⇒
168             actualResponse match {
169               case routerResponse: RouterResponseMessage[_] ⇒
170                 routerResponse.response match {
171                   case Left(errorMessage) ⇒
172                     logger.error("Error '%s' serving %s. Response is: %s".format(errorMessage, message, actualResponse))
173                     responder.complete(stringResponse(routerResponse.suggestedHTTPStatus, errorMessage, "text/plain"))
174
175                   case Right(response) ⇒
176                     responder.complete(
177                       HttpResponse(
178                         routerResponse.suggestedHTTPStatus,
179                         body = routerResponse.responseToJsonString.getBytes("UTF-8"),
180                         headers = HttpHeader("Content-type", "application/json;charset=utf-8") :: Nil))
181                 }
182
183               case _ ⇒
184                 logger.error("Error serving %s: Response is: %s".format(message, actualResponse))
185                 responder.complete(stringResponse(500, "Internal Server Error", "text/plain"))
186             }
187         }
188     }
189   }
190
191   ////////////// helpers //////////////
192
193   val defaultHeaders = List(HttpHeader("Content-Type", "text/plain"))
194
195   lazy val serverActor = Actor.registry.actorsFor("spray-can-server").head
196
197   def role = RESTRole
198 }