├── .github ├── FUNDING.yml └── workflows │ └── build-and-test.yaml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── project └── Build.scala └── src └── main ├── resources └── application.conf └── scala └── app ├── Application.scala ├── Configs.scala ├── actors ├── PostgresActor.scala └── Starter.scala ├── data └── TaskJsonProtocol.scala ├── models └── TaskDAO.scala ├── server ├── HTTPHelpers.scala ├── ServerSupervisor.scala ├── TaskService.scala └── WebService.scala └── utils └── PostgresSupport.scala /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [lucperkins] 2 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | on: [push, pull_request] 3 | jobs: 4 | build_and_test: 5 | strategy: 6 | matrix: 7 | go-version: [1.14.x] 8 | platform: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v1 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v1 17 | - name: Build 18 | run: go build -v ./... 19 | - name: Test 20 | run: go test -v -p 1 ./... 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | project/project 3 | project/target 4 | .idea -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - "2.10.3" 4 | script: 5 | - sbt ++$TRAVIS_SCALA_VERSION compile 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spray CRUD Example 2 | ================== 3 | 4 | This is a basic REST web service that allows for CRUD operations on a `Task` data type. Yet another todo list app for the universe to admire! 5 | 6 | Application stack: 7 | * [Akka](http://akka.io/) 8 | * [Spray HTTP](https://github.com/spray/spray/tree/master/spray-http) 9 | * [Spray Routing](http://spray.io/documentation/1.1-SNAPSHOT/spray-routing/) 10 | * [Spray JSON](https://github.com/spray/spray-json) 11 | * [SLICK](http://slick.typesafe.com/) 12 | * [PostgreSQL](http://www.postgresql.org/) -------------------------------------------------------------------------------- /project/Build.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | 4 | object BuildSettings { 5 | val buildOrganization = "spray-akka-slick-postgres" 6 | val buildVersion = "0.1.0" 7 | val buildScalaVersion = "2.10.3" 8 | 9 | val buildSettings = Defaults.defaultSettings ++ Seq ( 10 | organization := buildOrganization, 11 | version := buildVersion, 12 | scalaVersion := buildScalaVersion 13 | ) 14 | 15 | scalacOptions ++= Seq( 16 | "-unchecked", 17 | "-deprecation", 18 | "-feature", 19 | "-encoding", "utf8" 20 | ) 21 | } 22 | 23 | object Resolvers { 24 | val sprayRepo = "spray" at "http://repo.spray.io/" 25 | val sprayNightlies = "Spray Nightlies" at "http://nightlies.spray.io/" 26 | val sonatypeRel = "Sonatype OSS Releases" at "http://oss.sonatype.org/content/repositories/releases/" 27 | val sonatypeSnap = "Sonatype OSS Snapshots" at "http://oss.sonatype.org/content/repositories/snapshots/" 28 | 29 | val sprayResolvers = Seq(sprayRepo, sprayNightlies) 30 | val sonatypeResolvers = Seq(sonatypeRel, sonatypeSnap) 31 | val allResolvers = sprayResolvers ++ sonatypeResolvers 32 | } 33 | 34 | object Dependencies { 35 | val akkaVersion = "2.2.0" 36 | val sprayVersion = "1.2-20130710" 37 | 38 | val akkaActor = "com.typesafe.akka" %% "akka-actor" % akkaVersion 39 | val akkaSlf4j = "com.typesafe.akka" %% "akka-slf4j" % akkaVersion 40 | val sprayCan = "io.spray" % "spray-can" % sprayVersion 41 | val sprayHttpx = "io.spray" % "spray-httpx" % sprayVersion 42 | val sprayRouting = "io.spray" % "spray-routing" % sprayVersion 43 | val sprayJson = "io.spray" %% "spray-json" % "1.2.5" 44 | val jodaDateTime = "joda-time" % "joda-time" % "2.1" 45 | val jodaConvert = "org.joda" % "joda-convert" % "1.8.1" 46 | val slick = "com.typesafe.slick" %% "slick" % "3.0.0" 47 | val postgres = "postgresql" % "postgresql" % "9.1-901-1.jdbc4" 48 | val slickJoda = "com.github.tototoshi" %% "slick-joda-mapper" % "2.0.0" 49 | val scalaCsv = "com.github.tototoshi" %% "scala-csv" % "1.3.4" 50 | val logback = "ch.qos.logback" % "logback-classic" % "1.0.0" 51 | } 52 | 53 | object AppBuild extends Build { 54 | import Resolvers._ 55 | import Dependencies._ 56 | import BuildSettings._ 57 | 58 | val akkaDeps = Seq(akkaActor, akkaSlf4j) 59 | 60 | val sprayDeps = Seq( 61 | sprayCan, 62 | sprayHttpx, 63 | sprayRouting, 64 | sprayJson 65 | ) 66 | 67 | val otherDeps = Seq( 68 | slick, 69 | postgres, 70 | slickJoda, 71 | scalaCsv, 72 | jodaDateTime, 73 | jodaConvert, 74 | logback 75 | ) 76 | 77 | val allDeps = akkaDeps ++ sprayDeps ++ otherDeps 78 | 79 | lazy val mainProject = Project( 80 | "spray-akka-slick-postgres", 81 | file("."), 82 | settings = buildSettings ++ Seq(resolvers ++= allResolvers, 83 | libraryDependencies ++= allDeps) 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | event-handlers = ["akka.event.slf4j.Slf4jEventHandler"] 3 | log-level = DEBUG 4 | } 5 | 6 | app { 7 | port = 3000 8 | interface = "localhost" 9 | } 10 | 11 | postgres { 12 | host = "localhost" 13 | port = 5432 14 | dbname = "mezmer" 15 | driver = "org.postgresql.Driver" 16 | } -------------------------------------------------------------------------------- /src/main/scala/app/Application.scala: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import akka.actor._ 4 | import app.{Configs => C} 5 | import app.actors.Starter 6 | import app.models.TaskDAO 7 | import app.utils.PostgresSupport 8 | import scala.concurrent.duration._ 9 | 10 | import scala.concurrent.Await 11 | 12 | object Application extends App 13 | with PostgresSupport 14 | { 15 | val system = ActorSystem("main-system") 16 | C.log.info("Actor system $system is up and running") 17 | C.log.info("Postgres is up and running") 18 | private implicit val context = system.dispatcher 19 | private val taskDAO = new TaskDAO() 20 | Await.result(startPostgres(taskDAO), 1.second) 21 | val starter = system.actorOf(Starter.apply(taskDAO), name = "main") 22 | 23 | starter ! Starter.Start 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/app/Configs.scala: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import com.typesafe.config.ConfigFactory 4 | import org.slf4j.{ Logger, LoggerFactory } 5 | 6 | object Configs { 7 | val c = ConfigFactory.load() 8 | 9 | val interface = c.getString("app.interface") 10 | val appPort = c.getInt("app.port") 11 | val pgHost = c.getString("postgres.host") 12 | val pgPort = c.getInt("postgres.port") 13 | val pgDBName = c.getString("postgres.dbname") 14 | val pgDriver = c.getString("postgres.driver") 15 | 16 | val log: Logger = LoggerFactory.getLogger(this.getClass) 17 | } -------------------------------------------------------------------------------- /src/main/scala/app/actors/PostgresActor.scala: -------------------------------------------------------------------------------- 1 | package app.actors 2 | 3 | import akka.actor.{Actor, Props} 4 | import akka.pattern.pipe 5 | import app.models.TaskDAO 6 | 7 | object PostgresActor { 8 | case object FetchAll 9 | case class CreateTask(content: String, assignee: String) 10 | case class FetchTask(id: Int) 11 | case class ModifyTask(id: Int, content: String) 12 | case class DeleteTask(id: Int) 13 | case object DeleteAll 14 | case object GetCount 15 | case class Populate(file: String) 16 | case object GetIds 17 | case object CreateTable 18 | case object DropTable 19 | 20 | def apply(taskDAO: TaskDAO) = Props(new PostgresActor(taskDAO)) 21 | } 22 | 23 | final class PostgresActor(taskDAO: TaskDAO) extends Actor { 24 | import PostgresActor._ 25 | import context.dispatcher 26 | 27 | def receive: Receive = { 28 | case FetchAll => 29 | taskDAO.listAllTasks.pipeTo(sender) 30 | 31 | case CreateTask(content: String, assignee: String) => 32 | taskDAO.addTask(content, assignee).pipeTo(sender) 33 | 34 | case FetchTask(id: Int) => 35 | taskDAO.fetchTaskById(id).pipeTo(sender) 36 | 37 | case ModifyTask(id: Int, content: String) => 38 | taskDAO.updateTaskById(id, content).pipeTo(sender) 39 | 40 | case DeleteTask(id: Int) => 41 | taskDAO.deleteTaskById(id).pipeTo(sender) 42 | 43 | case DeleteAll => 44 | taskDAO.deleteAll.pipeTo(sender) 45 | 46 | case GetCount => 47 | taskDAO.numberOfTasks.pipeTo(sender) 48 | 49 | case Populate(file: String) => 50 | taskDAO.populateTable(file).pipeTo(sender) 51 | 52 | case GetIds => 53 | taskDAO.listAllIds.pipeTo(sender) 54 | 55 | case CreateTable => 56 | taskDAO.createTable.pipeTo(sender) 57 | 58 | case DropTable => 59 | taskDAO.dropTable.pipeTo(sender) 60 | } 61 | } -------------------------------------------------------------------------------- /src/main/scala/app/actors/Starter.scala: -------------------------------------------------------------------------------- 1 | package app.actors 2 | 3 | import akka.actor._ 4 | import akka.io.IO 5 | import akka.routing.RoundRobinRouter 6 | import app.models.TaskDAO 7 | import spray.can.Http 8 | import app.{Configs => C} 9 | import app.server.ServerSupervisor 10 | 11 | object Starter { 12 | case object Start 13 | case object Stop 14 | 15 | def apply(taskDAO: TaskDAO) = Props(new Starter(taskDAO)) 16 | } 17 | 18 | final class Starter(taskDAO: TaskDAO) extends Actor { 19 | import Starter.Start 20 | 21 | private implicit val system = context.system 22 | 23 | def receive: Receive = { 24 | case Start => 25 | val mainHandler: ActorRef = 26 | context.actorOf(ServerSupervisor.apply(taskDAO).withRouter(RoundRobinRouter(nrOfInstances = 10))) 27 | IO(Http) ! Http.Bind(mainHandler, interface = C.interface, port = C.appPort) 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/scala/app/data/TaskJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package app.data 2 | 3 | import spray.json._ 4 | import DefaultJsonProtocol._ 5 | import org.joda.time.DateTime 6 | import org.joda.time.format.ISODateTimeFormat 7 | 8 | import app.models.Task 9 | 10 | object TaskJsonProtocol extends DefaultJsonProtocol { 11 | def dateTimeParse(dt: String): DateTime = ISODateTimeFormat.dateTimeParser().parseDateTime(dt) 12 | 13 | implicit object TaskJsonFormat extends RootJsonFormat[Task] { 14 | def write(t: Task) = JsObject( 15 | "taskId" -> JsNumber(t.taskId.getOrElse(0)), 16 | "content" -> JsString(t.content.toString()), 17 | "created" -> JsString(t.created.toString()), 18 | "finished" -> JsBoolean(t.finished), 19 | "assignee" -> JsString(t.assignee.toString) 20 | ) 21 | 22 | def read(j: JsValue) = { 23 | j.asJsObject.getFields("taskId", "content", "created", "finished", "assignee") match { 24 | case Seq(JsNumber(taskId), JsString(content), JsString(created), JsBoolean(finished), JsString(assignee)) => 25 | Task(Some(taskId.toInt), content, dateTimeParse(created), finished, assignee) 26 | case _ => throw new DeserializationException("Improperly formed Task object") 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/scala/app/models/TaskDAO.scala: -------------------------------------------------------------------------------- 1 | package app.models 2 | 3 | import slick.driver.PostgresDriver.api._ 4 | import com.github.tototoshi.slick.PostgresJodaSupport._ 5 | import org.joda.time.DateTime 6 | import spray.json._ 7 | import DefaultJsonProtocol._ 8 | import com.github.tototoshi.csv._ 9 | import app.utils.PostgresSupport 10 | 11 | import scala.concurrent.{ExecutionContext, Future} 12 | 13 | case class Task( 14 | taskId: Option[Int] = None, 15 | content: String, 16 | created: DateTime, 17 | finished: Boolean, 18 | assignee: String 19 | ) 20 | 21 | class TaskDAO(implicit val executionContext: ExecutionContext) extends PostgresSupport { 22 | import CSVConverter._ 23 | import app.data.TaskJsonProtocol._ 24 | 25 | class TaskTable(tag: Tag) extends Table[Task](tag, "tasks") { 26 | def taskId = column[Int] ("taskId", O.AutoInc, O.PrimaryKey, O.SqlType("BIGINT")) 27 | def content = column[String] ("content", O.SqlType("VARCHAR(50)"), O.NotNull) 28 | def created = column[DateTime]("created", O.SqlType("TIMESTAMP"), O.NotNull) 29 | def finished = column[Boolean] ("finished", O.SqlType("BOOLEAN"), O.NotNull) 30 | def assignee = column[String] ("assignee", O.SqlType("VARCHAR(20)"), O.NotNull) 31 | 32 | def * = (taskId.?, content, created, finished, assignee) <> (Task.tupled, Task.unapply) 33 | } 34 | 35 | private val tasks = TableQuery[TaskTable] 36 | 37 | case class Count(numberOfTasks: Int) 38 | case class Ids(ids: List[Int]) 39 | case class Result(result: String) 40 | implicit val countJsonFormat = jsonFormat1(Count) 41 | implicit val idsJsonFormat = jsonFormat1(Ids) 42 | implicit val resultFormat = jsonFormat1(Result) 43 | 44 | def pgResult(result: String): String = Result(result).toJson.compactPrint 45 | 46 | def numberOfTasks: Future[String] = db.run(tasks.length.result).map(r => Count(r).toJson.compactPrint) 47 | 48 | def listAllIds: Future[String] = db.run(tasks.map(_.taskId).result).map(ids => Ids(ids.toList).toJson.compactPrint) 49 | 50 | def listAllTasks: Future[String] = db.run(tasks.result).map(_.toJson.compactPrint) 51 | 52 | def createTable: Future[Unit] = db.run(tasks.schema.create) 53 | 54 | def dropTable: Future[Unit] = db.run(tasks.schema.drop) 55 | 56 | def addTask(content: String, assignee: String): Future[Int] = 57 | db.run(tasks += Task(content = content, created = new DateTime(), finished = false, assignee = assignee)) 58 | 59 | def fetchTaskById(id: Int): Future[Option[Task]] = db.run(tasks.filter(_.taskId === id).result.headOption) 60 | 61 | def deleteTaskById(id: Int): Future[String] = db.run(tasks.filter(_.taskId === id).delete).collect { 62 | case 0 => pgResult("0 tasks deleted") 63 | case 1 => pgResult("1 task deleted") 64 | case n => pgResult(s"$n tasks deleted") 65 | } 66 | 67 | def updateTaskById(id: Int, newContent: String): Future[String] = 68 | db.run(tasks.filter(_.taskId === id).map(t => t.content).update(newContent)).collect { 69 | case 1 => pgResult(s"Task $id successfully modified") 70 | case _ => pgResult(s"Task $id was not found") 71 | } 72 | 73 | def addMultipleTasks(args: List[(String, String)]): Future[List[Int]] = 74 | Future.sequence(args.map(value => addTask(value._1, value._2))) 75 | 76 | def populateTable(filename: String): Future[List[Int]] = { 77 | val csvInfo = CSVConverter.convert(filename) 78 | addMultipleTasks(csvInfo) 79 | } 80 | 81 | def deleteAll: Future[String] = 82 | db.run(tasks.delete).collect { 83 | case 0 => pgResult("0 tasks deleted") 84 | case 1 => pgResult("1 task deleted") 85 | case n => pgResult(s"$n tasks deleted") 86 | } 87 | 88 | } 89 | 90 | object CSVConverter { 91 | import java.io.File 92 | import scala.collection.mutable.ListBuffer 93 | 94 | def convert(filename: String) = { 95 | val reader = CSVReader.open(new File(filename)) 96 | val rawList = reader.iterator.toList 97 | val tweets = new ListBuffer[(String, String)] 98 | rawList.foreach(line => tweets ++= List((line(0), line(1)))) 99 | tweets.toList 100 | } 101 | } -------------------------------------------------------------------------------- /src/main/scala/app/server/HTTPHelpers.scala: -------------------------------------------------------------------------------- 1 | package app.server 2 | 3 | import spray.http._ 4 | import HttpCharsets._ 5 | import MediaTypes._ 6 | 7 | object HTTPHelpers { 8 | def Ok(ct: MediaType = `text/plain`, s: String): HttpResponse = 9 | new HttpResponse(StatusCodes.OK, HttpEntity(ContentType(ct, `UTF-8`), s)) 10 | 11 | def OkJson(s: String): HttpResponse = 12 | new HttpResponse(StatusCodes.OK, HttpEntity(ContentType(`application/json`, `UTF-8`), s)) 13 | } -------------------------------------------------------------------------------- /src/main/scala/app/server/ServerSupervisor.scala: -------------------------------------------------------------------------------- 1 | package app.server 2 | 3 | import akka.actor._ 4 | import app.models.TaskDAO 5 | 6 | object ServerSupervisor { 7 | def apply(taskDAO: TaskDAO) = Props(new ServerSupervisor(taskDAO)) 8 | } 9 | 10 | final class ServerSupervisor(taskDAO: TaskDAO) extends Actor 11 | with TaskService { 12 | def actorRefFactory = context 13 | 14 | def receive = runRoute( 15 | pathPrefix("api" / "v1") { 16 | taskServiceRoutes(taskDAO) 17 | } 18 | ) 19 | } -------------------------------------------------------------------------------- /src/main/scala/app/server/TaskService.scala: -------------------------------------------------------------------------------- 1 | package app.server 2 | 3 | import akka.pattern.ask 4 | 5 | import scala.concurrent.ExecutionContext.Implicits.global 6 | import spray.json._ 7 | import app.actors.PostgresActor 8 | import app.models.TaskDAO 9 | import spray.routing.Route 10 | 11 | trait TaskService extends WebService { 12 | import PostgresActor._ 13 | 14 | def taskServiceRoutes(taskDAO: TaskDAO): Route = { 15 | val postgresWorker = actorRefFactory.actorOf(PostgresActor.apply(taskDAO), "postgres-worker") 16 | 17 | def postgresCall(message: Any) = 18 | (postgresWorker ? message).mapTo[String].map(result => result) 19 | 20 | pathPrefix("tasks") { 21 | path("") { 22 | get { ctx => 23 | ctx.complete(postgresCall(FetchAll)) 24 | } ~ 25 | post { 26 | formFields('content.as[String], 'assignee.as[String]) { (content, assignee) => 27 | complete(postgresCall(CreateTask(content, assignee))) 28 | } 29 | } 30 | } ~ 31 | path("count") { 32 | get { ctx => 33 | ctx.complete(postgresCall(GetCount)) 34 | } 35 | } ~ 36 | path("all") { 37 | delete { ctx => 38 | ctx.complete(postgresCall(DeleteAll)) 39 | } 40 | } ~ 41 | path("populate" / Segment) { filename => 42 | post { ctx => 43 | complete(postgresCall(Populate(filename))) 44 | } 45 | } ~ 46 | path("ids") { 47 | get { ctx => 48 | ctx.complete(postgresCall(GetIds)) 49 | } 50 | } ~ 51 | path("table") { 52 | get { ctx => 53 | ctx.complete(postgresCall(CreateTable)) 54 | } ~ 55 | delete { ctx => 56 | ctx.complete(postgresCall(DropTable)) 57 | } 58 | } 59 | } ~ 60 | path("task" / IntNumber) { taskId => 61 | get { ctx => 62 | ctx.complete(postgresCall(FetchTask(taskId))) 63 | } ~ 64 | put { 65 | formFields('content.as[String]) { (content) => 66 | complete(postgresCall(ModifyTask(taskId, content))) 67 | } 68 | } ~ 69 | delete { ctx => 70 | ctx.complete(postgresCall(DeleteTask(taskId))) 71 | } 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /src/main/scala/app/server/WebService.scala: -------------------------------------------------------------------------------- 1 | package app.server 2 | 3 | import akka.util.Timeout 4 | import scala.concurrent.duration._ 5 | import spray.routing.HttpService 6 | 7 | trait WebService extends HttpService { 8 | implicit def executionContext = actorRefFactory.dispatcher 9 | implicit val timeout = Timeout(120 seconds) 10 | } -------------------------------------------------------------------------------- /src/main/scala/app/utils/PostgresSupport.scala: -------------------------------------------------------------------------------- 1 | package app.utils 2 | 3 | import slick.driver.PostgresDriver.api._ 4 | import slick.jdbc.meta.MTable 5 | import app.models.TaskDAO 6 | import app.{Configs => C} 7 | 8 | import scala.concurrent.{ExecutionContext, Future} 9 | 10 | trait PostgresSupport { 11 | def db = Database.forURL( 12 | url = s"jdbc:postgresql://${C.pgHost}:${C.pgPort}/${C.pgDBName}", 13 | driver = C.pgDriver 14 | ) 15 | 16 | implicit val session: Session = db.createSession() 17 | 18 | def startPostgres(taskDAO: TaskDAO)(implicit executionContext: ExecutionContext): Future[Unit] = 19 | db.run(MTable.getTables("tasks")).map(v => if(v.isEmpty) taskDAO.createTable) 20 | 21 | } --------------------------------------------------------------------------------