├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── app └── io │ └── pathfinder │ ├── authentication │ ├── AuthServer.scala │ └── AuthenticationStatus.scala │ ├── config │ └── Global.scala │ ├── controllers │ └── Application.scala │ ├── data │ ├── CrudDao.scala │ ├── EbeanCrudDao.scala │ ├── ObserverDao.scala │ └── Resource.scala │ ├── models │ ├── Application.scala │ ├── CapacityParameter.java │ ├── Cluster.scala │ ├── Commodity.scala │ ├── CommodityStatus.java │ ├── Customer.java │ ├── HasCluster.scala │ ├── HasId.scala │ ├── JsonConverter.scala │ ├── ModelId.scala │ ├── ObjectiveFunction.java │ ├── ObjectiveParameter.java │ ├── Transport.scala │ └── TransportStatus.java │ ├── routing │ ├── Action.scala │ ├── ClusterRoute.scala │ ├── ClusterRouter.scala │ ├── Route.scala │ └── Router.scala │ ├── util │ ├── EnumerationFormat.scala │ ├── HasFormat.scala │ └── JavaEnumFormat.scala │ └── websockets │ ├── Events.scala │ ├── ModelTypes.scala │ ├── WebSocketActor.scala │ ├── WebSocketMessage.scala │ ├── controllers │ ├── ClusterSocketController.scala │ ├── CommoditySocketController.scala │ ├── VehicleSocketController.scala │ ├── WebSocketController.scala │ └── WebSocketCrudController.scala │ └── pushing │ ├── EventBusActor.scala │ ├── PushSubscriber.scala │ ├── SocketMessagePusher.scala │ └── WebSocketDao.scala ├── build.sbt ├── conf ├── application.conf ├── logback.xml └── routes ├── project ├── build.properties └── plugins.sbt └── test └── io └── pathfinder ├── BaseAppTest.java ├── routing └── RouteTest.scala └── websockets └── WebSocketActorTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | /logs 2 | /target 3 | /project/project 4 | /project/target 5 | /.idea 6 | /.idea_modules 7 | /.classpath 8 | /.project 9 | /.settings 10 | /RUNNING_PID 11 | *~ 12 | bin/ 13 | .cache-main 14 | .cache-tests 15 | /db 16 | .eclipse 17 | /lib/ 18 | /logs/ 19 | /modules 20 | tmp/ 21 | test-result 22 | server.pid 23 | *.iml 24 | *.eml 25 | /dist/ 26 | .cache 27 | *.swp 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - 2.11.7 4 | jdk: 5 | - oraclejdk8 6 | script: sbt clean coverage test 7 | after_success: sbt coveralls 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Carter Grove, Man Hanson, Adam Michael, David Robinson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pathfinder API Server 2 | [![Build Status](https://travis-ci.org/CSSE497/pathfinder-server.svg?branch=dev)](https://travis-ci.org/CSSE497/pathfinder-server) 3 | [![Coverage Status](https://coveralls.io/repos/CSSE497/pathfinder-server/badge.svg?branch=dev&service=github)](https://coveralls.io/github/CSSE497/pathfinder-server?branch=dev) 4 | 5 | This server exposes `https://api.thepathfinder.xyz`, the websocket API that powers the Pathfinder SDKs. Pathfinder users do not need to be familiar with this service as it is entirely wrapped by the [Pathfinder SDKs](https://pathfinder.readme.io/docs/platform-support). 6 | 7 | 8 | ## Development Guide 9 | These instructions exist mostly as a reference for the development team. 10 | 11 | ### Local development 12 | The server can be run and debugged locally with 13 | 14 | activator "~run" 15 | 16 | ### Standalone release 17 | A standalone release can be built with 18 | 19 | activator dist 20 | 21 | This release can be run with 22 | 23 | unzip -d /opt target/universal/pathfinder-server-.zip 24 | /opt/pathfinder-server-/bin/pathfinder-server -Dhttp.port=80 -Dhttps.port=443 -Dplay.server.https.keyStore.path= -Dplay.server.https.keyStore.password= 25 | 26 | ### Docker 27 | 28 | A Docker image cant be built with 29 | 30 | activator docker:publishLocal 31 | 32 | The image can be run locally with 33 | 34 | docker run -p 9000:9000 pathfinder-server: 35 | 36 | The image can be pushed to gcloud with 37 | 38 | gcloud docker push beta.gcr.io//pathfinder-server: 39 | 40 | The running Docker container can be updated with 41 | 42 | kubectl rolling-update pathfinder-server --image=beta.gcr.io//pathfinder-server: 43 | 44 | #### Detailed GCR release instructions 45 | 46 | 1. Increase the version number in `build.sbt` and tag the repository 47 | 48 | ``` 49 | vi build.sbt 50 | git tag -a v0.1.1 51 | git push --tag 52 | ``` 53 | 54 | 2. Ensure that Docker is up and running 55 | 56 | ``` 57 | docker-machine start default 58 | eval "$(docker-machine env default)" 59 | ``` 60 | 61 | 3. Authenticate Docker to GCR 62 | 63 | ``` 64 | docker login -e -u _token -p "$(gcloud auth print-access-token)" https://beta.gcr.io 65 | ``` 66 | 67 | 4. Build the image and push it to GCR (as defined in build.sbt) 68 | 69 | ``` 70 | activator docker:publish 71 | ``` 72 | 73 | 5. Update the pods 74 | 75 | ``` 76 | kubectl rolling-update pathfinder-server --image=beta.gcr.io//pathfinder-server: 77 | ``` 78 | 79 | ## License 80 | 81 | [MIT](https://raw.githubusercontent.com/CSSE497/pathfinder-server/master/LICENSE). 82 | -------------------------------------------------------------------------------- /app/io/pathfinder/authentication/AuthServer.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.authentication 2 | 3 | import play.api.libs.ws.WS 4 | import play.api.Play 5 | import play.api.Play.current 6 | import scala.concurrent.ExecutionContext.Implicits.global 7 | import play.api.libs.json.JsValue 8 | import scala.concurrent.{Future, Promise} 9 | import java.util.Base64 10 | import io.pathfinder.models.Application 11 | import io.pathfinder.authentication.AuthenticationStatus._ 12 | import java.util.Date 13 | import play.Logger 14 | 15 | import java.security.KeyFactory 16 | import java.security.interfaces.RSAPublicKey 17 | import java.security.spec.X509EncodedKeySpec 18 | import java.security.PublicKey 19 | import java.util.Base64 20 | 21 | import com.nimbusds.jose.crypto.RSASSAVerifier 22 | import com.nimbusds.jwt.{JWTClaimsSet,SignedJWT} 23 | 24 | object AuthServer { 25 | val connection = Play.current.configuration.getString("AuthServer.connection").get 26 | val certificate = Play.current.configuration.getString("AuthServer.certificate").get 27 | val audience = Play.current.configuration.getString("AuthServer.audience").get 28 | val issuer = Play.current.configuration.getString("AuthServer.issuer").get 29 | private var authServerVerifier: RSASSAVerifier = null 30 | private val kf = KeyFactory.getInstance("RSA") 31 | 32 | private def toVerifier(bytes: Array[Byte]): RSASSAVerifier = 33 | new RSASSAVerifier(kf.generatePublic(new X509EncodedKeySpec(bytes)).asInstanceOf[RSAPublicKey]) 34 | 35 | for { 36 | response <- WS.url(certificate).get() 37 | } yield { 38 | try { 39 | Logger.info("Received auth server key") 40 | Logger.info(response.body) 41 | val pub = new String(response.bodyAsBytes, "UTF-8").replaceAll("-----BEGIN PUBLIC KEY-----\n|-----END PUBLIC KEY-----|\n", ""); 42 | val decoded = Base64.getDecoder.decode(pub) 43 | authServerVerifier = toVerifier(decoded) 44 | } catch { 45 | case e: Throwable => 46 | Logger.error("Failed to load auth server key", e) 47 | System.exit(1) 48 | } 49 | } 50 | 51 | def connection(appId: String, cId: String, isDashboardLogin: Boolean): Future[AuthenticationStatus] = { 52 | val app = Application.finder.byId(appId) 53 | if(app == null){ 54 | throw new NoSuchElementException("No Application with id: " + appId) 55 | } 56 | val auth_url = if(null == app.auth_url || isDashboardLogin) { 57 | connection 58 | } else { 59 | app.auth_url 60 | } 61 | val verifier = if(isDashboardLogin || connection == auth_url) { 62 | authServerVerifier 63 | } else { 64 | toVerifier(app.key) 65 | } 66 | for { 67 | res <- WS.url(app.auth_url).withQueryString("connection_id" -> cId, "application_id" -> appId).get() 68 | } yield { 69 | Logger.info(verifier.toString()) 70 | val token = SignedJWT.parse(new String(res.bodyAsBytes,"UTF-8")) 71 | if(!token.verify(verifier)){ 72 | throw new IllegalArgumentException("Invalid Token, unable to verify signature") 73 | } 74 | val claims = token.getJWTClaimsSet 75 | if(claims.getExpirationTime.before(new Date())){ 76 | throw new IllegalArgumentException("expired token") 77 | } 78 | if(!claims.getAudience.contains(audience)){ 79 | throw new IllegalArgumentException(audience + " required in aud claim") 80 | } 81 | if(claims.getIssuer() != issuer){ 82 | throw new IllegalArgumentException(issuer + " required in iss claim") 83 | } 84 | val status = claims.getStringClaim("status") 85 | if(status == null){ 86 | throw new IllegalArgumentException("status claim required") 87 | } 88 | AuthenticationStatus.withName(status) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/io/pathfinder/authentication/AuthenticationStatus.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.authentication 2 | 3 | import io.pathfinder.util.HasFormat 4 | 5 | object AuthenticationStatus extends Enumeration with HasFormat { 6 | type AuthenticationStatus = Value 7 | val Authenticated, UnauthorizedUser = Value 8 | } 9 | -------------------------------------------------------------------------------- /app/io/pathfinder/config/Global.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.config 2 | 3 | import akka.actor.ActorSystem 4 | import io.pathfinder.routing.Router 5 | import play.api.GlobalSettings 6 | import play.api.Application 7 | import play.Logger 8 | import io.pathfinder.authentication.AuthServer 9 | 10 | /** 11 | * These hooks are called by Play Framework. We can use them to initialize expensive objects and 12 | * set up any communication channels. 13 | */ 14 | object Global extends GlobalSettings { 15 | 16 | val actorSystem = ActorSystem.create() 17 | 18 | override def onStart(app: Application) { 19 | Logger.info("Application has started.") 20 | Router 21 | AuthServer 22 | } 23 | 24 | override def onStop(app: Application) { 25 | Logger.info("Application has stopped.") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/io/pathfinder/controllers/Application.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.controllers 2 | 3 | import akka.actor.{Props, ActorRef} 4 | import io.pathfinder.websockets.{WebSocketActor, WebSocketMessage} 5 | import play.api.mvc.{Results, RequestHeader, WebSocket, Controller} 6 | import play.Logger 7 | 8 | import scala.concurrent.Future 9 | 10 | object Application { 11 | private def handlerProps(appId: String): WebSocket.HandlerProps = WebSocketActor.props(_, appId) 12 | } 13 | 14 | class Application extends Controller { 15 | import io.pathfinder.websockets.WebSocketMessage.frameFormat 16 | import play.api.Play.current 17 | import Application._ 18 | 19 | def socket = WebSocket.tryAcceptWithActor[WebSocketMessage,WebSocketMessage] { request: RequestHeader => 20 | Future.successful( 21 | request.headers.get("Authorization").map{ h => 22 | Logger.info("Connection using id: " + h + ", from authorization header"); h 23 | }.orElse( 24 | request.cookies.get("AppId").map{ c => 25 | Logger.info("Connection using id: " + c.value + ", from cookie") 26 | c.value 27 | } 28 | ).orElse( 29 | request.getQueryString("AppId").map{ id => 30 | Logger.info("Connection using id: " + id + ", from query string") 31 | id 32 | } 33 | ).orElse { 34 | Logger.info("Using default id: 7d8f2ead-ee48-45ef-8314-3c5bebd4db82") 35 | Some("7d8f2ead-ee48-45ef-8314-3c5bebd4db82") 36 | }.flatMap( 37 | appId => 38 | Option(io.pathfinder.models.Application.finder.byId(appId)).map( 39 | app => Right(handlerProps(appId)) 40 | ) 41 | ).getOrElse(Left(Results.Unauthorized)) 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/io/pathfinder/data/CrudDao.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.data 2 | 3 | abstract class CrudDao[K,M] { 4 | 5 | /** 6 | * Adds the specified model to the database 7 | */ 8 | def create(model: M): M 9 | 10 | /** 11 | * Creates a model according to the specified resource, if the update returns 12 | * false, no model is created in the database and None is returned 13 | */ 14 | def create(create: Resource[M]): Option[M] 15 | 16 | /** 17 | * applies the resource as an update to the model with the given id, 18 | * returns the model if it exists, otherwise returns None 19 | */ 20 | def update(id: K, update: Resource[M]): Option[M] 21 | 22 | /** 23 | * updates the specified model 24 | */ 25 | def update(model: M): Option[M] 26 | 27 | /** 28 | * deletes the model with the specified id 29 | */ 30 | def delete(id: K): Option[M] 31 | 32 | /** 33 | * returns the model with the specified id, if the id does not exist, 34 | * None is returned instead 35 | */ 36 | def read(id: K): Option[M] 37 | 38 | /** 39 | * returns all of the models as a Seq 40 | */ 41 | def readAll: Seq[M] 42 | } 43 | -------------------------------------------------------------------------------- /app/io/pathfinder/data/EbeanCrudDao.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.data 2 | 3 | import com.avaje.ebean.Model 4 | import play.db.ebean.Transactional 5 | import play.Logger 6 | import scala.collection.JavaConversions.asScalaBuffer 7 | 8 | class EbeanCrudDao[K,M <: Model](protected val finder: Model.Find[K,M]) extends CrudDao[K,M] { 9 | 10 | final override def create(model: M): M = { 11 | model.insert() 12 | model 13 | } 14 | 15 | final def create(create: Resource[M]): Option[M] = { 16 | create.create.map { 17 | (model: M) => 18 | Logger.info(String.format("EbeanCrudDao inserting new %s: %s", model.getClass.getName, model)) 19 | model.insert() 20 | model 21 | } 22 | } 23 | 24 | @Transactional 25 | final override def update(id: K, update: Resource[M]): Option[M] = for { 26 | model <- Option(finder.byId(id)) 27 | updated <- update.update(model) 28 | } yield { 29 | updated.update() 30 | updated 31 | } 32 | 33 | final override def update(model: M): Option[M] = { 34 | model.update() 35 | Some(model) 36 | } 37 | 38 | @Transactional 39 | final override def delete(id: K): Option[M] = 40 | Option(finder.byId(id)).map{ 41 | model => 42 | model.delete() 43 | model 44 | } 45 | 46 | final override def read(id: K): Option[M] = Option(finder.byId(id)) 47 | 48 | final override def readAll: Seq[M] = finder.all() 49 | } 50 | -------------------------------------------------------------------------------- /app/io/pathfinder/data/ObserverDao.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.data 2 | 3 | import com.avaje.ebean.Model 4 | import com.avaje.ebean.Model.Find 5 | 6 | /** 7 | * subclasses can use callbacks to listen to changes to models in the specified dao 8 | */ 9 | abstract class ObserverDao[V <: Model](dao: CrudDao[Long,V]) extends CrudDao[Long,V] { 10 | 11 | def this(find: Find[Long,V]) = this(new EbeanCrudDao[Long,V](find)) 12 | 13 | protected def onCreated(model: V): Unit 14 | protected def onDeleted(model: V): Unit 15 | protected def onUpdated(model: V): Unit 16 | 17 | final override def update(id: Long, update: Resource[V]): Option[V] = for{ 18 | mod <- dao.update(id, update) 19 | _ = onUpdated(mod) 20 | } yield mod 21 | 22 | final override def update(model: V): Option[V] = for{ 23 | mod <- dao.update(model) 24 | _ = onUpdated(mod) 25 | } yield mod 26 | 27 | final override def delete(id: Long): Option[V] = for{ 28 | mod <- dao.delete(id) 29 | _ = onDeleted(mod) 30 | } yield mod 31 | 32 | final override def readAll: Seq[V] = dao.readAll 33 | 34 | final override def read(id: Long): Option[V] = dao.read(id) 35 | 36 | final override def create(model: V): V = { 37 | val mod: V = dao.create(model) 38 | onCreated(mod) 39 | mod 40 | } 41 | 42 | final override def create(create: Resource[V]): Option[V] = for{ 43 | mod <- dao.create(create) 44 | _ = onCreated(mod) 45 | } yield mod 46 | } 47 | -------------------------------------------------------------------------------- /app/io/pathfinder/data/Resource.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.data 2 | 3 | /** 4 | * A resource is what is received and sent to the API clients 5 | */ 6 | trait Resource[M] { 7 | 8 | protected type R <: Resource[M] 9 | 10 | /** 11 | * updates the specified model with the resource's fields 12 | * @param model 13 | * @return 14 | */ 15 | def update(model: M): Option[M] 16 | 17 | /** 18 | * creates a new model instance from this resource 19 | * @return 20 | */ 21 | def create: Option[M] 22 | 23 | def withAppId(appId: String): Option[R] 24 | 25 | def withoutAppId: R 26 | } 27 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/Application.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import java.util 4 | import javax.persistence.CascadeType._ 5 | import javax.persistence._ 6 | import com.avaje.ebean.Model 7 | import com.avaje.ebean.Model.{Finder, Find} 8 | import play.api.libs.json.{Json, Format} 9 | 10 | import scala.collection.mutable 11 | import scala.collection.JavaConverters.asScalaBufferConverter 12 | 13 | object Application { 14 | val finder: Find[String, Application] = 15 | new Finder[String, Application](classOf[Application]) 16 | 17 | val format: Format[Application] = Json.format[Application] 18 | 19 | def apply(id: String, name: String): Application = { 20 | val app = new Application 21 | app.id = id 22 | app 23 | } 24 | 25 | def unapply(p: Application): Option[(String, String)] = 26 | Some((p.id, p.name)) 27 | } 28 | 29 | @Entity 30 | class Application extends Model { 31 | 32 | @Id 33 | @Column(updatable = false) 34 | var id: String = null 35 | 36 | @Column 37 | var name: String = null 38 | 39 | @ManyToOne 40 | var customer: Customer = null 41 | 42 | @OneToMany(mappedBy = "application", cascade = Array(ALL)) 43 | var capacityParametersList: util.List[CapacityParameter] = new util.ArrayList[CapacityParameter]() 44 | 45 | @OneToMany(mappedBy = "application", cascade = Array(ALL)) 46 | var objectiveParametersList: util.List[ObjectiveParameter] = new util.ArrayList[ObjectiveParameter]() 47 | 48 | @ManyToOne 49 | var objectiveFunction: ObjectiveFunction = null 50 | 51 | @Column 52 | var key: Array[Byte] = null 53 | 54 | @Column 55 | var auth_url: String = null 56 | 57 | def cluster: Cluster = { 58 | Cluster.finder.byId(id) 59 | } 60 | 61 | def capacityParameters: mutable.Buffer[CapacityParameter] = capacityParametersList.asScala 62 | def objectiveParameters: mutable.Buffer[ObjectiveParameter] = objectiveParametersList.asScala 63 | } 64 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/CapacityParameter.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models; 2 | 3 | import com.avaje.ebean.Model; 4 | 5 | import java.security.Timestamp; 6 | 7 | import javax.persistence.Entity; 8 | import javax.persistence.GeneratedValue; 9 | import javax.persistence.GenerationType; 10 | import javax.persistence.Id; 11 | import javax.persistence.ManyToOne; 12 | import javax.persistence.Version; 13 | import javax.validation.constraints.NotNull; 14 | 15 | @Entity public class CapacityParameter extends Model { 16 | 17 | public static final Find find = 18 | new Find() { 19 | }; 20 | 21 | @Id @NotNull @GeneratedValue(strategy = GenerationType.IDENTITY) public long id; 22 | @ManyToOne @NotNull public Application application; 23 | @NotNull public String parameter; 24 | @Version public Timestamp lastUpdate; 25 | } 26 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/Cluster.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import java.util 4 | import javax.persistence.{Transient, OneToMany, CascadeType, Id, Column, Entity} 5 | 6 | import com.avaje.ebean.Model 7 | import com.avaje.ebean.annotation.Transactional 8 | import io.pathfinder.data.{EbeanCrudDao, Resource} 9 | import play.api.libs.json.{Json, Format} 10 | import scala.collection.JavaConverters.{asScalaBufferConverter, seqAsJavaListConverter, asScalaIteratorConverter} 11 | import scala.collection.{mutable, Iterator} 12 | 13 | object Cluster { 14 | private val ROOT: String = "/root" 15 | 16 | val finder: Model.Find[String, Cluster] = new Model.Finder[String, Cluster](classOf[Cluster]) 17 | 18 | def byPrefix(path: String): Iterator[Cluster] = 19 | finder.query().where().startsWith("id", path).findIterate().asScala 20 | 21 | object Dao extends EbeanCrudDao[String, Cluster](finder) 22 | 23 | def addAppToPath(path: String, app: String): Option[String] = 24 | if (path.startsWith(ROOT)) { 25 | Some(app + path.stripPrefix(ROOT)) 26 | } else None 27 | 28 | def removeAppFromPath(path: String): String = 29 | if(path.startsWith(ROOT)) { 30 | path 31 | } else { 32 | val i = path.indexOf("/") 33 | if(i == -1){ 34 | ROOT 35 | } else { 36 | ROOT + path.substring(i) 37 | } 38 | } 39 | 40 | implicit val format: Format[Cluster] = Json.format[Cluster] 41 | implicit val resourceFormat: Format[ClusterResource] = Json.format[ClusterResource] 42 | 43 | case class ClusterResource( 44 | id: Option[String], 45 | transports: Option[Seq[Transport.TransportResource]], 46 | commodities: Option[Seq[Commodity.CommodityResource]], 47 | subClusters: Option[Seq[ClusterResource]] 48 | ) extends Resource[Cluster] { 49 | override type R = ClusterResource 50 | 51 | override def update(model: Cluster): Option[Cluster] = None // Cluster Updates are not supported 52 | 53 | /** Creates a new model instance from this resource. */ 54 | override def create: Option[Cluster] = { 55 | val model = new Cluster 56 | model.id = id.getOrElse(return None) // should validate path 57 | transports.foreach( 58 | _.foreach { 59 | transportResource => model.transportList.add( 60 | (for { 61 | id <- transportResource.id 62 | model <- Transport.Dao.read(id) 63 | update <- transportResource.update(model) 64 | } yield update).orElse(transportResource.create(model)).getOrElse(return None) 65 | ) 66 | } 67 | ) 68 | commodities.foreach( 69 | _.foreach { 70 | commodityResource => model.commodityList.add( 71 | (for { 72 | id <- commodityResource.id 73 | model <- Commodity.Dao.read(id) 74 | update <- commodityResource.update(model) 75 | } yield update).orElse(commodityResource.create(model)).getOrElse(return None) 76 | ) 77 | } 78 | ) 79 | Some(model) 80 | } 81 | 82 | override def withAppId(appId: String): Option[ClusterResource] = Some( 83 | copy( 84 | id.map(Cluster.addAppToPath(_, appId).getOrElse(return None)), 85 | transports.map(_.map(_.withAppId(appId).getOrElse(return None))), 86 | commodities.map(_.map(_.withAppId(appId).getOrElse(return None))), 87 | subClusters.map(_.map(_.withAppId(appId).getOrElse(return None))) 88 | ) 89 | ) 90 | 91 | override def withoutAppId: ClusterResource = copy( 92 | id.map(Cluster.removeAppFromPath), 93 | transports.map(_.map(_.withoutAppId)), 94 | commodities.map(_.map(_.withoutAppId)), 95 | subClusters.map(_.map(_.withoutAppId)) 96 | ) 97 | } 98 | 99 | def apply(id: String, transports: Seq[Transport], commodities: Seq[Commodity], subClusters: Seq[Cluster]): Cluster = { 100 | val c = new Cluster 101 | c.id = id 102 | c.transportList.addAll(transports.asJava) 103 | c.commodityList.addAll(commodities.asJava) 104 | c.unsavedSubclusters ++= subClusters 105 | c 106 | } 107 | 108 | def unapply(c: Cluster): Option[(String, Seq[Transport], Seq[Commodity], Seq[Cluster])] = 109 | Some((Cluster.removeAppFromPath(c.id), c.transports, c.commodities, c.subClusters.toSeq)) 110 | } 111 | 112 | @Entity 113 | class Cluster() extends Model { 114 | @Id 115 | @Column(nullable = false) 116 | var id: String = null 117 | 118 | @OneToMany(mappedBy = "cluster", cascade=Array(CascadeType.ALL)) 119 | var transportList: util.List[Transport] = new util.ArrayList[Transport]() 120 | 121 | @OneToMany(mappedBy = "cluster", cascade=Array(CascadeType.ALL)) 122 | var commodityList: util.List[Commodity] = new util.ArrayList[Commodity]() 123 | 124 | def transports: Seq[Transport] = transportList.asScala 125 | 126 | def commodities: Seq[Commodity] = commodityList.asScala 127 | 128 | def subClusters: Iterator[Cluster] = descendants.filter(_.id.count(_ == '/') == id.count(_ == '/') + 1) 129 | 130 | @Transient 131 | private val unsavedSubclusters: mutable.Buffer[Cluster] = mutable.Buffer.empty 132 | 133 | def parent: Option[Cluster] = Option(Cluster.finder.byId(id.substring(0,id.lastIndexOf("/")))) 134 | 135 | def parents: Iterator[Cluster] = 136 | Iterator.iterate[Option[Cluster]]( 137 | this.parent 138 | )( 139 | _.flatMap(_.parent) 140 | ).takeWhile(_.isDefined).map(_.get) 141 | 142 | def descendants: Iterator[Cluster] = Cluster.byPrefix(id +"/") 143 | 144 | def application: Application = 145 | Application.finder.byId(id.iterator.takeWhile(_ != '/').mkString) 146 | 147 | @Transactional 148 | override def delete(): Boolean = { 149 | Cluster.byPrefix(id + "/").foreach(_.delete()) 150 | super.delete() 151 | } 152 | 153 | @Transactional 154 | override def insert(): Unit = { 155 | unsavedSubclusters.foreach(_.insert()) 156 | unsavedSubclusters.clear() 157 | super.insert() 158 | } 159 | 160 | @Transactional 161 | override def update(): Unit = { 162 | unsavedSubclusters.foreach(_.update()) 163 | unsavedSubclusters.clear() 164 | super.update() 165 | } 166 | 167 | @Transactional 168 | override def save(): Unit = { 169 | unsavedSubclusters.foreach(_.save()) 170 | unsavedSubclusters.clear() 171 | super.save() 172 | } 173 | 174 | override def toString = String.format( 175 | "Cluster(id: %s, transports: %s, commodities: %s)", 176 | id, 177 | util.Arrays.toString(transportList.toArray), 178 | util.Arrays.toString(commodityList.toArray)) 179 | } 180 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/Commodity.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import java.util.Date 4 | import javax.persistence.{GenerationType, Column, Id, Entity, GeneratedValue, JoinColumn, ManyToOne} 5 | 6 | import com.avaje.ebean.Model 7 | 8 | import io.pathfinder.data.Resource 9 | import io.pathfinder.websockets.ModelTypes 10 | import io.pathfinder.websockets.pushing.WebSocketDao 11 | 12 | import play.api.libs.json.{JsObject, Writes, Json, Format} 13 | 14 | object Commodity { 15 | val finder: Model.Find[Long,Commodity] = new Model.Finder[Long,Commodity](classOf[Commodity]) 16 | 17 | object Dao extends WebSocketDao[Commodity](finder) { 18 | 19 | override def modelType: ModelTypes.Value = ModelTypes.Commodity 20 | 21 | override def writer: Writes[Commodity] = Commodity.format 22 | } 23 | 24 | implicit val statusFormat = CommodityStatus.format 25 | implicit val format: Format[Commodity] = Json.format[Commodity] 26 | implicit val resourceFormat: Format[CommodityResource] = Json.format[CommodityResource] 27 | 28 | case class CommodityResource( 29 | id: Option[Long], 30 | startLatitude: Option[Double], 31 | endLatitude: Option[Double], 32 | startLongitude: Option[Double], 33 | endLongitude: Option[Double], 34 | status: Option[CommodityStatus], 35 | metadata: Option[JsObject], 36 | clusterId: Option[String], 37 | transportId: Option[Long] 38 | ) extends Resource[Commodity] { 39 | override type R = CommodityResource 40 | 41 | override def update(c: Commodity): Option[Commodity] = { 42 | startLatitude.foreach(c.startLatitude = _) 43 | startLongitude.foreach(c.startLongitude = _) 44 | endLatitude.foreach(c.endLatitude = _) 45 | endLongitude.foreach(c.endLongitude = _) 46 | metadata.foreach(c.metadata = _) 47 | status.foreach { 48 | newStatus => 49 | c.status = newStatus 50 | if (c.status.equals(CommodityStatus.PickedUp)) { 51 | transportId.orElse(return None).foreach { id => c.transport = Transport.Dao.read(id).getOrElse(return None) } 52 | } else { 53 | if (CommodityStatus.Waiting.equals(newStatus)) { 54 | c.requestTime = new Date 55 | } 56 | c.transport = null // don't know what should happen here 57 | } 58 | } 59 | Some(c) 60 | } 61 | 62 | def create(cluster: Cluster): Option[Commodity] = for { 63 | startLatitude <- startLatitude 64 | startLongitude <- startLongitude 65 | endLatitude <- endLatitude 66 | endLongitude <- endLongitude 67 | } yield { 68 | val stat = status.getOrElse(CommodityStatus.Inactive) 69 | val c = Commodity( 70 | 0, 71 | startLatitude, 72 | startLongitude, 73 | endLatitude, 74 | endLongitude, 75 | stat, 76 | metadata.getOrElse(JsObject(Seq.empty)), 77 | transportId, 78 | cluster.id 79 | ) 80 | c.cluster = cluster 81 | if(CommodityStatus.Waiting.equals(stat)) { 82 | c.requestTime = new Date 83 | } 84 | c 85 | } 86 | 87 | override def create: Option[Commodity] = for { 88 | id <- clusterId 89 | cluster <- Cluster.Dao.read(id) 90 | model <- create(cluster) 91 | } yield model 92 | 93 | override def withAppId(appId: String): Option[CommodityResource] = Some( 94 | copy( 95 | clusterId = clusterId.map(Cluster.addAppToPath(_, appId).getOrElse(return None)) 96 | ) 97 | ) 98 | 99 | override def withoutAppId: CommodityResource = copy( 100 | clusterId = clusterId.map(Cluster.removeAppFromPath) 101 | ) 102 | } 103 | 104 | def apply( 105 | id: Long, 106 | startLatitude: 107 | Double, 108 | startLongitude: Double, 109 | endLatitude: Double, 110 | endLongitude: Double, 111 | status: CommodityStatus, 112 | metadata: JsObject, 113 | transportId: Option[Long], 114 | clusterId: String 115 | ): Commodity = { 116 | val c = new Commodity 117 | c.id = id 118 | c.startLatitude = startLatitude 119 | c.startLongitude = startLongitude 120 | c.endLatitude = endLatitude 121 | c.endLongitude = endLongitude 122 | c.status = status 123 | c.metadata = metadata 124 | c.transport = transportId.flatMap{ 125 | vId => Transport.Dao.read(vId).orElse(throw new IllegalArgumentException("no transport with " + " id: " + vId)) 126 | }.orNull 127 | c.cluster = Cluster.Dao.read(clusterId).getOrElse( 128 | throw new IllegalArgumentException("No Cluster With Id: " + clusterId) 129 | ) 130 | c 131 | } 132 | 133 | def unapply(c: Commodity): Option[ 134 | (Long, Double, Double, Double, Double, CommodityStatus, JsObject, Option[Long], String) 135 | ] = Some(( 136 | c.id, 137 | c.startLatitude, 138 | c.startLongitude, 139 | c.endLatitude, 140 | c.endLongitude, 141 | c.status, 142 | c.metadata, 143 | Option(c.transport).map(_.id), 144 | Cluster.removeAppFromPath(c.cluster.id) 145 | )) 146 | } 147 | 148 | @Entity 149 | class Commodity() extends Model with HasId with HasCluster { 150 | 151 | @Id 152 | @Column(name="id", nullable=false) 153 | @GeneratedValue(strategy = GenerationType.IDENTITY) 154 | var id: Long = 0 155 | 156 | @Column(name="startLatitude", nullable=false) 157 | var startLatitude: Double = 0 158 | 159 | @Column(name="startLongitude", nullable=false) 160 | var startLongitude: Double = 0 161 | 162 | @Column(name="endLatitude", nullable=false) 163 | var endLatitude: Double = 0 164 | 165 | @Column(name="endLongitude", nullable=false) 166 | var endLongitude: Double = 0 167 | 168 | @Column(nullable = false) 169 | var status: CommodityStatus = CommodityStatus.Inactive 170 | 171 | @Column(length = 255) 172 | var metadata: JsObject = JsObject(Seq.empty) 173 | 174 | @JoinColumn 175 | @ManyToOne(optional = true) 176 | var transport: Transport = null 177 | 178 | @JoinColumn 179 | @ManyToOne 180 | var cluster: Cluster = null 181 | 182 | @Column 183 | var requestTime: Date = new Date(0) 184 | } 185 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/CommodityStatus.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models; 2 | 3 | import com.avaje.ebean.annotation.EnumValue; 4 | import io.pathfinder.util.JavaEnumFormat; 5 | import play.api.libs.json.Format; 6 | 7 | public enum CommodityStatus { 8 | @EnumValue("0") 9 | Inactive, 10 | @EnumValue("1") 11 | Cancelled, 12 | @EnumValue("2") 13 | Waiting, 14 | @EnumValue("3") 15 | PickedUp, 16 | @EnumValue("4") 17 | DroppedOff; 18 | 19 | public static final Format format = new JavaEnumFormat<>(CommodityStatus.class); 20 | } 21 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/Customer.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models; 2 | 3 | import com.avaje.ebean.Model; 4 | import com.fasterxml.jackson.annotation.JsonIgnore; 5 | 6 | import java.security.Timestamp; 7 | import java.util.List; 8 | 9 | import javax.persistence.CascadeType; 10 | import javax.persistence.Entity; 11 | import javax.persistence.Id; 12 | import javax.persistence.OneToMany; 13 | import javax.persistence.Version; 14 | import javax.validation.constraints.NotNull; 15 | import javax.validation.constraints.Size; 16 | 17 | @Entity public class Customer extends Model { 18 | 19 | public static final int PASSWORD_MIN_LENGTH = 6; 20 | public static final Find find = new Find() { 21 | }; 22 | 23 | @Id @NotNull public String email; 24 | @JsonIgnore @NotNull @Size(min = PASSWORD_MIN_LENGTH) public String password; 25 | @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL) public List 26 | applications; 27 | @Version public Timestamp lastUpdate; 28 | } 29 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/HasCluster.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import com.avaje.ebean.Model 4 | 5 | trait HasCluster extends Model { 6 | def cluster: Cluster 7 | } 8 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/HasId.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import com.avaje.ebean.Model 4 | 5 | trait HasId extends Model { 6 | def id: Long 7 | } 8 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/JsonConverter.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import com.avaje.ebean.config.ScalarTypeConverter 4 | import com.fasterxml.jackson.databind.JsonNode 5 | import com.fasterxml.jackson.databind.node.ObjectNode 6 | import play.api.libs.json.JsObject 7 | import play.api.libs.json.Writes.JsonNodeWrites 8 | import play.api.libs.json.Reads.JsonNodeReads 9 | 10 | /** 11 | * This converter allows JsValues to be serialized by Ebean 12 | */ 13 | class JsonConverter extends ScalarTypeConverter[JsObject, JsonNode] { 14 | override def getNullValue: JsObject = JsObject(Seq.empty) 15 | override def unwrapValue(jsObj: JsObject): ObjectNode = JsonNodeReads.reads(jsObj).get.asInstanceOf[ObjectNode] 16 | override def wrapValue(node: JsonNode): JsObject = JsonNodeWrites.writes(node).as[JsObject] 17 | } 18 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/ModelId.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import io.pathfinder.websockets.ModelTypes 4 | import io.pathfinder.websockets.ModelTypes.ModelType 5 | import play.api.libs.json.{Reads, JsString, JsNumber, JsResult, JsValue, Writes, Json, Format} 6 | 7 | object ModelId { 8 | 9 | sealed abstract class LongId extends ModelId { 10 | override type Key = Long 11 | } 12 | 13 | sealed abstract class StringId extends ModelId { 14 | override type Key = String 15 | } 16 | 17 | def read(model: ModelType, jsonId: JsValue): JsResult[ModelId] = model match { 18 | case ModelTypes.Commodity => CommodityId.format.reads(jsonId) 19 | case ModelTypes.Cluster => ClusterPath.format.reads(jsonId) 20 | case ModelTypes.Transport => TransportId.format.reads(jsonId) 21 | } 22 | 23 | def write(mId: ModelId): JsValue = mId match { 24 | case CommodityId(id) => JsNumber(id) 25 | case ClusterPath(path) => JsString(path) 26 | case TransportId(id) => JsNumber(id) 27 | } 28 | 29 | object CommodityId { 30 | val format: Format[CommodityId] = Format( 31 | Reads.JsNumberReads.map(num => CommodityId(num.value.longValue())), 32 | Writes { 33 | case CommodityId(id) => JsNumber(id) 34 | } 35 | ) 36 | } 37 | 38 | case class CommodityId(id: Long) extends LongId { 39 | override type Id = CommodityId 40 | override def modelType = ModelTypes.Commodity 41 | } 42 | 43 | object ClusterPath { 44 | val format: Format[ClusterPath] = Format( 45 | Reads.JsStringReads.map(str => ClusterPath(str.value)), 46 | Writes { 47 | case ClusterPath(path) => JsString(path) 48 | } 49 | ) 50 | } 51 | case class ClusterPath(path: String) extends StringId { 52 | override type Id = ClusterPath 53 | override def modelType = ModelTypes.Cluster 54 | override def id = path 55 | override def withAppId(appId: String): Option[ClusterPath] = 56 | Cluster.addAppToPath(path, appId).map(ClusterPath.apply) 57 | override def withoutAppId: ClusterPath = 58 | ClusterPath(Cluster.removeAppFromPath(path)) 59 | } 60 | 61 | object TransportId { 62 | val format: Format[TransportId] = Format( 63 | Reads.JsNumberReads.map(num => TransportId.apply(num.value.toLong)), Writes { 64 | case TransportId(id) => JsNumber(id) 65 | } 66 | ) 67 | } 68 | 69 | case class TransportId(id: Long) extends LongId { 70 | override type Id = TransportId 71 | override def modelType = ModelTypes.Transport 72 | } 73 | } 74 | 75 | sealed abstract class ModelId { 76 | protected type Id <: ModelId 77 | type Key 78 | def modelType: ModelType 79 | def id: Key 80 | def withAppId(app: String): Option[Id] = Some(this.asInstanceOf[Id]) 81 | def withoutAppId: Id = this.asInstanceOf[Id] 82 | } 83 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/ObjectiveFunction.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models; 2 | 3 | import com.avaje.ebean.Model; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.Id; 7 | import javax.validation.constraints.NotNull; 8 | 9 | @Entity public class ObjectiveFunction extends Model { 10 | public static final Find find = 11 | new Find() { 12 | }; 13 | 14 | @Id @NotNull public String id; 15 | @NotNull public String function; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/ObjectiveParameter.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models; 2 | 3 | import com.avaje.ebean.Model; 4 | 5 | import java.security.Timestamp; 6 | 7 | import javax.persistence.Entity; 8 | import javax.persistence.GeneratedValue; 9 | import javax.persistence.GenerationType; 10 | import javax.persistence.Id; 11 | import javax.persistence.ManyToOne; 12 | import javax.persistence.Version; 13 | import javax.validation.constraints.NotNull; 14 | 15 | @Entity public class ObjectiveParameter extends Model { 16 | 17 | public static final Find find = 18 | new Find() { 19 | }; 20 | 21 | @Id @NotNull @GeneratedValue(strategy = GenerationType.IDENTITY) public long id; 22 | @ManyToOne @NotNull public Application application; 23 | @NotNull public String parameter; 24 | @Version public Timestamp lastUpdate; 25 | } 26 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/Transport.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models 2 | 3 | import java.util 4 | 5 | import com.avaje.ebean.Model 6 | import io.pathfinder.data.Resource 7 | import io.pathfinder.models.Commodity.CommodityResource 8 | import io.pathfinder.websockets.ModelTypes 9 | import io.pathfinder.websockets.pushing.WebSocketDao 10 | import javax.persistence.{CascadeType, OneToMany, Enumerated, JoinColumn, ManyToOne, Id, Column, Entity, GeneratedValue, GenerationType} 11 | import play.api.libs.json.{JsObject, Writes, Format, Json} 12 | import scala.collection.JavaConverters.{asScalaBufferConverter, seqAsJavaListConverter} 13 | 14 | object Transport { 15 | 16 | val finder: Model.Find[Long, Transport] = new Model.Finder[Long, Transport](classOf[Transport]) 17 | 18 | object Dao extends WebSocketDao[Transport](finder) { 19 | 20 | override def modelType: ModelTypes.Value = ModelTypes.Transport 21 | 22 | override def writer: Writes[Transport] = Transport.format 23 | } 24 | 25 | implicit val statusFormat: Format[TransportStatus] = TransportStatus.format 26 | implicit val format: Format[Transport] = Json.format[Transport] 27 | 28 | implicit val resourceFormat: Format[TransportResource] = Json.format[TransportResource] 29 | 30 | case class TransportResource( 31 | id: Option[Long], 32 | latitude: Option[Double], 33 | longitude: Option[Double], 34 | clusterId: Option[String], 35 | status: Option[TransportStatus], 36 | metadata: Option[JsObject] 37 | ) extends Resource[Transport] { 38 | 39 | override type R = TransportResource 40 | 41 | override def update(v: Transport): Option[Transport] = { 42 | latitude.foreach(v.latitude = _) 43 | longitude.foreach(v.longitude = _) 44 | status.foreach(v.status = _) 45 | clusterId.foreach { 46 | Cluster.Dao.read(_).foreach(v.cluster = _) 47 | } 48 | metadata.foreach(v.metadata = _) 49 | 50 | Some(v) 51 | } 52 | 53 | def create(c: Cluster): Option[Transport] = { 54 | for { 55 | lat <- latitude 56 | lng <- longitude 57 | } yield { 58 | val stat = status.getOrElse(TransportStatus.Offline) 59 | val met = metadata.getOrElse(JsObject(Seq.empty)) 60 | val v = Transport(id.getOrElse(0), lat, lng, stat, met, None, c.id) 61 | v.cluster = c 62 | v 63 | } 64 | } 65 | 66 | override def create: Option[Transport] = for { 67 | id <- clusterId 68 | cluster <- Cluster.Dao.read(id) 69 | mod <- create(cluster) 70 | } yield mod 71 | 72 | override def withAppId(appId: String): Option[TransportResource] = Some( 73 | copy( 74 | clusterId = clusterId.map(Cluster.addAppToPath(_, appId).getOrElse(return None)) 75 | ) 76 | ) 77 | 78 | override def withoutAppId: TransportResource = copy( 79 | clusterId = clusterId.map(Cluster.removeAppFromPath) 80 | ) 81 | } 82 | 83 | def apply( 84 | id: Long, 85 | latitude: Double, 86 | longitude: Double, 87 | status: TransportStatus, 88 | metadata: JsObject, 89 | commodities: Option[Seq[Commodity]], 90 | clusterId: String 91 | ): Transport = { 92 | val v = new Transport 93 | v.id = id 94 | v.latitude = latitude 95 | v.longitude = longitude 96 | v.status = status 97 | v.metadata = metadata 98 | commodities.map(cs => v.commodityList.addAll(cs.asJava)) 99 | v.cluster = Cluster.Dao.read(clusterId).getOrElse( 100 | throw new IllegalArgumentException("No Cluster with id: " + clusterId +" found") 101 | ) 102 | v 103 | } 104 | 105 | def unapply(v: Transport): Option[(Long, Double, Double, TransportStatus, JsObject, Option[Seq[Commodity]], String)] = 106 | Some(( 107 | v.id, 108 | v.latitude, 109 | v.longitude, 110 | v.status, 111 | v.metadata, 112 | Some(v.commodities), 113 | Cluster.removeAppFromPath(v.cluster.id) 114 | )) 115 | } 116 | 117 | @Entity 118 | class Transport() extends Model with HasId with HasCluster { 119 | 120 | @Id 121 | @Column(nullable = false) 122 | @GeneratedValue(strategy = GenerationType.IDENTITY) 123 | var id: Long = 0 124 | 125 | @Column(nullable=false) 126 | var latitude: Double = 0 127 | 128 | @Column(nullable=false) 129 | var longitude: Double = 0 130 | 131 | @ManyToOne 132 | @JoinColumn 133 | var cluster: Cluster = null 134 | 135 | @Column(nullable=false) 136 | @Enumerated 137 | var status: TransportStatus = TransportStatus.Offline 138 | 139 | @Column(length = 255) 140 | var metadata: JsObject = JsObject(Seq.empty) 141 | 142 | @OneToMany(mappedBy = "transport", cascade=Array(CascadeType.ALL)) 143 | var commodityList: java.util.List[Commodity] = new util.ArrayList[Commodity]() 144 | 145 | def commodities: Seq[Commodity] = commodityList.asScala 146 | 147 | override def toString = { 148 | "Transport(" + id + ", " + latitude + ", " + longitude + ", " + status + ", " + metadata +")" 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /app/io/pathfinder/models/TransportStatus.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder.models; 2 | 3 | import com.avaje.ebean.annotation.EnumValue; 4 | import io.pathfinder.util.JavaEnumFormat; 5 | import play.api.libs.json.Format; 6 | 7 | public enum TransportStatus { 8 | @EnumValue("0") 9 | Offline, 10 | @EnumValue("1") 11 | Online; 12 | 13 | public static final Format format = new JavaEnumFormat<>(TransportStatus.class); 14 | } 15 | -------------------------------------------------------------------------------- /app/io/pathfinder/routing/Action.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.routing 2 | 3 | import io.pathfinder.models.{Transport, Commodity} 4 | import play.api.libs.json._ 5 | 6 | sealed abstract class Action(val name: String) { 7 | def latitude: Double 8 | def longitude: Double 9 | } 10 | 11 | object Action { 12 | 13 | case class Start( 14 | latitude: Double, 15 | longitude: Double 16 | ) extends Action("Start") { 17 | def this(v: Transport) = this( 18 | v.latitude, 19 | v.longitude 20 | ) 21 | } 22 | 23 | sealed abstract class CommodityAction(name: String) extends Action(name) { 24 | def commodity: Commodity 25 | } 26 | 27 | case class PickUp( 28 | latitude: Double, 29 | longitude: Double, 30 | commodity: Commodity 31 | ) extends CommodityAction("PickUp") { 32 | def this(com: Commodity) = this( 33 | com.startLatitude, 34 | com.startLongitude, 35 | com 36 | ) 37 | } 38 | 39 | case class DropOff( 40 | latitude: Double, 41 | longitude: Double, 42 | commodity: Commodity 43 | ) extends CommodityAction("DropOff") { 44 | def this(com: Commodity) = this( 45 | com.endLatitude, 46 | com.endLongitude, 47 | com 48 | ) 49 | } 50 | 51 | implicit val writes = Writes[Action]{ 52 | a => 53 | val json = Json.obj( 54 | "action" -> a.name, 55 | "latitude" -> a.latitude, 56 | "longitude" -> a.longitude 57 | ) 58 | a match { 59 | case coma : CommodityAction => json++Json.obj("commodity"->Commodity.format.writes(coma.commodity)) 60 | case x => json 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/io/pathfinder/routing/ClusterRoute.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.routing 2 | 3 | import io.pathfinder.models.{Cluster, Commodity} 4 | import io.pathfinder.websockets.ModelTypes 5 | import io.pathfinder.websockets.WebSocketMessage.Routed 6 | import play.api.libs.json.{Writes, JsString, JsObject} 7 | 8 | case class ClusterRoute(id: String, routes: Seq[Route], unroutedCommodities: Seq[Commodity]) { 9 | def routed: Routed = Routed( 10 | ModelTypes.Cluster, 11 | JsObject(Seq( 12 | "id" -> JsString(Cluster.removeAppFromPath(id)) 13 | )), 14 | Writes.seq(Route.writes).writes(routes), 15 | Some(unroutedCommodities) 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/io/pathfinder/routing/ClusterRouter.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.routing 2 | 3 | import akka.actor.{ActorRef, Props} 4 | import akka.event.{ActorEventBus, LookupClassification} 5 | import com.avaje.ebean.Model 6 | import io.pathfinder.config.Global 7 | import io.pathfinder.models.ModelId.ClusterPath 8 | import io.pathfinder.models.{TransportStatus, CommodityStatus, ModelId, Commodity, Transport, Cluster} 9 | import io.pathfinder.routing.Action.{DropOff, PickUp} 10 | import io.pathfinder.routing.ClusterRouter.CacheState.{Updating, UpToDate} 11 | import io.pathfinder.routing.ClusterRouter.ClusterRouterMessage.{Recalculate, RouteRequest, ClusterEvent} 12 | import io.pathfinder.websockets.WebSocketMessage.{Recalculated, Error, Routed} 13 | import io.pathfinder.websockets.pushing.EventBusActor 14 | import io.pathfinder.websockets.{WebSocketMessage, Events, ModelTypes} 15 | import play.Logger 16 | import play.api.Play 17 | import play.api.libs.json.{JsResultException, JsString, JsArray, Reads, __, JsNumber, JsObject, Writes, JsValue} 18 | import play.api.libs.ws.{WSResponse, WS} 19 | import play.api.Play.current 20 | import scala.concurrent.Future 21 | import scala.concurrent.ExecutionContext.Implicits.global 22 | import play.api.libs.functional.syntax._ 23 | import scala.language.postfixOps 24 | import scala.concurrent.duration._ 25 | 26 | import scala.util.{Failure, Success, Try} 27 | 28 | object ClusterRouter { 29 | 30 | type Row = Seq[Int] 31 | type Matrix = Seq[Row] 32 | 33 | protected val ZERO = BigDecimal(0) 34 | protected implicit val BigDecimalReads = Reads.JsNumberReads.map(_.value) 35 | 36 | val routingServer = WS.url(Play.current.configuration.getString("routing.server").getOrElse( 37 | throw new scala.Error("routing.server not defined in application.conf, routing will not work") 38 | )).withHeaders( 39 | "Content-Type" -> "application/json" 40 | ) 41 | 42 | def props(cluster: Cluster): Props = Props(new ClusterRouter(cluster.id)) 43 | def props(clusterPath: String): Props = Props(new ClusterRouter(clusterPath)) 44 | 45 | abstract sealed class ClusterRouterMessage 46 | 47 | object ClusterRouterMessage { 48 | 49 | /*** For when an item in the route is updated **/ 50 | case class ClusterEvent(event: Events.Value, model: Model) extends ClusterRouterMessage 51 | 52 | /*** For when a client requests to see a specific route **/ 53 | case class RouteRequest(client: ActorRef, id: ModelId) extends ClusterRouterMessage 54 | 55 | case class Recalculate(client: ActorRef) extends ClusterRouterMessage 56 | } 57 | 58 | object DistanceFinder { 59 | 60 | val googleMaps = WS.url("https://maps.googleapis.com/maps/api/distancematrix/json") 61 | val apiKey = Play.configuration.getString("google.key").orElse{ 62 | Logger.warn("No API key set in application.conf file") 63 | None 64 | } 65 | 66 | def makeRequest(origins: TraversableOnce[(Double, Double)], dests: TraversableOnce[(Double, Double)]): Future[WSResponse] = { 67 | val req = apiKey.fold { 68 | googleMaps.withQueryString( 69 | "origins" -> origins.map(latlng => f"${latlng._1}%.4f,${latlng._2}%.4f").mkString("|"), 70 | "destinations" -> dests.map(latlng => f"${latlng._1}%.4f,${latlng._2}%.4f").mkString("|")) 71 | } { key => 72 | googleMaps.withQueryString( 73 | "origins" -> origins.map(latlng => f"${latlng._1}%.4f,${latlng._2}%.4f").mkString("|"), 74 | "destinations" -> dests.map(latlng => f"${latlng._1}%.4f,${latlng._2}%.4f").mkString("|"), 75 | "key" -> key) 76 | } 77 | Logger.info(req.toString) 78 | req.get() 79 | } 80 | 81 | def parseResponse(res: WSResponse): Try[(Matrix,Matrix)] = Try( 82 | res.json.validate( 83 | (__ \ "rows").read[Seq[JsValue]].map( 84 | _.map(_.validate( 85 | (__ \ "elements").read[Seq[JsValue]].map( 86 | _.map( 87 | _.validate( 88 | (__ \ "distance" \ "value").read[Int] and 89 | (__ \ "duration" \ "value").read[Int] 90 | tupled 91 | ).get 92 | ).unzip 93 | ) 94 | ).get).unzip 95 | ) 96 | ).get 97 | ) 98 | 99 | def find(origins: Seq[(Double, Double)], dests: Seq[(Double, Double)]): Future[(Matrix,Matrix)] = 100 | if(origins.isEmpty || dests.isEmpty) 101 | Future.successful((Seq.empty, Seq.empty)) 102 | else makeRequest(origins, dests).map{ resp => 103 | Logger.info(resp.body) 104 | resp 105 | }.map(parseResponse).flatMap( 106 | _.recover{case t => return Future.failed(t)}.map(Future.successful).get 107 | ) 108 | } 109 | 110 | abstract sealed class CacheState { 111 | def asFuture: Future[ClusterRoute] 112 | def events: Seq[ClusterEvent] 113 | } 114 | 115 | object CacheState { 116 | 117 | // a route calculation is in progress 118 | case class Updating(routes: Future[ClusterRoute], events: Seq[ClusterEvent]) extends CacheState { 119 | override def asFuture: Future[ClusterRoute] = routes 120 | } 121 | 122 | // an up to date route is available 123 | case class UpToDate(routes: ClusterRoute) extends CacheState { 124 | override def asFuture: Future[ClusterRoute] = { 125 | Logger.info("Using cached routes") 126 | Future.successful(routes) 127 | } 128 | override def events = Seq.empty 129 | } 130 | } 131 | } 132 | 133 | class ClusterRouter(clusterPath: String) extends EventBusActor with ActorEventBus with LookupClassification { 134 | import ClusterRouter._ 135 | 136 | @volatile 137 | private var cachedRoutes: CacheState = handleUpdating(Updating(recalculate(), Seq.empty)) 138 | 139 | override type Event = (ModelId, Routed) 140 | override type Classifier = ModelId // subscribe by model and by id 141 | 142 | override protected def classify(event: Event): Classifier = event._1 143 | 144 | override protected def publish(event: Event, subscriber: ActorRef): Unit = { 145 | subscriber ! event._2 146 | } 147 | 148 | override def subscribe(client: ActorRef, c: Classifier): Boolean = { 149 | c match { 150 | case ClusterPath(path) => super.subscribe(client, ClusterPath(clusterPath)) 151 | case modelId => super.subscribe(client, modelId) 152 | } 153 | Logger.info("Websocket: "+ client+" subscribed to route updates for: "+c) 154 | super.subscribe(client, c) 155 | } 156 | 157 | def vehicleRouted(route: Route): Routed = Routed( 158 | ModelTypes.Transport, 159 | Transport.format.writes(route.transport), 160 | Route.writes.writes(route), 161 | None 162 | ) 163 | 164 | def publish(cr: ClusterRoute): Unit = { 165 | publish((ModelId.ClusterPath(clusterPath), cr.routed)) // send list of routes to cluster subscribers 166 | cr.routes.foreach { route => 167 | val routeJson: JsValue = Route.writes.writes(route) 168 | val vehicleJson: JsValue = Transport.format.writes(route.transport) 169 | 170 | // publish vehicles to vehicle subscribers 171 | publish((ModelId.TransportId(route.transport.id), Routed(ModelTypes.Transport, vehicleJson, routeJson, None))) 172 | route.actions.tail.collect { 173 | case PickUp(lat, lng, com) => 174 | val comJson = Commodity.format.writes(com) // publish commodities to commodity subscribers 175 | publish((ModelId.CommodityId(com.id), Routed(ModelTypes.Commodity, comJson, routeJson, None))) 176 | case _Else => Unit 177 | } 178 | } 179 | } 180 | 181 | private def addErrorHandling[E](f: Future[E]): Future[E] = { 182 | f.onFailure{ 183 | case e: Throwable => 184 | Logger.error("Error updating routes for cluster: " + clusterPath, e) 185 | } 186 | f 187 | } 188 | 189 | private def after[E](f: Future[E]): Future[E] = 190 | akka.pattern.after(0.seconds, Global.actorSystem.scheduler)(f) 191 | 192 | override protected def mapSize(): Int = 16 193 | 194 | // returns Some(ClusterRoute) when the routes can be changed without a recalculation, otherwise it returns None 195 | private def handleEvent(e: ClusterEvent, cr: ClusterRoute): Option[ClusterRoute] = 196 | e match { 197 | case ClusterEvent(Events.Updated, v: Transport) => 198 | Logger.info("vehicle Updated received: " + e) 199 | Logger.info("for route: " + cr) 200 | if (TransportStatus.Offline.equals(v.status)) { 201 | if (cr.routes.exists(_.transport.id == v.id)) { 202 | None 203 | } else { 204 | Some(cr) 205 | } 206 | } else { 207 | var found = 0 208 | val replacement = cr.routes.map { 209 | route => 210 | if (route.transport.id == v.id) { 211 | found += 1 212 | route.copy(transport = v, actions = new Action.Start(v) +: route.actions.tail) 213 | } else route 214 | } 215 | if (found == 1) { 216 | // good to go 217 | Some(cr.copy(routes = replacement)) 218 | } else { 219 | if (found > 1) { 220 | Logger.warn( 221 | "Received vehicle update for vehicle:" + v.id + " with " + found + " routes in cluster:" + clusterPath + ", routing is probably broken." 222 | ) 223 | } 224 | None // vehicle was turned Online 225 | } 226 | } 227 | case ClusterEvent(event, model) => None 228 | } 229 | 230 | private def handleEvents(es: Seq[ClusterEvent], init: ClusterRoute): (ClusterRoute,Boolean) = 231 | ( 232 | es.foldLeft(init) { 233 | (cr, e) => handleEvent(e, cr).getOrElse(return (cr, false)) 234 | }, 235 | true 236 | ) 237 | 238 | private def handleUpdating(u: Updating): Updating = { 239 | handleUpdating(u.routes) 240 | u 241 | } 242 | 243 | private def handleUpdating(futureRoutes: Future[ClusterRoute]): Unit = { 244 | futureRoutes.onComplete { routeTry => 245 | routeTry.map { cr => 246 | synchronized { 247 | handleEvents(cachedRoutes.events, cr) match { 248 | case (ncr, true) => 249 | cachedRoutes = CacheState.UpToDate(ncr) 250 | publish(ncr) 251 | case (ncr, false) => 252 | cachedRoutes = handleUpdating(Updating(recalculate(), Seq.empty)) 253 | publish(ncr) 254 | } 255 | } 256 | } 257 | } 258 | } 259 | 260 | /* 261 | * The cluster router just recalculates whenever a route request or update occurs 262 | */ 263 | override def receive: Receive = { 264 | case Recalculate(client) => 265 | synchronized { 266 | Logger.info("ClusterRouter received recalculate request") 267 | cachedRoutes match { 268 | case UpToDate(routes) => 269 | val updating = handleUpdating(Updating(recalculate(), Seq.empty)) 270 | updating.routes.onSuccess{ 271 | case x => client ! Recalculated(Cluster.removeAppFromPath(clusterPath)) 272 | } 273 | updating.routes.onFailure{ 274 | case e => client ! WebSocketMessage.Error("Failed to recalculate route: " + e.getMessage) 275 | } 276 | case Updating(future, events) => // we ignore the events since we are updating everything 277 | val next = after(future).flatMap(x => recalculate()).recoverWith{case x => recalculate()} 278 | cachedRoutes = handleUpdating(Updating( 279 | next, 280 | Seq.empty 281 | )) 282 | next.onSuccess{ 283 | case x => client ! Recalculated(Cluster.removeAppFromPath(clusterPath)) 284 | } 285 | next.onFailure { 286 | case e => client ! WebSocketMessage.Error("Failed to recalculate route: " + e.getMessage) 287 | } 288 | } 289 | } 290 | case e: ClusterEvent => 291 | if(e.model match { 292 | case v: Transport => v.cluster.id == clusterPath 293 | case c: Commodity => c.cluster.id == clusterPath 294 | case cl: Cluster => cl.id == clusterPath 295 | }) { 296 | synchronized { 297 | Logger.info("Received event :" + e) 298 | Logger.info("Cached Routes: " + cachedRoutes) 299 | cachedRoutes match { 300 | case UpToDate(cr) => 301 | cachedRoutes = handleEvent(e, cr).map { ncr => 302 | publish(ncr) 303 | UpToDate(ncr) 304 | }.getOrElse { 305 | handleUpdating(Updating(recalculate(), Seq.empty)) 306 | } 307 | case u: Updating => cachedRoutes = u.copy(events = u.events :+ e) 308 | } 309 | } 310 | } 311 | case RouteRequest(client, mId) => cachedRoutes.asFuture.recoverWith{ 312 | case t => 313 | client ! Error("Failed to route cluster: "+t.getMessage) 314 | Future.failed(t) 315 | }.foreach { rc => 316 | mId match { 317 | case ModelId.ClusterPath(path) => client ! rc.routed 318 | case ModelId.TransportId(id) => rc.routes.find(_.transport.id == id).foreach { route => 319 | client ! vehicleRouted(route).withoutApp 320 | } 321 | case ModelId.CommodityId(id) => 322 | var commodity: Commodity = null 323 | rc.routes.find { route => 324 | route.actions.exists { 325 | case PickUp(lat, lng, com) => 326 | if (com.id == id) { 327 | commodity = com 328 | true 329 | } else false 330 | case x => false 331 | } 332 | }.foreach { route => 333 | client ! Routed( 334 | ModelTypes.Commodity, 335 | Commodity.format.writes(commodity), 336 | Route.writes.writes(route), 337 | None 338 | ).withoutApp 339 | } 340 | } 341 | } 342 | case _Else => super.receive(_Else) 343 | } 344 | 345 | val matrixWriter: Writes[Matrix] = Writes.seq[Row] //Json.writes[Array[Array[Double]]] 346 | 347 | def parseRoutingResponse(vehicles: Seq[Transport], commodities: Seq[Commodity], result: WSResponse): Try[Seq[Route]] = 348 | result.json.validate( 349 | (__ \ "routes").read( 350 | Reads.seq(Reads.seq(Reads.JsNumberReads.map(_.value.toInt))).map(routes => 351 | routes.map { arr => 352 | val routeBuilder = Route.newBuilder(vehicles(arr.head - 1 - 2 * commodities.size)) 353 | if(arr.length > 1){ 354 | arr.tail.foreach{ i => 355 | Logger.info("COM SIZE: " + commodities.size) 356 | if (i <= commodities.size) { 357 | routeBuilder += new PickUp(commodities(i - 1)) 358 | } else { 359 | routeBuilder += new DropOff(commodities(i - commodities.size - 1)) 360 | } 361 | } 362 | } 363 | routeBuilder.result() 364 | } 365 | ) 366 | ) 367 | ).fold( 368 | { t => return Failure(JsResultException(t)) }, 369 | //Logger.error("Failed to parse response json", new JsResultException(ex)); return Failure(ex) }, 370 | routes => Success(routes) 371 | ) 372 | 373 | private def recalculate(): Future[ClusterRoute] = { 374 | 375 | val cluster: Cluster = Cluster.Dao.read(clusterPath).getOrElse{ 376 | Logger.warn("Cluster with id: "+clusterPath+" missing") 377 | // return Future.failed(null) 378 | throw new scala.Error("NO CLUSTER WITH ID: " + clusterPath) 379 | } 380 | Logger.info("RECALCULATING") 381 | val vehicles = cluster.transports.filter(v => TransportStatus.Online.equals(v.status)) 382 | val commodities = cluster.commodities.filter( 383 | c => CommodityStatus.Waiting.equals(c.status) || CommodityStatus.PickedUp.equals(c.status) 384 | ) 385 | if (vehicles.size <= 0) { 386 | Logger.info("Someone asked Router to recalculate but there are no vehicles in cluster.") 387 | return Future.successful(ClusterRoute(clusterPath, Seq.empty, commodities)) 388 | } 389 | 390 | if (commodities.size <= 0) { 391 | Logger.info("someone asked for Router to recalculate but there are no commodities in cluster") 392 | return Future.successful(ClusterRoute(clusterPath, vehicles.map(v => Route(v, Seq(new Action.Start(v)))), Seq.empty)) 393 | } 394 | 395 | val starts = vehicles.map(x => (x.latitude, x.longitude)) 396 | val pickups = commodities.map(c => (c.startLatitude, c.startLongitude)) 397 | val dropOffs = commodities.map(c => (c.endLatitude, c.endLongitude)) 398 | val body: Future[JsValue] = for { 399 | (startToPickUpDist, startToPickUpDur) <- DistanceFinder.find(starts, pickups) 400 | (startToDropOffDist, startToDropOffDur) <- DistanceFinder.find(starts, dropOffs) 401 | (pickUpToDropOffDist, pickUpToDropOffDur) <- DistanceFinder.find(pickups, dropOffs) 402 | (pickUpToPickUpDist, pickUpToPickUpDur) <- DistanceFinder.find(pickups, pickups) 403 | (dropOffToPickUpDist, dropOffToPickUpDur) <- DistanceFinder.find(dropOffs, pickups) 404 | (dropOffToDropOffDist, dropOffToDropOffDur) <- DistanceFinder.find(dropOffs, dropOffs) 405 | } yield { 406 | def makeMatrix( 407 | startsToPickUps: Matrix, 408 | startsToDropOffs: Matrix, 409 | pickUpsToDropOffs: Matrix, 410 | pickUpsToPickUps: Matrix, 411 | dropOffsToPickUps: Matrix, 412 | dropOffsToDropOffs: Matrix 413 | ) = JsArray(( 414 | pickUpsToPickUps.zip(pickUpsToDropOffs).map( 415 | tup => tup._1 ++ tup._2 ++ Seq.fill(vehicles.size)(0) 416 | ) ++ dropOffsToPickUps.zip(dropOffsToDropOffs).map( 417 | tup => tup._1 ++ tup._2 ++ Seq.fill(vehicles.size)(0) 418 | ) ++ startsToPickUps.zip(startsToDropOffs).map( 419 | tup => tup._1 ++ tup._2 ++ Seq.fill(vehicles.size)(0) 420 | )).map(row => JsArray(row.map(JsNumber(_)))) 421 | ) 422 | 423 | val vehicleTable = JsArray(vehicles.indices.map(num => JsNumber(num + 2 * commodities.size + 1))) 424 | val comTable = JsObject(commodities.indices.map{ num => 425 | ( 426 | (num + commodities.size + 1).toString, 427 | JsNumber(BigDecimal( 428 | Option(commodities(num).transport).map { vehicle => 429 | vehicles.zipWithIndex.find(kv => kv._1.id == vehicle.id).get._2 + 2 * commodities.size + 1 430 | } getOrElse (num + 1) 431 | )) 432 | ) 433 | }) 434 | val capacities = JsObject(cluster.application.capacityParameters.map { p => 435 | p.parameter -> JsObject( 436 | commodities.zipWithIndex.foldLeft(Seq.empty[(String, JsValue)]) { 437 | case (seq, (com, i)) => 438 | val cap = com.metadata.validate((__ \ p.parameter).read(BigDecimalReads)).getOrElse(ZERO) 439 | seq :+ (i + 1).toString -> JsNumber(cap) :+ 440 | (i + 1 + commodities.size).toString -> JsNumber(-cap) 441 | } ++ vehicles.zipWithIndex.map { 442 | case (veh, i) => 443 | (i + 1 + 2 * commodities.size).toString -> JsNumber( 444 | veh.metadata.validate( 445 | (__ \ p.parameter).read(BigDecimalReads) 446 | ).getOrElse( 447 | BigDecimal(Integer.MAX_VALUE) 448 | ) - veh.commodities.map( 449 | _.metadata.validate((__ \ p.parameter).read[JsNumber].map(_.value)).getOrElse(ZERO) 450 | ).reduceOption(_+_).getOrElse(ZERO) 451 | ) 452 | } 453 | ) 454 | }) 455 | Logger.info("Capacities: " + capacities) 456 | val parameters = JsObject(cluster.application.objectiveParameters.map { p => 457 | p.parameter -> JsObject( 458 | commodities.zipWithIndex.foldLeft(Seq.empty[(String,JsValue)]) { 459 | case (seq, (com, i)) => 460 | val par = com.metadata.validate( 461 | (__ \ p.parameter).read[JsNumber] 462 | ).getOrElse(JsNumber(0)) 463 | seq :+ (i + 1).toString -> par :+ (i + 1 + commodities.size).toString -> par 464 | } ++ vehicles.zipWithIndex.map { 465 | case (veh, i) => 466 | (i + 1 + 2 * commodities.size).toString -> 467 | veh.metadata.validate((__ \ p.parameter).read[JsNumber]).getOrElse(JsNumber(Integer.MAX_VALUE)) 468 | } 469 | ) 470 | } :+ ( 471 | "request_time" -> JsObject( 472 | commodities.zipWithIndex.foldLeft(Seq.empty[(String, JsValue)]) { 473 | case (seq, (com, i)) => 474 | val time = JsNumber(com.requestTime.getTime / 1000) 475 | seq :+ (i + 1).toString -> time :+ (i + 1 + commodities.size).toString -> time 476 | } ++ vehicles.indices.map(i => (i + 1 + 2 * commodities.size).toString -> JsNumber(0)) 477 | ))) 478 | Logger.info("Parameters: " + parameters) 479 | val fun = cluster.application.objectiveFunction 480 | fun.refresh() 481 | Logger.info("Objective Function : "+fun.id+" : "+fun.function) 482 | JsObject(Seq( 483 | "commodities" -> comTable, 484 | "transports" -> vehicleTable, 485 | "capacities" -> capacities, 486 | "distances" -> makeMatrix( 487 | startToPickUpDist, 488 | startToDropOffDist, 489 | pickUpToDropOffDist, 490 | pickUpToPickUpDist, 491 | dropOffToPickUpDist, 492 | dropOffToDropOffDist 493 | ), 494 | "durations" -> makeMatrix( 495 | startToPickUpDur, 496 | startToDropOffDur, 497 | pickUpToDropOffDur, 498 | pickUpToPickUpDur, 499 | dropOffToPickUpDur, 500 | dropOffToDropOffDur 501 | ), 502 | "parameters" -> parameters, 503 | "objective" -> JsString(fun.function) 504 | )) 505 | } 506 | addErrorHandling(body.recoverWith[JsValue]{ 507 | case t => 508 | Logger.error("Failed to request and receive route data from google maps api", t) 509 | Future.failed(t) 510 | }.flatMap[WSResponse]{ json => 511 | Logger.info("Sending message: " + json) 512 | routingServer.post( 513 | json 514 | ).map{ response => 515 | Logger.info("Received routing response: "+response.body) 516 | response 517 | }.recoverWith[WSResponse]{ 518 | case t => 519 | Logger.warn("Failed to read route response", t) 520 | Future.failed(t) 521 | } 522 | }.flatMap { resp => 523 | parseRoutingResponse(vehicles, commodities, resp).map(routes => 524 | Future.successful(ClusterRoute(clusterPath, routes, Seq.empty))).recover{ 525 | case t => Future.failed(t) 526 | }.get 527 | }) 528 | } 529 | } 530 | -------------------------------------------------------------------------------- /app/io/pathfinder/routing/Route.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.routing 2 | 3 | import io.pathfinder.models.Transport 4 | import io.pathfinder.routing.Action.Start 5 | import play.api.libs.json.Json 6 | 7 | import scala.collection.mutable 8 | 9 | case class Route(transport: Transport, actions: Seq[Action]) { 10 | def this(v: Transport) = this(v,Seq(new Start(v))) 11 | } 12 | 13 | object Route { 14 | private class RouteBuilder(vehicle: Transport) extends mutable.Builder[Action,Route] { 15 | private val actions = Seq.newBuilder[Action] += new Action.Start(vehicle) 16 | 17 | override def +=(elem: Action): RouteBuilder.this.type = { 18 | actions += elem 19 | this 20 | } 21 | 22 | override def result(): Route = Route(vehicle, actions.result()) 23 | 24 | override def clear(): Unit = { 25 | actions.clear() 26 | actions += new Action.Start(vehicle) 27 | } 28 | } 29 | import Action.writes 30 | import Transport.format 31 | def newBuilder(transport: Transport): mutable.Builder[Action,Route] = new RouteBuilder(transport) 32 | val writes = Json.writes[Route] 33 | } 34 | -------------------------------------------------------------------------------- /app/io/pathfinder/routing/Router.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.routing 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | import akka.actor.ActorRef 6 | import akka.event.{SubchannelClassification, ActorEventBus} 7 | import akka.util.{Subclassification, Timeout} 8 | import io.pathfinder.config.Global 9 | import io.pathfinder.models.{ModelId, HasCluster, Cluster, Commodity, Transport} 10 | import io.pathfinder.routing.ClusterRouter.ClusterRouterMessage 11 | import io.pathfinder.routing.ClusterRouter.ClusterRouterMessage.{RouteRequest, ClusterEvent} 12 | import io.pathfinder.websockets.pushing.EventBusActor.EventBusMessage.Subscribe 13 | import io.pathfinder.websockets.{WebSocketMessage, Events} 14 | import play.Logger 15 | import scala.collection.mutable 16 | import scala.concurrent.duration.{FiniteDuration, DurationInt} 17 | import scala.concurrent.ExecutionContext.Implicits.global 18 | 19 | /** 20 | * The Router is an object that is responsible for dispatching subscription requests and route updates to cluster routers. 21 | */ 22 | object Router extends ActorEventBus with SubchannelClassification { 23 | 24 | override type Classifier = String // cluster path 25 | override type Event = (String, Any) // cluster path and message to cluster router 26 | override type Subscriber = ActorRef 27 | 28 | // SubchannelClassification is retarded and didn't provide a way to view the current subscriptions 29 | private def add(path: String): Boolean = { 30 | val clusters = Cluster.byPrefix(path) 31 | clusters.foreach { c => 32 | if(!subs.contains(c.id)) { 33 | val ref = Global.actorSystem.actorOf(ClusterRouter.props(c)) 34 | if (subscribe(ref, c.id)) { 35 | Logger.info("adding cluster router: " + ref + ", to cluster: " + c.id) 36 | subs.put(c.id, ref) 37 | } else { 38 | Global.actorSystem.stop(ref) 39 | Logger.warn("Failed to subscribe cluster router to cluster: " + c.id) 40 | return false 41 | } 42 | } 43 | } 44 | true 45 | } 46 | 47 | private def remove(path: String): Boolean = { 48 | val ref = subs.get(path).orElse(return false).get 49 | unsubscribe(ref) 50 | true 51 | } 52 | 53 | protected val subs: mutable.Map[String, ActorRef] = new mutable.HashMap[String, ActorRef] 54 | 55 | override protected val subclassification = new Subclassification[Classifier] { 56 | override def isEqual(x: String, y: String): Boolean = x.equals(y) 57 | 58 | override def isSubclass(x: String, y: String): Boolean = y.startsWith(x) 59 | } 60 | 61 | override protected def classify(event: Event): Classifier = event._1 62 | 63 | override protected def publish(event: Event, subscriber: ActorRef): Unit = subscriber ! event._2 64 | 65 | private def clusterFromId(id: ModelId): Option[Cluster] = id match { 66 | case ModelId.TransportId(vId) => 67 | Transport.Dao.read(vId).map(_.cluster) 68 | case ModelId.CommodityId(cId) => 69 | Commodity.Dao.read(cId).map(_.cluster) 70 | case ModelId.ClusterPath(path) => 71 | Cluster.Dao.read(path) 72 | } 73 | 74 | def subscribeToRoute(client: ActorRef, id: ModelId): Boolean = { 75 | implicit val timeout = Timeout(2.seconds) // used for the recalculation futures used right below 76 | 77 | val cluster = clusterFromId(id).getOrElse(return false) 78 | if(!subs.contains(cluster.id)){ 79 | add(cluster.id) 80 | } 81 | Logger.info(client + " is subscribing to :" + id) 82 | publish((cluster.id, Subscribe(client, id))) 83 | publish(cluster, RouteRequest(client, id)) 84 | true 85 | } 86 | 87 | def routeRequest(client: ActorRef, id: ModelId): Boolean = { 88 | val cluster = clusterFromId(id).getOrElse(return false) 89 | if(!subs.contains(cluster.id)){ 90 | add(cluster.id) 91 | } 92 | publish(cluster, RouteRequest(client, id)) 93 | true 94 | } 95 | 96 | def publish(cluster: Cluster, msg: ClusterRouterMessage): Unit = { 97 | publish((cluster.id, msg)) 98 | } 99 | 100 | def publish(event: Events.Value, model: HasCluster): Unit = { 101 | publish(model.cluster, ClusterEvent(event, model)) 102 | } 103 | 104 | def recalculate(client: ActorRef, clusterId: String): Unit = { 105 | if(subs.contains(clusterId) || Cluster.Dao.read(clusterId).exists(c => add(clusterId))) { 106 | publish((clusterId, ClusterRouterMessage.Recalculate(client))) 107 | } else { 108 | client ! WebSocketMessage.Error("No Cluster with id: " + Cluster.removeAppFromPath(clusterId) + " found") 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/io/pathfinder/util/EnumerationFormat.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.util 2 | 3 | import play.api.libs.json.{JsError, JsSuccess, Reads, JsString, JsResult, JsValue, Writes, Format} 4 | 5 | import scala.util.Try 6 | 7 | class EnumerationFormat[E <: Enumeration](enum: E) extends Format[E#Value] { 8 | override def writes(o: E#Value): JsValue = JsString(o.toString) 9 | 10 | override def reads(json: JsValue): JsResult[E#Value] = 11 | Reads.StringReads.reads(json).flatMap{ 12 | str => Try(JsSuccess(enum.withName(str))).getOrElse(JsError()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/io/pathfinder/util/HasFormat.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.util 2 | 3 | import play.api.libs.json.Format 4 | 5 | trait HasFormat extends Enumeration { 6 | implicit val format: Format[this.type#Value] = new EnumerationFormat[this.type](this) 7 | } 8 | -------------------------------------------------------------------------------- /app/io/pathfinder/util/JavaEnumFormat.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.util 2 | 3 | import java.util 4 | 5 | import play.api.libs.json.Reads.StringReads 6 | import play.api.libs.json.{Writes, JsError, JsResult, JsValue, Format} 7 | 8 | import scala.util.Try 9 | 10 | class JavaEnumFormat[E <: Enum[E]](c: Class[E]) extends Format[E] { 11 | 12 | private val valueOf = c.getMethod("valueOf", classOf[String]) 13 | 14 | override def reads(json: JsValue): JsResult[E] = Try( 15 | StringReads.reads(json).map(valueOf.invoke(null, _).asInstanceOf[E]) 16 | ).getOrElse(JsError("Invalid Enum Value: "+json+" must be one of: "+c.getEnumConstants.mkString(", "))) 17 | 18 | override def writes(o: E): JsValue = Writes.StringWrites.writes(o.name()) 19 | } 20 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/Events.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets 2 | 3 | import io.pathfinder.util.HasFormat 4 | 5 | /** 6 | * the valid events that a client may subscribe to 7 | */ 8 | object Events extends Enumeration with HasFormat { 9 | type Event = Value 10 | val Created, Updated, Deleted = Value 11 | } 12 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/ModelTypes.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets 2 | 3 | import io.pathfinder.util.HasFormat 4 | 5 | /** 6 | * An enum containing the models that a websocket message can use 7 | */ 8 | object ModelTypes extends Enumeration with HasFormat { 9 | type ModelType = Value 10 | val Transport, Commodity, Cluster = Value 11 | } 12 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/WebSocketActor.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets 2 | 3 | import akka.actor.{Props, Actor, ActorRef} 4 | import io.pathfinder.models.ModelId.{ClusterPath, CommodityId, TransportId} 5 | import io.pathfinder.models.{Transport, Commodity} 6 | import io.pathfinder.routing.Router 7 | import play.api.Play 8 | import io.pathfinder.websockets.ModelTypes.ModelType 9 | import io.pathfinder.websockets.WebSocketMessage._ 10 | import io.pathfinder.websockets.pushing.PushSubscriber 11 | import play.Logger 12 | import io.pathfinder.websockets.controllers.{CommoditySocketController, ClusterSocketController, WebSocketController, VehicleSocketController} 13 | import java.util.UUID 14 | import scala.util.Try 15 | import io.pathfinder.authentication.AuthServer 16 | import scala.concurrent.ExecutionContext.Implicits.global 17 | import play.api.libs.json.{JsSuccess, JsResult, Format, Json, JsValue, __} 18 | import play.api.libs.functional.syntax._ 19 | import io.pathfinder.authentication.AuthenticationStatus 20 | 21 | object WebSocketActor { 22 | private val authenticate = Play.current.configuration.getBoolean("Authenticate").getOrElse(false) 23 | 24 | val controllers: Map[ModelType, WebSocketController] = Map( 25 | ModelTypes.Transport -> VehicleSocketController, 26 | ModelTypes.Cluster -> ClusterSocketController, 27 | ModelTypes.Commodity -> CommoditySocketController 28 | ) 29 | 30 | val observers: Map[ModelType, PushSubscriber] = Map( 31 | ModelTypes.Transport -> Transport.Dao, 32 | ModelTypes.Commodity -> Commodity.Dao 33 | ) 34 | 35 | def props(out: ActorRef, app: String) = Props(new WebSocketActor(out, app, UUID.randomUUID().toString())) 36 | } 37 | 38 | /** 39 | * An actor that manages a websocket connection. It allows the client to make api calls as well as subscribe 40 | * to push notifications. 41 | */ 42 | class WebSocketActor ( 43 | client: ActorRef, 44 | app: String, 45 | id: String 46 | ) extends Actor { 47 | import WebSocketActor.{controllers, observers, authenticate} 48 | 49 | def receive: Receive = { 50 | case Authenticate(opt) => 51 | val res = AuthServer.connection(app, id, opt.getOrElse(false)) 52 | res.onSuccess{ case x => client ! Authenticated(x); context.become(authenticated) } 53 | res.onFailure{ case e => Logger.error("Error from connection request", e); client ! Error(e.getMessage) } 54 | case m: WebSocketMessage => client ! Error("Not Authenticated") 55 | } 56 | 57 | private val authenticated: Receive = { 58 | case m: WebSocketMessage => Try{ 59 | Logger.info("Received Socket Message " + m) 60 | m.withApp(app).getOrElse{ 61 | Logger.info("Could not find app id " + app) 62 | client ! Error("Unable to parse cluster id") 63 | } match { 64 | case Route(id) => 65 | if(!Router.routeRequest(client, id)) { 66 | client ! Error("could not get route, could not find "+ id.modelType + " with id: " + id.id) 67 | } 68 | case RouteSubscribe(id) => 69 | if(Router.subscribeToRoute(client, id)) 70 | client ! RouteSubscribed(id).withoutApp 71 | else 72 | client ! Error("id: "+id.toString+" not found for model: "+id.modelType.toString) 73 | 74 | case Recalculate(cId) => 75 | Router.recalculate(client, cId) 76 | case c: ControllerMessage => controllers.get(c.model).flatMap(_.receive(c, app)).foreach(client ! _) 77 | 78 | case Subscribe(None, _model, Some(id)) => 79 | client ! { 80 | id match { 81 | case TransportId(vId) => 82 | observers(ModelTypes.Transport).subscribeById(vId, client) 83 | Subscribed(None, None, Some(id)).withoutApp 84 | case CommodityId(cId) => 85 | observers(ModelTypes.Commodity).subscribeById(cId, client) 86 | Subscribed(None, None, Some(id)).withoutApp 87 | case ClusterPath(path) => 88 | observers.values.foreach(_.subscribeByClusterPath(path, client)) 89 | Subscribed(Some(path), None, Some(id)).withoutApp 90 | case _Else => Error("Only subscriptions to vehicles and commodities are supported") 91 | } 92 | } 93 | 94 | case Subscribe(Some(path), None, None) => 95 | observers.values.foreach(_.subscribeByClusterPath(path, client)) 96 | client ! Subscribed(Some(path), None, Some(ClusterPath(path))) 97 | 98 | case Subscribe(Some(path), Some(modelType), None) => 99 | client ! observers.get(modelType).map{ obs => 100 | obs.subscribeByClusterPath(path, client) 101 | Subscribed(Some(path), Some(modelType), None).withoutApp 102 | }.getOrElse(Error("Subscriptions to clusters by cluster not supported")) 103 | 104 | // unsubscribe from everything 105 | case Unsubscribe(None, None, None) => 106 | observers.foreach(_._2.unsubscribe(client)) 107 | client ! Unsubscribed(None,None,None).withoutApp 108 | 109 | // unsubscribe from a specified cluster 110 | case Unsubscribe(Some(cId), None, None) => 111 | observers.foreach(_._2.unsubscribeByClusterPath(cId, client)) 112 | client ! Unsubscribed(Some(cId),None,None).withoutApp 113 | 114 | // unsubscribe from cluster for models of a specified type 115 | case Unsubscribe(Some(cId), Some(model), None) => 116 | client ! observers.get(model).map { 117 | obs => 118 | obs.unsubscribeByClusterPath(cId, client) 119 | Unsubscribed(Some(cId), Some(model), None).withoutApp 120 | }.getOrElse(Error("Cannot unsubscribe for model: "+model+" which has no support for subscriptions")) 121 | 122 | // unsibscribe from a single model 123 | case Unsubscribe(None, modelType, Some(id)) => 124 | client ! { 125 | id match { 126 | case TransportId(vId) => 127 | observers(ModelTypes.Transport).unsubscribeById(vId, client) 128 | Unsubscribed(None, modelType, Some(id)).withoutApp 129 | case CommodityId(cId) => 130 | observers(ModelTypes.Commodity).unsubscribeById(cId, client) 131 | Unsubscribed(None, modelType, Some(id)).withoutApp 132 | case ClusterPath(path) => 133 | observers.foreach(_._2.unsubscribeByClusterPath(path, client)) 134 | Unsubscribed(Some(path), None, None).withoutApp 135 | } 136 | } 137 | case u: Unsubscribe => 138 | client ! Error("An unsubscribe message must either have a model id and model type, cluster id and model type, a cluster id, or be empty") 139 | 140 | case UnknownMessage(value) => client ! Error("Received unknown message: " + value.toString) 141 | 142 | case e: Error => client ! e 143 | 144 | case x => client ! Error("received unhandled message: "+ x.toString) 145 | } 146 | }.recover{ case e => 147 | e.printStackTrace() 148 | client ! Error("Unhandled Exception: " + e.getMessage + " : " + e.getStackTrace.mkString("\n\t")) 149 | } 150 | } 151 | 152 | // we could check application options here to see if they want authentication, for now we'll use application.conf 153 | if(authenticate) { 154 | client ! ConnectionId(id) 155 | } else { 156 | context.become(authenticated) 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/WebSocketMessage.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets 2 | 3 | import io.pathfinder.models.{Commodity, Cluster, ModelId} 4 | import io.pathfinder.websockets.WebSocketMessage.MessageCompanion 5 | import io.pathfinder.authentication.AuthenticationStatus._ 6 | import play.Logger 7 | import play.api.libs.json.{JsSuccess, JsResult, Format, Json, JsValue, __} 8 | import play.api.mvc.WebSocket.FrameFormatter 9 | import play.api.libs.functional.syntax._ 10 | import scala.language.postfixOps 11 | 12 | /** 13 | * Contains all of the web socket messages and their json formats 14 | */ 15 | sealed abstract class WebSocketMessage { 16 | protected type M >: this.type <: WebSocketMessage 17 | 18 | def companion: MessageCompanion[M] 19 | 20 | def message: String = companion.message 21 | 22 | def withApp(app: String): Option[M] = Some(this.asInstanceOf[M]) 23 | 24 | def withoutApp: M = this.asInstanceOf[M] 25 | 26 | def toJson: JsValue = companion.format.writes(this.asInstanceOf[M]) 27 | } 28 | 29 | object WebSocketMessage { 30 | import ModelTypes.{ModelType, format => modelFormat} 31 | import Events.{Event, format => eventFormat} 32 | 33 | private val builder = Map.newBuilder[String, MessageCompanion[_ <: WebSocketMessage]] 34 | 35 | sealed abstract class MessageCompanion[M <: WebSocketMessage] { 36 | def message: String 37 | def format: Format[M] 38 | } 39 | 40 | private def addComp(comp: MessageCompanion[_ <: WebSocketMessage]) = builder += comp.message -> comp 41 | 42 | /** 43 | * These messages are routed to controllers based on the model they contain 44 | */ 45 | sealed abstract class ControllerMessage extends WebSocketMessage { 46 | override type M >: this.type <: ControllerMessage 47 | def model: ModelType 48 | } 49 | 50 | sealed abstract class ModelMessage extends ControllerMessage { 51 | override type M >: this.type <: ModelMessage 52 | def id: ModelId 53 | override def model = id.modelType 54 | override def withApp(app: String): Option[M] = id.withAppId(app).map(withId) 55 | override def withoutApp: M = withId(id.withoutAppId) 56 | protected def withId(id: ModelId): M 57 | } 58 | 59 | sealed abstract class SubscriptionMessage extends WebSocketMessage { 60 | override type M >: this.type <: SubscriptionMessage 61 | def clusterId: Option[String] 62 | def model: Option[ModelType] 63 | def id: Option[ModelId] 64 | override def withApp(app: String): Option[M] = Some( 65 | withClusterAndId( 66 | clusterId.map(Cluster.addAppToPath(_, app).getOrElse(return None)), 67 | id.map(_.withAppId(app).getOrElse(return None)) 68 | ) 69 | ) 70 | override def withoutApp: M = withClusterAndId( 71 | clusterId.map(Cluster.removeAppFromPath), 72 | id.map(_.withoutAppId) 73 | ) 74 | protected def withClusterAndId(clusterId: Option[String], id: Option[ModelId]): M 75 | } 76 | 77 | private def simpleModelMessageFormat[M <: ModelMessage](makeMessage: ModelId => M): Format[M] = { 78 | (__ \ "model").format(ModelTypes.format) and 79 | (__ \ "id").format[JsValue] 80 | }.apply[M]( 81 | { (model: ModelType, id: JsValue) => makeMessage(ModelId.read(model, id).get) }, 82 | { mf: M => (mf.id.modelType, ModelId.write(mf.id)) } 83 | ) 84 | 85 | private def subscriptionMessageFormat[M <: SubscriptionMessage]( 86 | makeMessage: (Option[String], Option[ModelType], Option[ModelId]) => M 87 | ) = { 88 | (__ \ "clusterId").formatNullable[String] and 89 | (__ \ "model").formatNullable(ModelTypes.format) and 90 | (__ \ "id").formatNullable[JsValue] 91 | }.apply[M]( 92 | { (path: Option[String], model: Option[ModelType], id: Option[JsValue]) => 93 | makeMessage( 94 | path, 95 | model, 96 | id.map(i => ModelId.read(model.getOrElse(ModelTypes.Cluster), i).get) 97 | ) 98 | }, { 99 | sub: M => (sub.clusterId, sub.model, sub.id.map(ModelId.write)) 100 | } 101 | ) 102 | 103 | /** 104 | * Standard error messages sent to client that make poor request 105 | */ 106 | case class Error(error: String) extends WebSocketMessage { 107 | override type M = Error 108 | override def companion = Error 109 | } 110 | 111 | object Error extends MessageCompanion[Error] { 112 | override val message = "Error" 113 | override val format = Json.format[Error] 114 | } 115 | addComp(Error) 116 | 117 | case class UnknownMessage(value: JsValue) extends WebSocketMessage { 118 | override type M = UnknownMessage 119 | override def companion = UnknownMessage 120 | } 121 | 122 | object UnknownMessage extends MessageCompanion[UnknownMessage] { 123 | override val message = "Unknown" 124 | override val format = new Format[UnknownMessage] { 125 | override def reads(json: JsValue): JsResult[UnknownMessage] = JsSuccess(UnknownMessage(json)) 126 | override def writes(o: UnknownMessage): JsValue = o.value 127 | } 128 | } 129 | addComp(UnknownMessage) 130 | 131 | /** 132 | * Sent by the client to unsubscribe from push notifications 133 | */ 134 | case class Unsubscribe( 135 | clusterId: Option[String], 136 | model: Option[ModelType], 137 | id: Option[ModelId] 138 | ) extends SubscriptionMessage { 139 | override type M = Unsubscribe 140 | override def companion = Unsubscribe 141 | override def withClusterAndId(clusterId: Option[String], id: Option[ModelId]) = 142 | copy(clusterId = clusterId, id = id) 143 | } 144 | 145 | object Unsubscribe extends MessageCompanion[Unsubscribe] { 146 | override val message = "Unsubscribe" 147 | override val format = subscriptionMessageFormat(Unsubscribe.apply) 148 | } 149 | addComp(Unsubscribe) 150 | 151 | /** 152 | * Sent by the client to subscribe to push notifications 153 | */ 154 | case class Subscribe( 155 | clusterId: Option[String], 156 | model: Option[ModelType], 157 | id: Option[ModelId] 158 | ) extends SubscriptionMessage { 159 | override type M = Subscribe 160 | override def companion = Subscribe 161 | override def withClusterAndId(clusterId: Option[String], id: Option[ModelId]): M = 162 | copy(clusterId = clusterId, id = id) 163 | } 164 | 165 | object Subscribe extends MessageCompanion[Subscribe] { 166 | override val message = "Subscribe" 167 | override val format = subscriptionMessageFormat(Subscribe.apply) 168 | } 169 | addComp(Subscribe) 170 | 171 | /** 172 | * Sent by the client to subscribe to route updates 173 | */ 174 | case class RouteSubscribe( 175 | id: ModelId 176 | ) extends ModelMessage { 177 | override type M = RouteSubscribe 178 | override def companion = RouteSubscribe 179 | override def withId(id: ModelId): RouteSubscribe = copy(id = id) 180 | } 181 | 182 | object RouteSubscribe extends MessageCompanion[RouteSubscribe] { 183 | override val message = "RouteSubscribe" 184 | override val format = simpleModelMessageFormat(RouteSubscribe.apply) 185 | } 186 | addComp(RouteSubscribe) 187 | 188 | /** 189 | * Sent by the client to unsubscribe from route updates 190 | */ 191 | case class RouteUnsubscribe( 192 | id: ModelId 193 | ) extends ModelMessage { 194 | override type M = RouteUnsubscribe 195 | override def companion = RouteUnsubscribe 196 | override def withId(id: ModelId): RouteUnsubscribe = copy(id = id) 197 | } 198 | 199 | object RouteUnsubscribe extends MessageCompanion[RouteUnsubscribe] { 200 | override val message = "RouteUnsubscribe" 201 | override val format = simpleModelMessageFormat(RouteUnsubscribe.apply) 202 | } 203 | addComp(RouteUnsubscribe) 204 | 205 | case class RouteSubscribed( 206 | id: ModelId 207 | ) extends ModelMessage { 208 | override type M = RouteSubscribed 209 | override def companion = RouteSubscribed 210 | override def withId(id: ModelId) = copy(id = id) 211 | } 212 | 213 | object RouteSubscribed extends MessageCompanion[RouteSubscribed] { 214 | override val message = "RouteSubscribed" 215 | override val format = simpleModelMessageFormat(RouteSubscribed.apply) 216 | } 217 | addComp(RouteSubscribed) 218 | 219 | /** 220 | * Sent by the client to create a new model 221 | */ 222 | case class Create( 223 | model: ModelType, 224 | value: JsValue 225 | ) extends ControllerMessage { 226 | override type M = Create 227 | override def companion = Create 228 | } 229 | 230 | object Create extends MessageCompanion[Create] { 231 | override val message = "Create" 232 | override val format = Json.format[Create] 233 | } 234 | addComp(Create) 235 | 236 | /** 237 | * Sent by the client to update a model with the specified id 238 | */ 239 | case class Update( 240 | id: ModelId, 241 | value: JsValue 242 | ) extends ModelMessage { 243 | override type M = Update 244 | override def companion = Update 245 | override def withId(id: ModelId) = copy(id = id) 246 | } 247 | 248 | object Update extends MessageCompanion[Update] { 249 | override val message = "Update" 250 | override val format = { 251 | (__ \ "model").format(ModelTypes.format) and 252 | (__ \ "id").format[JsValue] and 253 | (__ \ "value").format[JsValue] 254 | }.apply[Update]( 255 | { (model: ModelType, id: JsValue, value: JsValue) => Update(ModelId.read(model, id).get, value) }, 256 | { u: Update => (u.id.modelType, ModelId.write(u.id), u.value) } 257 | ) 258 | } 259 | addComp(Update) 260 | 261 | /** 262 | * Sent by the client to delete the specified model 263 | */ 264 | case class Delete( 265 | id: ModelId 266 | ) extends ModelMessage { 267 | override type M = Delete 268 | override def companion = Delete 269 | override def withId(id: ModelId) = copy(id = id) 270 | } 271 | 272 | object Delete extends MessageCompanion[Delete] { 273 | override val message = "Delete" 274 | override val format = simpleModelMessageFormat(Delete.apply) 275 | } 276 | addComp(Delete) 277 | 278 | /** 279 | * Request for when the client wants a route for a vehicle or commodity 280 | */ 281 | case class Route( 282 | id: ModelId 283 | ) extends ModelMessage { 284 | override type M = Route 285 | override def companion = Route 286 | override def withId(id: ModelId) = copy(id = id) 287 | } 288 | 289 | object Route extends MessageCompanion[Route] { 290 | override val message = "Route" 291 | override val format = simpleModelMessageFormat(Route.apply) 292 | } 293 | addComp(Route) 294 | 295 | private implicit val cFormat = io.pathfinder.models.Commodity.format 296 | /** 297 | * Response for a route request 298 | */ 299 | case class Routed( 300 | model: ModelType, 301 | value: JsValue, 302 | route: JsValue, 303 | unroutedCommodities: Option[Seq[Commodity]] 304 | ) extends ControllerMessage { 305 | override type M = Routed 306 | override def companion = Routed 307 | } 308 | 309 | object Routed extends MessageCompanion[Routed] { 310 | override val message = "Routed" 311 | override val format = Json.format[Routed] 312 | } 313 | addComp(Routed) 314 | 315 | /** 316 | * Sent by the client that wants to read a model from the database 317 | */ 318 | case class Read( 319 | id: ModelId 320 | ) extends ModelMessage { 321 | override type M = Read 322 | override def companion = Read 323 | override protected def withId(id: ModelId): Read = copy(id = id) 324 | } 325 | 326 | object Read extends MessageCompanion[Read] { 327 | override val message = "Read" 328 | override val format = simpleModelMessageFormat(Read.apply) 329 | } 330 | addComp(Read) 331 | 332 | /** 333 | * Message sent to the client that requested a create 334 | */ 335 | case class Created( 336 | model: ModelType, 337 | value: JsValue 338 | ) extends ControllerMessage { 339 | override type M = Created 340 | override def companion = Created 341 | } 342 | 343 | object Created extends MessageCompanion[Created] { 344 | override val message = "Created" 345 | override val format = Json.format[Created] 346 | } 347 | addComp(Created) 348 | 349 | /** 350 | * Message sent to a client that requested an update 351 | * or any clients that have subscribed to updates 352 | */ 353 | case class Updated( 354 | model: ModelType, 355 | value: JsValue 356 | ) extends ControllerMessage { 357 | override type M = Updated 358 | override def companion = Updated 359 | } 360 | 361 | object Updated extends MessageCompanion[Updated] { 362 | override val message = "Updated" 363 | override val format = Json.format[Updated] 364 | } 365 | addComp(Updated) 366 | 367 | /** 368 | * Message sent to a client that requested a read 369 | */ 370 | case class Model( 371 | model: ModelType, 372 | value: JsValue 373 | ) extends ControllerMessage { 374 | override type M = Model 375 | override def companion = Model 376 | } 377 | 378 | object Model extends MessageCompanion[Model] { 379 | override val message = "Model" 380 | override val format = Json.format[Model] 381 | } 382 | addComp(Model) 383 | 384 | /** 385 | * Message sent to a client that requested a delete 386 | */ 387 | case class Deleted( 388 | model: ModelType, 389 | value: JsValue 390 | ) extends ControllerMessage { 391 | override type M = Deleted 392 | override def companion = Deleted 393 | } 394 | 395 | object Deleted extends MessageCompanion[Deleted] { 396 | override val message = "Deleted" 397 | override val format = Json.format[Deleted] 398 | } 399 | addComp(Deleted) 400 | 401 | /** 402 | * Message sent to a client that requested a subscribe 403 | */ 404 | case class Subscribed( 405 | clusterId: Option[String], 406 | model: Option[ModelType], 407 | id: Option[ModelId] 408 | ) extends SubscriptionMessage { 409 | override type M = Subscribed 410 | override def companion = Subscribed 411 | override def withClusterAndId(clusterId: Option[String], id: Option[ModelId]): Subscribed = 412 | copy(clusterId = clusterId, id = id) 413 | } 414 | 415 | object Subscribed extends MessageCompanion[Subscribed] { 416 | override val message = "Subscribed" 417 | override val format = subscriptionMessageFormat(Subscribed.apply) 418 | } 419 | addComp(Subscribed) 420 | 421 | /** 422 | * Message sent to a client that requested to unsubscribe 423 | */ 424 | case class Unsubscribed( 425 | clusterId: Option[String], 426 | model: Option[ModelType], 427 | id: Option[ModelId] 428 | ) extends SubscriptionMessage { 429 | override type M = Unsubscribed 430 | override def companion = Unsubscribed 431 | override def withClusterAndId(clusterId: Option[String], id: Option[ModelId]): Unsubscribed = 432 | copy(clusterId = clusterId, id = id) 433 | } 434 | 435 | object Unsubscribed extends MessageCompanion[Unsubscribed] { 436 | override val message = "Unsubscribed" 437 | override val format = subscriptionMessageFormat(Unsubscribed.apply) 438 | } 439 | addComp(Unsubscribed) 440 | 441 | case class Recalculate( 442 | clusterId: String 443 | ) extends WebSocketMessage { 444 | override type M = Recalculate 445 | override def companion: MessageCompanion[M] = Recalculate 446 | override def withApp(app: String): Option[Recalculate] = 447 | Cluster.addAppToPath(clusterId, app).map(id => copy(clusterId = id)) 448 | override def withoutApp: Recalculate = 449 | copy(clusterId = Cluster.removeAppFromPath(clusterId)) 450 | } 451 | 452 | object Recalculate extends MessageCompanion[Recalculate] { 453 | override def message: String = "Recalculate" 454 | override def format: Format[Recalculate] = Json.format[Recalculate] 455 | } 456 | addComp(Recalculate) 457 | 458 | case class Recalculated( 459 | clusterId: String 460 | ) extends WebSocketMessage { 461 | override type M = Recalculated 462 | override def companion = Recalculated 463 | override def withApp(app: String): Option[Recalculated] = 464 | Cluster.addAppToPath(clusterId, app).map(id => copy(clusterId = id)) 465 | override def withoutApp: Recalculated = 466 | copy(clusterId = Cluster.removeAppFromPath(clusterId)) 467 | } 468 | 469 | object Recalculated extends MessageCompanion[Recalculated] { 470 | override def message = "Recalculated" 471 | override def format: Format[Recalculated] = Json.format[Recalculated] 472 | } 473 | addComp(Recalculated) 474 | 475 | case class ConnectionId( 476 | id: String 477 | ) extends WebSocketMessage { 478 | override type M = ConnectionId 479 | override def companion = ConnectionId 480 | } 481 | 482 | object ConnectionId extends MessageCompanion[ConnectionId] { 483 | override def message = "ConnectionId" 484 | override def format: Format[ConnectionId] = Json.format[ConnectionId] 485 | } 486 | addComp(ConnectionId) 487 | 488 | case class Authenticate( 489 | dashboard: Option[Boolean] 490 | ) extends WebSocketMessage { 491 | override type M = Authenticate 492 | override def companion = Authenticate 493 | } 494 | 495 | object Authenticate extends MessageCompanion[Authenticate] { 496 | override def message = "Authenticate" 497 | override def format: Format[Authenticate] = Json.format[Authenticate] 498 | } 499 | addComp(Authenticate) 500 | 501 | case class Authenticated(status: AuthenticationStatus) extends WebSocketMessage { 502 | override type M = Authenticated 503 | override def companion = Authenticated 504 | } 505 | 506 | object Authenticated extends MessageCompanion[Authenticated] { 507 | override def message = "Authenticated" 508 | override def format: Format[Authenticated] = Json.format[Authenticated] 509 | } 510 | addComp(Authenticated) 511 | 512 | val stringToMessage: Map[String, _ <: MessageCompanion[_]] = builder.result() 513 | 514 | Logger.info("stringToMessage: [" + stringToMessage.keys.mkString("|")+"]") 515 | 516 | /** 517 | * reads and writes WebSocketMessages from/to Json 518 | */ 519 | implicit val format: Format[WebSocketMessage] = ( 520 | (__ \ "message").format[String] and 521 | __.format[JsValue] 522 | ) ( 523 | { case (msg,json) => stringToMessage.get(msg).map{ // reads are not covariant so a cast us required 524 | _.format.reads(json).recoverTotal( 525 | errs => Error("Could not parse json: " + json + "\n" + errs.errors.map(err => 526 | err._1+":"+err._2.mkString("\n\t") 527 | ).mkString("\n\n")) 528 | ).asInstanceOf[WebSocketMessage] 529 | }.getOrElse(UnknownMessage(json)) }, 530 | { msg => 531 | Logger.info("Sending Message: " + msg) 532 | (msg.message, msg.toJson) } 533 | ) 534 | 535 | /** 536 | * reads and writes WebSocketMessages for the WebSocketActor, uses the format above 537 | */ 538 | implicit val frameFormat: FrameFormatter[WebSocketMessage] = FrameFormatter.jsonFrame[WebSocketMessage] 539 | } 540 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/controllers/ClusterSocketController.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.controllers 2 | 3 | import io.pathfinder.models.{Cluster, Commodity} 4 | import io.pathfinder.websockets.ModelTypes 5 | 6 | object ClusterSocketController extends WebSocketCrudController[String, Cluster](ModelTypes.Cluster,Cluster.Dao) -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/controllers/CommoditySocketController.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.controllers 2 | 3 | import io.pathfinder.models.Commodity 4 | import io.pathfinder.websockets.ModelTypes 5 | 6 | object CommoditySocketController extends WebSocketCrudController[Long, Commodity](ModelTypes.Commodity,Commodity.Dao) 7 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/controllers/VehicleSocketController.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.controllers 2 | 3 | import io.pathfinder.models.{Commodity, Cluster, Transport} 4 | import io.pathfinder.routing.Action.{Start, DropOff, PickUp} 5 | import io.pathfinder.routing.{Action, Route} 6 | import io.pathfinder.websockets.{WebSocketMessage, ModelTypes} 7 | import io.pathfinder.websockets.WebSocketMessage.{Route => RouteMsg, Error, Subscribe, Routed} 8 | import scala.collection.JavaConversions.asScalaBuffer 9 | /** 10 | * manages vehicle API calls 11 | */ 12 | object VehicleSocketController extends WebSocketCrudController[Long, Transport](ModelTypes.Transport,Transport.Dao) 13 | 14 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/controllers/WebSocketController.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.controllers 2 | 3 | import io.pathfinder.websockets.WebSocketMessage 4 | 5 | /** 6 | * The websocket anologue to the MVC controllers 7 | */ 8 | trait WebSocketController{ 9 | def receive(webSocketMessage: WebSocketMessage, appId: String): Option[WebSocketMessage] 10 | } 11 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/controllers/WebSocketCrudController.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.controllers 2 | 3 | import io.pathfinder.data.{CrudDao, Resource} 4 | import io.pathfinder.models.ModelId 5 | import io.pathfinder.models.ModelId.ClusterPath 6 | import io.pathfinder.websockets.ModelTypes.ModelType 7 | import io.pathfinder.websockets.WebSocketMessage 8 | import io.pathfinder.websockets.WebSocketMessage._ 9 | import play.api.libs.json.{Reads, Writes} 10 | import play.api.Logger 11 | import com.avaje.ebean 12 | 13 | /** 14 | * Adds basic crud support to any implementing controller 15 | */ 16 | abstract class WebSocketCrudController[K,V <: ebean.Model]( 17 | model: ModelType, 18 | dao: CrudDao[K,V] 19 | )(implicit 20 | val reads: Reads[V], 21 | val writes: Writes[V], 22 | val resources: Reads[_ <: Resource[V]] 23 | ) extends WebSocketController { 24 | 25 | override def receive(webSocketMessage: WebSocketMessage, appId: String): Option[WebSocketMessage] = { 26 | Logger.info(s"Received web socket crud request $webSocketMessage") 27 | webSocketMessage match { 28 | case Update(id, value) => Some( 29 | resources.reads(value).map { res => 30 | dao.update(id.id.asInstanceOf[K], res.withAppId(appId).get).map( 31 | m => Updated(model, writes.writes(m)).withoutApp 32 | ) getOrElse Error("Could not update " + model + " with id " + id) 33 | } getOrElse Error("Could not parse json in "+model+" Update Request: "+value) 34 | ) 35 | case Create(m, value) => Some( 36 | resources.reads(value).map{ res => 37 | dao.create(res.withAppId(appId).get).map( 38 | m => Created(model, writes.writes(m)).withoutApp 39 | ) getOrElse Error("Could not create "+model+" from Create Request: "+value) 40 | } getOrElse Error("Could not parse json in "+model+" Create Request") 41 | ) 42 | case Delete(id) => Some( 43 | dao.delete(id.id.asInstanceOf[K]).map( 44 | m => Deleted(model,writes.writes(m)).withoutApp 45 | ) getOrElse Error("Could not delete "+model+" with id "+id+", does this id exist?") 46 | ) 47 | case Read(id) => Some( 48 | dao.read(id.id.asInstanceOf[K]).map( 49 | m => Model(model,writes.writes(m)).withoutApp 50 | ) getOrElse Error("Could not read "+model+" with id "+id+", does this id exist?") 51 | ) 52 | case x: WebSocketMessage => Some(Error("No support for message: " + WebSocketMessage.format.writes(x.withoutApp) + " for model: "+model.toString)) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/pushing/EventBusActor.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.pushing 2 | 3 | import akka.actor.{Actor, ActorRef} 4 | import akka.event.ActorEventBus 5 | import io.pathfinder.websockets.pushing.EventBusActor.EventBusMessage.{UnsubscribeAll, Unsubscribe, Publish, Subscribe} 6 | import play.Logger 7 | 8 | object EventBusActor { 9 | abstract sealed class EventBusMessage 10 | 11 | object EventBusMessage { 12 | case class Subscribe[C](subscriber: ActorRef, to: C) extends EventBusMessage 13 | case class Unsubscribe[C](subscriber: ActorRef, from: C) extends EventBusMessage 14 | case class UnsubscribeAll(subscriber: ActorRef) extends EventBusMessage 15 | case class Publish[E](event: E) extends EventBusMessage 16 | } 17 | } 18 | 19 | abstract class EventBusActor extends Actor with ActorEventBus { 20 | 21 | override def receive: Receive = { 22 | case Subscribe(sub, to) => 23 | Logger.info(sub.toString() + " subcribed to "+this.toString) 24 | subscribe(sub, to.asInstanceOf[Classifier]) 25 | case Unsubscribe(sub, from) => unsubscribe(sub, from.asInstanceOf[Classifier]) 26 | case UnsubscribeAll(sub) => unsubscribe(sub) 27 | case Publish(event) => publish(event.asInstanceOf[Event]) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/pushing/PushSubscriber.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.pushing 2 | 3 | import akka.actor.ActorRef 4 | 5 | trait PushSubscriber { 6 | def subscribeByClusterPath(path: String, client: ActorRef): Unit 7 | 8 | def subscribeById(id: Long, client: ActorRef): Unit 9 | 10 | def unsubscribeById(id: Long, client: ActorRef): Unit 11 | 12 | def unsubscribeByClusterPath(path: String, client: ActorRef): Unit 13 | 14 | def unsubscribe(client: ActorRef): Unit 15 | } 16 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/pushing/SocketMessagePusher.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.pushing 2 | 3 | import akka.actor.{Props, ActorRef} 4 | import akka.event.{LookupClassification, ActorEventBus} 5 | import play.Logger 6 | 7 | object SocketMessagePusher { 8 | def props[K]: Props = Props(classOf[SocketMessagePusher[K]]) 9 | } 10 | 11 | class SocketMessagePusher[K] extends EventBusActor with ActorEventBus with LookupClassification { 12 | 13 | override type Event = (K, Any) // id and message 14 | 15 | override type Classifier = K // cluster id 16 | 17 | override protected def classify(event: Event): Classifier = event._1 18 | 19 | override protected def publish(event: Event, subscriber: ActorRef): Unit = { 20 | Logger.info("pushing: "+event._2+ " to websocket: "+subscriber) 21 | subscriber ! event._2 22 | } 23 | 24 | override def publish(event: Event): Unit = { 25 | Logger.info("notification pushed: "+event) 26 | super.publish(event) 27 | } 28 | 29 | override protected def mapSize(): Int = 16 30 | 31 | override def subscribe(ref: ActorRef, classifier: Classifier): Boolean = { 32 | Logger.info("websocket "+ref+" subscribed to: "+classifier+" at "+self) 33 | super.subscribe(ref, classifier) 34 | } 35 | 36 | override def unsubscribe(ref: ActorRef, classifier: Classifier): Boolean = { 37 | Logger.info("Websocket "+ref+" unsubscribed from: "+classifier+" for "+self) 38 | super.unsubscribe(ref, classifier) 39 | } 40 | 41 | override def unsubscribe(ref: ActorRef): Unit = { 42 | Logger.info("Websocket "+ref+" unsubscribed from: "+self) 43 | super.unsubscribe(ref) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/io/pathfinder/websockets/pushing/WebSocketDao.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets.pushing 2 | 3 | import akka.actor.ActorRef 4 | import com.avaje.ebean.Model.Find 5 | import io.pathfinder.config.Global 6 | import io.pathfinder.data.{CrudDao, EbeanCrudDao, ObserverDao} 7 | import io.pathfinder.models.{HasId, HasCluster} 8 | import io.pathfinder.routing.Router 9 | import io.pathfinder.websockets.WebSocketMessage.{Updated, Deleted, Created} 10 | import io.pathfinder.websockets.pushing.EventBusActor.EventBusMessage.{UnsubscribeAll, Unsubscribe, Subscribe, Publish} 11 | import io.pathfinder.websockets.{Events, ModelTypes} 12 | import play.Logger 13 | import play.api.libs.json.Writes 14 | 15 | /** 16 | * this class listens for changed to models so that it can push the changes to registered websocket clients 17 | */ 18 | 19 | abstract class WebSocketDao[V <: HasCluster with HasId](dao: CrudDao[Long,V]) extends ObserverDao(dao) with PushSubscriber { 20 | 21 | def this(find: Find[Long, V]) = this(new EbeanCrudDao[Long, V](find)) 22 | 23 | def modelType: ModelTypes.Value 24 | 25 | def writer: Writes[V] 26 | 27 | val byIdPusher: ActorRef = Global.actorSystem.actorOf(SocketMessagePusher.props[Long]) 28 | 29 | val byClusterPusher: ActorRef = Global.actorSystem.actorOf(SocketMessagePusher.props[String]) 30 | 31 | protected def onCreated(model: V): Unit = { 32 | Logger.info("Adding model to create channel: " + model) 33 | val msg = Created(modelType, writer.writes(model)).withoutApp 34 | val id = model.id 35 | val clusterId = model.cluster.id 36 | byIdPusher ! Publish((id, msg)) 37 | byClusterPusher ! Publish((clusterId, msg)) 38 | Router.publish(Events.Created, model) 39 | } 40 | 41 | protected def onDeleted(model: V): Unit = { 42 | Logger.info("Adding model to create channel: " + model) 43 | val msg = Deleted(modelType, writer.writes(model)).withoutApp 44 | val id = model.id 45 | val clusterId = model.cluster.id 46 | byIdPusher ! Publish((id, msg)) 47 | byClusterPusher ! Publish((clusterId, msg)) 48 | Router.publish(Events.Deleted, model) 49 | } 50 | 51 | protected def onUpdated(model: V): Unit = { 52 | Logger.info("Adding model to create channel: "+model) 53 | val msg = Updated(modelType, writer.writes(model)).withoutApp 54 | val id = model.id 55 | val clusterId = model.cluster.id 56 | byIdPusher ! Publish((id, msg)) 57 | byClusterPusher ! Publish((clusterId, msg)) 58 | Router.publish(Events.Updated, model) 59 | } 60 | 61 | override def subscribeByClusterPath(clusterId: String, client: ActorRef): Unit = { 62 | byClusterPusher ! Subscribe(client, clusterId) 63 | } 64 | 65 | def subscribeById(id: Long, client: ActorRef): Unit = { 66 | byIdPusher ! Subscribe(client, id) 67 | } 68 | 69 | def unsubscribeById(id: Long, client: ActorRef): Unit = { 70 | byIdPusher ! Unsubscribe(client, id) 71 | } 72 | 73 | override def unsubscribeByClusterPath(clusterId: String, client: ActorRef): Unit = { 74 | byClusterPusher ! Unsubscribe(client, clusterId) 75 | } 76 | 77 | def unsubscribe(client: ActorRef): Unit = { 78 | byIdPusher ! UnsubscribeAll(client) 79 | byClusterPusher ! UnsubscribeAll(client) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := """pathfinder-server""" 2 | 3 | version := "1.0-SNAPSHOT" 4 | 5 | lazy val root = (project in file(".")).enablePlugins(PlayScala, PlayEbean, PlayJava) 6 | 7 | scalaVersion := "2.11.7" 8 | 9 | libraryDependencies ++= Seq( 10 | jdbc, 11 | javaJdbc, 12 | cache, 13 | ws, 14 | filters, 15 | specs2 % Test, 16 | "org.avaje.ebeanorm" % "avaje-ebeanorm" % "6.12.3", 17 | "postgresql" % "postgresql" % "9.1-901-1.jdbc4", 18 | "org.scalatest" %% "scalatest" % "2.2.1" % "test", 19 | "org.scalatestplus" %% "play" % "1.4.0-M3" % "test", 20 | "org.postgresql" % "postgresql" % "9.4-1203-jdbc42", 21 | "com.jolbox" % "bonecp" % "0.8.0.RELEASE", 22 | "com.typesafe.akka" %% "akka-testkit" % "2.4.1", 23 | "com.typesafe.akka" %% "akka-slf4j" % "2.4.1", 24 | "com.nimbusds" % "nimbus-jose-jwt" % "4.12" 25 | ) 26 | 27 | testOptions += Tests.Argument(TestFrameworks.JUnit, "-v", "-q", "-a") 28 | 29 | resolvers += "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases" 30 | 31 | // Play provides two styles of routers, one expects its actions to be injected, the 32 | // other, legacy style, accesses its actions statically. 33 | routesGenerator := InjectedRoutesGenerator 34 | 35 | // Docker configuration 36 | packageName in Docker := "pathfinder-server" 37 | version in Docker := "0.4.6" 38 | maintainer in Docker := "Pathfinder Team" 39 | dockerRepository := Some("beta.gcr.io/phonic-aquifer-105721") 40 | dockerExposedPorts := Seq(9000, 9443) 41 | 42 | scoverage.ScoverageSbtPlugin.ScoverageKeys.coverageExcludedPackages := ";controllers\\..*Reverse.*;router\\..*Routes.*" 43 | -------------------------------------------------------------------------------- /conf/application.conf: -------------------------------------------------------------------------------- 1 | # This is the main configuration file for the application. 2 | # ~~~~~ 3 | 4 | # Secret key 5 | # ~~~~~ 6 | # The secret key is used to secure cryptographics functions. 7 | # 8 | # This must be changed for production, but we recommend not changing it in this file. 9 | # 10 | # See http://www.playframework.com/documentation/latest/ApplicationSecret for more details. 11 | play.crypto.secret = "dont changeme" 12 | 13 | # The application languages 14 | # ~~~~~ 15 | play.i18n.langs = [ "en" ] 16 | 17 | ebean.default=[ "io.pathfinder.models.*" ] 18 | 19 | ## GCP DB settings 20 | db.default.driver="org.postgresql.Driver" 21 | db.default.url="jdbc:postgresql://db.thepathfinder.xyz:5432/pathfinderdb?ssl=true&sslfactory=org.postgresql.ssl.NonValidatingFactory" 22 | #db.default.url="jdbc:postgresql://localhost:5432/pathfinderdb" 23 | db.default.username="pathfinderwebserver" 24 | # db.default.password= 25 | play.db.pool="bonecp" 26 | db.default.bonecp.logStatements=true 27 | 28 | db.default.idleMaxAge=10 minutes 29 | db.default.idleConnectionTestPeriod=30 seconds 30 | db.default.connectionTimeout=20 second 31 | db.default.connectionTestStatement="SELECT 1" 32 | db.default.maxConnectionAge=30 minutes 33 | 34 | db.default.maxConnectionsPerPartition=1000 35 | db.default.minConnectionsPerPartition=10 36 | db.default.acquireIncrement=10 37 | 38 | play.evolutions.enabled=true 39 | play.evolutions.db.default.autoApply=true 40 | 41 | play.filters.cors { 42 | allowedOrigins = null 43 | } 44 | 45 | # The Global object provides hooks for application startup and shutdown. 46 | application.global=io.pathfinder.config.Global 47 | 48 | authServer.connectionUrl="https://localhost:3000/connection" 49 | authServer.certificateUrl="https://localhost:3000/certificate" 50 | 51 | routing.server="http://routing.thepathfinder.xyz" 52 | # add google key here as a string 53 | # google.key= 54 | Authenticate=true 55 | AuthServer.connection="https://auth.thepathfinder.xyz/connection" 56 | AuthServer.certificate="https://auth.thepathfinder.xyz/certificate" 57 | AuthServer.audience="https://api.thepathfinder.xyz" 58 | AuthServer.issuer="https://auth.thepathfinder.xyz" 59 | -------------------------------------------------------------------------------- /conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %coloredLevel - %logger - %message%n%xException 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /conf/routes: -------------------------------------------------------------------------------- 1 | GET /socket io.pathfinder.controllers.Application.socket 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | #Activator-generated Properties 2 | #Tue Sep 15 13:47:56 EDT 2015 3 | template.uuid=7da33b5a-3aef-4d2e-b318-dae93632b999 4 | sbt.version=0.13.8 5 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // The Play plugin 2 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.4.3") 3 | 4 | addSbtPlugin("com.typesafe.sbt" %% "sbt-play-ebean" % "2.0.0") 5 | resolvers += Classpaths.sbtPluginReleases 6 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.0.4") 7 | addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.0.0") 8 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.4") 9 | -------------------------------------------------------------------------------- /test/io/pathfinder/BaseAppTest.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder; 2 | 3 | import io.pathfinder.models.Cluster; 4 | import io.pathfinder.models.Application; 5 | 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | 9 | import org.junit.After; 10 | import org.junit.Before; 11 | import play.test.FakeApplication; 12 | import play.test.Helpers; 13 | 14 | /** 15 | * Provides the boilerplate for setting up a FakeApplication. All vehicles and commodities will 16 | * need to be added to a cluster. One is created an inserted here for convenience. 17 | */ 18 | public class BaseAppTest { 19 | public FakeApplication app; 20 | public final Cluster cluster = new Cluster(); 21 | public final Application PATHFINDER_APPLICATION = new Application(); 22 | public static final String APPLICATION_ID = "001e7047-ee14-40d6-898a-5acf3a1cfd8a"; 23 | public static final String CLUSTER_ID = APPLICATION_ID; 24 | public static final String ROOT = "/root"; 25 | 26 | public Cluster baseCluster() { 27 | return Cluster.finder().byId(CLUSTER_ID); 28 | } 29 | 30 | @Before 31 | public void startApp() { 32 | Map conf = new HashMap<>(Helpers.inMemoryDatabase()); 33 | conf.put("Authenticate", "false"); 34 | app = Helpers.fakeApplication(conf); 35 | Helpers.start(app); 36 | PATHFINDER_APPLICATION.id_$eq(APPLICATION_ID); 37 | PATHFINDER_APPLICATION.name_$eq("MY COOL APP"); 38 | cluster.id_$eq(CLUSTER_ID); 39 | cluster.insert(); 40 | cluster.id_$eq(CLUSTER_ID); 41 | PATHFINDER_APPLICATION.insert(); 42 | cluster.save(); 43 | } 44 | 45 | @After 46 | public void stopApp() { 47 | Helpers.stop(app); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/io/pathfinder/routing/RouteTest.scala: -------------------------------------------------------------------------------- 1 | package io.pathfinder.routing 2 | 3 | import io.pathfinder.models.{Cluster, Application, CommodityStatus, TransportStatus, Transport, Commodity} 4 | import io.pathfinder.routing.Action.{DropOff, PickUp, Start} 5 | import org.scalatestplus.play.PlaySpec 6 | import org.specs2.execute 7 | import org.specs2.execute.AsResult 8 | import play.api.libs.json.{JsNumber, JsObject, Json} 9 | import play.api.test.{FakeApplication, Helpers, WithApplication} 10 | 11 | class RouteTest extends PlaySpec { 12 | 13 | val APP_ID = "cluster" 14 | val ROOT = "/root" 15 | 16 | class FakeApp extends WithApplication(FakeApplication(additionalConfiguration = Helpers.inMemoryDatabase())) { 17 | 18 | override def around[T : AsResult](t: => T) = { 19 | Helpers.running(app){ 20 | val pathfinderApp = new Application() 21 | pathfinderApp.id = APP_ID 22 | pathfinderApp.name = "my cluster" 23 | pathfinderApp.insert() 24 | val cluster = new Cluster() 25 | cluster.id = APP_ID 26 | cluster.insert() 27 | AsResult.effectively(t) 28 | } 29 | } 30 | } 31 | 32 | val routeJson = Json.parse(s"""{ 33 | "transport":{"id":9,"latitude":2,"longitude":3,"status":"Online","metadata":{"capacity":1},"commodities":[],"clusterId":"$ROOT"}, 34 | "actions":[ 35 | { "action":"Start","latitude":12,"longitude":18}, 36 | { "action":"PickUp", 37 | "latitude":2, 38 | "longitude":3, 39 | "commodity":{"id":1,"startLatitude":2,"startLongitude":3,"endLatitude":4,"endLongitude":5,"status":"Waiting","metadata":{"param":6},"clusterId":"$ROOT"}}, 40 | { "action":"DropOff", 41 | "latitude":4, 42 | "longitude":5, 43 | "commodity":{"id":1,"startLatitude":2,"startLongitude":3,"endLatitude":4,"endLongitude":5,"status":"Waiting","metadata":{"param":6},"clusterId":"$ROOT"}}, 44 | { "action":"PickUp", 45 | "latitude":8, 46 | "longitude":9, 47 | "commodity":{"id":7,"startLatitude":8,"startLongitude":9,"endLatitude":10,"endLongitude":11,"status":"Waiting","metadata":{"param":12},"clusterId":"$ROOT"}}, 48 | { "action":"DropOff", 49 | "latitude":10, 50 | "longitude":11, 51 | "commodity":{"id":7,"startLatitude":8,"startLongitude":9,"endLatitude":10,"endLongitude":11,"status":"Waiting","metadata":{"param":12},"clusterId":"$ROOT"}}, 52 | { "action":"PickUp", 53 | "latitude":14, 54 | "longitude":15, 55 | "commodity":{"id":13,"startLatitude":14,"startLongitude":15,"endLatitude":16,"endLongitude":17,"status":"Waiting","metadata":{"param":18},"clusterId":"$ROOT"}}, 56 | { "action":"DropOff", 57 | "latitude":16, 58 | "longitude":17, 59 | "commodity":{"id":13,"startLatitude":14,"startLongitude":15,"endLatitude":16,"endLongitude":17,"status":"Waiting","metadata":{"param":18},"clusterId":"$ROOT"}} 60 | ] 61 | }""") 62 | 63 | "Route.writes" should { 64 | "write a route object into json" in new FakeApp { 65 | val coms = Seq( 66 | Commodity(1,2,3,4,5,CommodityStatus.Waiting,JsObject(Seq("param" -> JsNumber(6))), None, APP_ID), 67 | Commodity(7,8,9,10,11,CommodityStatus.Waiting,JsObject(Seq("param" -> JsNumber(12))), None, APP_ID), 68 | Commodity(13,14,15,16,17,CommodityStatus.Waiting,JsObject(Seq("param" -> JsNumber(18))), None, APP_ID) 69 | ) 70 | val actions = coms.foldLeft(Seq.newBuilder[Action]+=Start(12,18)) { 71 | (b,c) => b += new PickUp(c) += new DropOff(c) 72 | }.result() 73 | val transport = Transport(9, 2, 3, TransportStatus.Online,JsObject(Seq("capacity" -> JsNumber(1))), None, APP_ID) 74 | val route = Route(transport,actions) 75 | Route.writes.writes(route) mustBe routeJson 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/io/pathfinder/websockets/WebSocketActorTest.java: -------------------------------------------------------------------------------- 1 | package io.pathfinder.websockets; 2 | 3 | import akka.actor.ActorSystem; 4 | import akka.actor.Props; 5 | import akka.pattern.Patterns; 6 | import akka.testkit.TestActorRef; 7 | import akka.testkit.TestProbe; 8 | import io.pathfinder.BaseAppTest; 9 | import io.pathfinder.models.Cluster; 10 | import io.pathfinder.models.Commodity; 11 | import io.pathfinder.models.Transport; 12 | import io.pathfinder.models.TransportStatus; 13 | import org.junit.Before; 14 | import org.junit.Test; 15 | import org.junit.runner.RunWith; 16 | import org.junit.runners.JUnit4; 17 | import play.api.libs.json.JsNumber; 18 | import play.api.libs.json.JsObject; 19 | import play.api.libs.json.JsValue; 20 | import play.api.libs.json.Json; 21 | import scala.collection.mutable.HashMap; 22 | import scala.collection.mutable.Map; 23 | import scala.math.BigDecimal; 24 | 25 | /** 26 | * This test was based off of the documentation at 27 | * http://doc.akka.io/docs/akka/snapshot/java/testing.html 28 | */ 29 | @RunWith(JUnit4.class) 30 | public class WebSocketActorTest extends BaseAppTest { 31 | private TestActorRef socket; 32 | private TestProbe client; 33 | 34 | private static final JsValue JSON_CREATE_CLUSTER = 35 | Json.parse("{\"message\":\"Create\"," + 36 | "\"model\":\"Cluster\"," + 37 | "\"value\":{" + 38 | "\"id\":\"" + ROOT + "/subcluster\"" + 39 | "}}"); 40 | private static final JsValue JSON_CREATE_TRANSPORT = 41 | Json.parse("{\"message\":\"Create\"," + 42 | "\"model\":\"Transport\"," + 43 | "\"value\":{" + 44 | "\"latitude\":0.1," + 45 | "\"longitude\":-12.3," + 46 | "\"clusterId\":\"" + ROOT + "\"," + 47 | "\"metadata\":{\"capacity\":99}," + 48 | "\"status\":\"Online\"" + 49 | "}}"); 50 | private static final JsValue JSON_CREATE_COMMODITY = 51 | Json.parse("{\"message\":\"Create\"," + 52 | "\"model\":\"Commodity\"," + 53 | "\"value\":{" + 54 | "\"startLatitude\":0.1," + 55 | "\"startLongitude\":-12.3," + 56 | "\"endLatitude\":99.4," + 57 | "\"endLongitude\":-3.5," + 58 | "\"clusterId\":\"" + ROOT + "\"," + 59 | "\"metadata\":{\"param\":5}" + 60 | "}}"); 61 | 62 | private static final int TIMEOUT = 3000; 63 | 64 | @Before 65 | public void initActor() { 66 | ActorSystem sys = ActorSystem.create(); 67 | client = new TestProbe(sys); 68 | final Props props = WebSocketActor.props(client.ref(), APPLICATION_ID); 69 | socket = TestActorRef.create(sys, props); 70 | } 71 | 72 | @Test 73 | public void testCreateCluster() throws Exception { 74 | final String PATH = ROOT + "/subcluster"; 75 | Patterns.ask(socket, WebSocketMessage.format().reads(JSON_CREATE_CLUSTER).get(), TIMEOUT); 76 | Cluster createdCluster = new Cluster(); 77 | createdCluster.id_$eq(PATH); 78 | client.expectMsg(new WebSocketMessage.Created( 79 | ModelTypes.Cluster(), Cluster.format().writes(createdCluster))); 80 | } 81 | 82 | @Test 83 | public void testCreateTransport() { 84 | final int NEXT_UNUSED_ID = 1; 85 | Patterns.ask(socket, WebSocketMessage.format().reads(JSON_CREATE_TRANSPORT).get(), TIMEOUT); 86 | Transport createdTransport = new Transport(); 87 | createdTransport.id_$eq(NEXT_UNUSED_ID); 88 | createdTransport.latitude_$eq(0.1); 89 | createdTransport.longitude_$eq(-12.3); 90 | Map meta = new HashMap<>(); 91 | meta.put("capacity", new JsNumber(BigDecimal.valueOf(99))); 92 | createdTransport.metadata_$eq(new JsObject(meta)); 93 | createdTransport.status_$eq(TransportStatus.Online); 94 | createdTransport.cluster_$eq(baseCluster()); 95 | client.expectMsg(new WebSocketMessage.Created( 96 | ModelTypes.Transport(), Transport.format().writes(createdTransport))); 97 | } 98 | 99 | @Test 100 | public void testCreateCommodity() { 101 | final int NEXT_UNUSED_ID = 1; 102 | Patterns.ask(socket, WebSocketMessage.format().reads(JSON_CREATE_COMMODITY).get(), TIMEOUT); 103 | Commodity createdCommodity = new Commodity(); 104 | createdCommodity.id_$eq(NEXT_UNUSED_ID); 105 | createdCommodity.startLatitude_$eq(0.1); 106 | createdCommodity.startLongitude_$eq(-12.3); 107 | createdCommodity.endLatitude_$eq(99.4); 108 | createdCommodity.endLongitude_$eq(-3.5); 109 | createdCommodity.cluster_$eq(baseCluster()); 110 | Map meta = new HashMap<>(); 111 | meta.put("param", new JsNumber(BigDecimal.valueOf(5))); 112 | createdCommodity.metadata_$eq(new JsObject(meta)); 113 | client.expectMsg(new WebSocketMessage.Created( 114 | ModelTypes.Commodity(), Commodity.format().writes(createdCommodity))); 115 | } 116 | } 117 | --------------------------------------------------------------------------------