├── .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 | }
--------------------------------------------------------------------------------