├── .gitignore ├── README.md ├── src └── main │ ├── resources │ ├── application.conf │ └── logback.xml │ └── scala │ └── com │ └── sysgears │ └── example │ ├── domain │ ├── CustomerSearchParameters.scala │ ├── Customer.scala │ └── Failure.scala │ ├── boot │ └── Boot.scala │ ├── config │ └── Configuration.scala │ ├── dao │ └── CustomerDAO.scala │ └── rest │ └── RestServiceActor.scala ├── project └── plugins.sbt └── UNLICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea* 3 | 4 | *.iml 5 | *.class 6 | *.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | simple-scala-rest-example 2 | ========================= 3 | 4 | Example of REST Service with Scala using Spray, Slick and Akka. 5 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loglevel = DEBUG 3 | event-handlers = ["akka.event.slf4j.Slf4jEventHandler"] 4 | } 5 | 6 | service { 7 | host = "localhost" 8 | port = 8080 9 | } 10 | 11 | db { 12 | host = "localhost" 13 | port = 3306 14 | name = "rest" 15 | user = "root" 16 | password = null 17 | } -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers ++= Seq( 2 | "Sonatype snapshots" at "http://oss.sonatype.org/content/repositories/snapshots/", 3 | "Sonatype releases" at "https://oss.sonatype.org/content/repositories/releases/" 4 | ) 5 | 6 | addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.5.0-SNAPSHOT") 7 | 8 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.9.0") -------------------------------------------------------------------------------- /src/main/scala/com/sysgears/example/domain/CustomerSearchParameters.scala: -------------------------------------------------------------------------------- 1 | package com.sysgears.example.domain 2 | 3 | import java.util.Date 4 | 5 | /** 6 | * Customers search parameters. 7 | * 8 | * @param firstName first name 9 | * @param lastName last name 10 | * @param birthday date of birth 11 | */ 12 | case class CustomerSearchParameters(firstName: Option[String] = None, 13 | lastName: Option[String] = None, 14 | birthday: Option[Date] = None) -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | System.out 6 | 7 | %date{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{1} - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/scala/com/sysgears/example/boot/Boot.scala: -------------------------------------------------------------------------------- 1 | package com.sysgears.example.boot 2 | 3 | import akka.actor.{Props, ActorSystem} 4 | import akka.io.IO 5 | import com.sysgears.example.config.Configuration 6 | import com.sysgears.example.rest.RestServiceActor 7 | import spray.can.Http 8 | 9 | object Boot extends App with Configuration { 10 | 11 | // create an actor system for application 12 | implicit val system = ActorSystem("rest-service-example") 13 | 14 | // create and start rest service actor 15 | val restService = system.actorOf(Props[RestServiceActor], "rest-endpoint") 16 | 17 | // start HTTP server with rest service actor as a handler 18 | IO(Http) ! Http.Bind(restService, serviceHost, servicePort) 19 | } -------------------------------------------------------------------------------- /src/main/scala/com/sysgears/example/domain/Customer.scala: -------------------------------------------------------------------------------- 1 | package com.sysgears.example.domain 2 | 3 | import scala.slick.driver.MySQLDriver.simple._ 4 | 5 | /** 6 | * Customer entity. 7 | * 8 | * @param id unique id 9 | * @param firstName first name 10 | * @param lastName last name 11 | * @param birthday date of birth 12 | */ 13 | case class Customer(id: Option[Long], firstName: String, lastName: String, birthday: Option[java.util.Date]) 14 | 15 | /** 16 | * Mapped customers table object. 17 | */ 18 | object Customers extends Table[Customer]("customers") { 19 | 20 | def id = column[Long]("id", O.PrimaryKey, O.AutoInc) 21 | 22 | def firstName = column[String]("first_name") 23 | 24 | def lastName = column[String]("last_name") 25 | 26 | def birthday = column[java.util.Date]("birthday", O.Nullable) 27 | 28 | def * = id.? ~ firstName ~ lastName ~ birthday.? <>(Customer, Customer.unapply _) 29 | 30 | implicit val dateTypeMapper = MappedTypeMapper.base[java.util.Date, java.sql.Date]( 31 | { 32 | ud => new java.sql.Date(ud.getTime) 33 | }, { 34 | sd => new java.util.Date(sd.getTime) 35 | }) 36 | 37 | val findById = for { 38 | id <- Parameters[Long] 39 | c <- this if c.id is id 40 | } yield c 41 | } -------------------------------------------------------------------------------- /src/main/scala/com/sysgears/example/config/Configuration.scala: -------------------------------------------------------------------------------- 1 | package com.sysgears.example.config 2 | 3 | import com.typesafe.config.ConfigFactory 4 | import util.Try 5 | 6 | /** 7 | * Holds service configuration settings. 8 | */ 9 | trait Configuration { 10 | 11 | /** 12 | * Application config object. 13 | */ 14 | val config = ConfigFactory.load() 15 | 16 | /** Host name/address to start service on. */ 17 | lazy val serviceHost = Try(config.getString("service.host")).getOrElse("localhost") 18 | 19 | /** Port to start service on. */ 20 | lazy val servicePort = Try(config.getInt("service.port")).getOrElse(8080) 21 | 22 | /** Database host name/address. */ 23 | lazy val dbHost = Try(config.getString("db.host")).getOrElse("localhost") 24 | 25 | /** Database host port number. */ 26 | lazy val dbPort = Try(config.getInt("db.port")).getOrElse(3306) 27 | 28 | /** Service database name. */ 29 | lazy val dbName = Try(config.getString("db.name")).getOrElse("rest") 30 | 31 | /** User name used to access database. */ 32 | lazy val dbUser = Try(config.getString("db.user")).toOption.orNull 33 | 34 | /** Password for specified user and database. */ 35 | lazy val dbPassword = Try(config.getString("db.password")).toOption.orNull 36 | } 37 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /src/main/scala/com/sysgears/example/domain/Failure.scala: -------------------------------------------------------------------------------- 1 | package com.sysgears.example.domain 2 | 3 | import spray.http.{StatusCodes, StatusCode} 4 | 5 | /** 6 | * Service failure description. 7 | * 8 | * @param message error message 9 | * @param errorType error type 10 | */ 11 | case class Failure(message: String, errorType: FailureType.Value) { 12 | 13 | /** 14 | * Return corresponding HTTP status code for failure specified type. 15 | * 16 | * @return HTTP status code value 17 | */ 18 | def getStatusCode: StatusCode = { 19 | FailureType.withName(this.errorType.toString) match { 20 | case FailureType.BadRequest => StatusCodes.BadRequest 21 | case FailureType.NotFound => StatusCodes.NotFound 22 | case FailureType.Duplicate => StatusCodes.Forbidden 23 | case FailureType.DatabaseFailure => StatusCodes.InternalServerError 24 | case _ => StatusCodes.InternalServerError 25 | } 26 | } 27 | } 28 | 29 | /** 30 | * Allowed failure types. 31 | */ 32 | object FailureType extends Enumeration { 33 | type Failure = Value 34 | 35 | val BadRequest = Value("bad_request") 36 | val NotFound = Value("not_found") 37 | val Duplicate = Value("entity_exists") 38 | val DatabaseFailure = Value("database_error") 39 | val InternalError = Value("internal_error") 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/main/scala/com/sysgears/example/dao/CustomerDAO.scala: -------------------------------------------------------------------------------- 1 | package com.sysgears.example.dao 2 | 3 | import com.sysgears.example.config.Configuration 4 | import com.sysgears.example.domain._ 5 | import java.sql._ 6 | import scala.Some 7 | import scala.slick.driver.MySQLDriver.simple.Database.threadLocalSession 8 | import scala.slick.driver.MySQLDriver.simple._ 9 | import slick.jdbc.meta.MTable 10 | 11 | /** 12 | * Provides DAL for Customer entities for MySQL database. 13 | */ 14 | class CustomerDAO extends Configuration { 15 | 16 | // init Database instance 17 | private val db = Database.forURL(url = "jdbc:mysql://%s:%d/%s".format(dbHost, dbPort, dbName), 18 | user = dbUser, password = dbPassword, driver = "com.mysql.jdbc.Driver") 19 | 20 | // create tables if not exist 21 | db.withSession { 22 | if (MTable.getTables("customers").list().isEmpty) { 23 | Customers.ddl.create 24 | } 25 | } 26 | 27 | /** 28 | * Saves customer entity into database. 29 | * 30 | * @param customer customer entity to 31 | * @return saved customer entity 32 | */ 33 | def create(customer: Customer): Either[Failure, Customer] = { 34 | try { 35 | val id = db.withSession { 36 | Customers returning Customers.id insert customer 37 | } 38 | Right(customer.copy(id = Some(id))) 39 | } catch { 40 | case e: SQLException => 41 | Left(databaseError(e)) 42 | } 43 | } 44 | 45 | /** 46 | * Updates customer entity with specified one. 47 | * 48 | * @param id id of the customer to update. 49 | * @param customer updated customer entity 50 | * @return updated customer entity 51 | */ 52 | def update(id: Long, customer: Customer): Either[Failure, Customer] = { 53 | try 54 | db.withSession { 55 | Customers.where(_.id === id) update customer.copy(id = Some(id)) match { 56 | case 0 => Left(notFoundError(id)) 57 | case _ => Right(customer.copy(id = Some(id))) 58 | } 59 | } 60 | catch { 61 | case e: SQLException => 62 | Left(databaseError(e)) 63 | } 64 | } 65 | 66 | /** 67 | * Deletes customer from database. 68 | * 69 | * @param id id of the customer to delete 70 | * @return deleted customer entity 71 | */ 72 | def delete(id: Long): Either[Failure, Customer] = { 73 | try { 74 | db.withTransaction { 75 | val query = Customers.where(_.id === id) 76 | val customers = query.run.asInstanceOf[List[Customer]] 77 | customers.size match { 78 | case 0 => 79 | Left(notFoundError(id)) 80 | case _ => { 81 | query.delete 82 | Right(customers.head) 83 | } 84 | } 85 | } 86 | } catch { 87 | case e: SQLException => 88 | Left(databaseError(e)) 89 | } 90 | } 91 | 92 | /** 93 | * Retrieves specific customer from database. 94 | * 95 | * @param id id of the customer to retrieve 96 | * @return customer entity with specified id 97 | */ 98 | def get(id: Long): Either[Failure, Customer] = { 99 | try { 100 | db.withSession { 101 | Customers.findById(id).firstOption match { 102 | case Some(customer: Customer) => 103 | Right(customer) 104 | case _ => 105 | Left(notFoundError(id)) 106 | } 107 | } 108 | } catch { 109 | case e: SQLException => 110 | Left(databaseError(e)) 111 | } 112 | } 113 | 114 | /** 115 | * Retrieves list of customers with specified parameters from database. 116 | * 117 | * @param params search parameters 118 | * @return list of customers that match given parameters 119 | */ 120 | def search(params: CustomerSearchParameters): Either[Failure, List[Customer]] = { 121 | implicit val typeMapper = Customers.dateTypeMapper 122 | 123 | try { 124 | db.withSession { 125 | val query = for { 126 | customer <- Customers if { 127 | Seq( 128 | params.firstName.map(customer.firstName is _), 129 | params.lastName.map(customer.lastName is _), 130 | params.birthday.map(customer.birthday is _) 131 | ).flatten match { 132 | case Nil => ConstColumn.TRUE 133 | case seq => seq.reduce(_ && _) 134 | } 135 | } 136 | } yield customer 137 | 138 | Right(query.run.toList) 139 | } 140 | } catch { 141 | case e: SQLException => 142 | Left(databaseError(e)) 143 | } 144 | } 145 | 146 | /** 147 | * Produce database error description. 148 | * 149 | * @param e SQL Exception 150 | * @return database error description 151 | */ 152 | protected def databaseError(e: SQLException) = 153 | Failure("%d: %s".format(e.getErrorCode, e.getMessage), FailureType.DatabaseFailure) 154 | 155 | /** 156 | * Produce customer not found error description. 157 | * 158 | * @param customerId id of the customer 159 | * @return not found error description 160 | */ 161 | protected def notFoundError(customerId: Long) = 162 | Failure("Customer with id=%d does not exist".format(customerId), FailureType.NotFound) 163 | } -------------------------------------------------------------------------------- /src/main/scala/com/sysgears/example/rest/RestServiceActor.scala: -------------------------------------------------------------------------------- 1 | package com.sysgears.example.rest 2 | 3 | import akka.actor.Actor 4 | import akka.event.slf4j.SLF4JLogging 5 | import com.sysgears.example.dao.CustomerDAO 6 | import com.sysgears.example.domain._ 7 | import java.text.{ParseException, SimpleDateFormat} 8 | import java.util.Date 9 | import net.liftweb.json.Serialization._ 10 | import net.liftweb.json.{DateFormat, Formats} 11 | import scala.Some 12 | import spray.http._ 13 | import spray.httpx.unmarshalling._ 14 | import spray.routing._ 15 | 16 | /** 17 | * REST Service actor. 18 | */ 19 | class RestServiceActor extends Actor with RestService { 20 | 21 | implicit def actorRefFactory = context 22 | 23 | def receive = runRoute(rest) 24 | } 25 | 26 | /** 27 | * REST Service 28 | */ 29 | trait RestService extends HttpService with SLF4JLogging { 30 | 31 | val customerService = new CustomerDAO 32 | 33 | implicit val executionContext = actorRefFactory.dispatcher 34 | 35 | implicit val liftJsonFormats = new Formats { 36 | val dateFormat = new DateFormat { 37 | val sdf = new SimpleDateFormat("yyyy-MM-dd") 38 | 39 | def parse(s: String): Option[Date] = try { 40 | Some(sdf.parse(s)) 41 | } catch { 42 | case e: Exception => None 43 | } 44 | 45 | def format(d: Date): String = sdf.format(d) 46 | } 47 | } 48 | 49 | implicit val string2Date = new FromStringDeserializer[Date] { 50 | def apply(value: String) = { 51 | val sdf = new SimpleDateFormat("yyyy-MM-dd") 52 | try Right(sdf.parse(value)) 53 | catch { 54 | case e: ParseException => { 55 | Left(MalformedContent("'%s' is not a valid Date value" format (value), e)) 56 | } 57 | } 58 | } 59 | } 60 | 61 | implicit val customRejectionHandler = RejectionHandler { 62 | case rejections => mapHttpResponse { 63 | response => 64 | response.withEntity(HttpEntity(ContentType(MediaTypes.`application/json`), 65 | write(Map("error" -> response.entity.asString)))) 66 | } { 67 | RejectionHandler.Default(rejections) 68 | } 69 | } 70 | 71 | val rest = respondWithMediaType(MediaTypes.`application/json`) { 72 | path("customer") { 73 | post { 74 | entity(Unmarshaller(MediaTypes.`application/json`) { 75 | case httpEntity: HttpEntity => 76 | read[Customer](httpEntity.asString(HttpCharsets.`UTF-8`)) 77 | }) { 78 | customer: Customer => 79 | ctx: RequestContext => 80 | handleRequest(ctx, StatusCodes.Created) { 81 | log.debug("Creating customer: %s".format(customer)) 82 | customerService.create(customer) 83 | } 84 | } 85 | } ~ 86 | get { 87 | parameters('firstName.as[String] ?, 'lastName.as[String] ?, 'birthday.as[Date] ?).as(CustomerSearchParameters) { 88 | searchParameters: CustomerSearchParameters => { 89 | ctx: RequestContext => 90 | handleRequest(ctx) { 91 | log.debug("Searching for customers with parameters: %s".format(searchParameters)) 92 | customerService.search(searchParameters) 93 | } 94 | } 95 | } 96 | } 97 | } ~ 98 | path("customer" / LongNumber) { 99 | customerId => 100 | put { 101 | entity(Unmarshaller(MediaTypes.`application/json`) { 102 | case httpEntity: HttpEntity => 103 | read[Customer](httpEntity.asString(HttpCharsets.`UTF-8`)) 104 | }) { 105 | customer: Customer => 106 | ctx: RequestContext => 107 | handleRequest(ctx) { 108 | log.debug("Updating customer with id %d: %s".format(customerId, customer)) 109 | customerService.update(customerId, customer) 110 | } 111 | } 112 | } ~ 113 | delete { 114 | ctx: RequestContext => 115 | handleRequest(ctx) { 116 | log.debug("Deleting customer with id %d".format(customerId)) 117 | customerService.delete(customerId) 118 | } 119 | } ~ 120 | get { 121 | ctx: RequestContext => 122 | handleRequest(ctx) { 123 | log.debug("Retrieving customer with id %d".format(customerId)) 124 | customerService.get(customerId) 125 | } 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * Handles an incoming request and create valid response for it. 132 | * 133 | * @param ctx request context 134 | * @param successCode HTTP Status code for success 135 | * @param action action to perform 136 | */ 137 | protected def handleRequest(ctx: RequestContext, successCode: StatusCode = StatusCodes.OK)(action: => Either[Failure, _]) { 138 | action match { 139 | case Right(result: Object) => 140 | ctx.complete(successCode, write(result)) 141 | case Left(error: Failure) => 142 | ctx.complete(error.getStatusCode, net.liftweb.json.Serialization.write(Map("error" -> error.message))) 143 | case _ => 144 | ctx.complete(StatusCodes.InternalServerError) 145 | } 146 | } 147 | } --------------------------------------------------------------------------------