├── .gitignore ├── .scalafmt.conf ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src ├── it └── scala │ └── com │ └── theiterators │ ├── WombatCuddlerDbItSpec.scala │ ├── WombatCuddlerItSpec.scala │ ├── WombatCuddlerServiceItSpec.scala │ └── wombatcuddler │ └── services │ └── CuddlerServiceItSpec.scala ├── main ├── resources │ └── db │ │ └── migration │ │ └── V1__initial.sql └── scala │ └── com │ └── theiterators │ └── wombatcuddler │ ├── actions │ └── Cuddler.scala │ ├── domain │ ├── Domain.scala │ ├── Errors.scala │ ├── JobApplication.scala │ └── Requests.scala │ ├── main │ ├── H2Driver.scala │ ├── Main.scala │ ├── RestInterface.scala │ ├── Server.scala │ └── Setup.scala │ ├── repository │ ├── JobApplicationRepository.scala │ ├── JobApplicationRow.scala │ ├── JobApplications.scala │ └── TypeMappers.scala │ ├── resources │ ├── CuddlerResource.scala │ └── JsonProtocol.scala │ ├── services │ ├── CuddlerService.scala │ ├── DbioServiceInstances.scala │ └── Service.scala │ └── utils │ └── DbioMonad.scala └── test └── scala └── com └── theiterators └── wombatcuddler └── CuddlerSpec.scala /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | 3 | ## File-based project format: 4 | *.iws 5 | 6 | ## Plugin-specific files: 7 | 8 | # IntelliJ 9 | /out/ 10 | /.idea 11 | 12 | # mpeltonen/sbt-idea plugin 13 | .idea_modules/ 14 | 15 | ### Scala template 16 | *.class 17 | *.log 18 | 19 | # sbt specific 20 | .cache 21 | .history 22 | .lib/ 23 | dist/* 24 | target/ 25 | lib_managed/ 26 | src_managed/ 27 | project/boot/ 28 | project/plugins/project/ 29 | 30 | /logs 31 | ### Linux template 32 | *~ 33 | 34 | # temporary files which can be created if a process still has a handle open of a deleted file 35 | .fuse_hidden* 36 | 37 | # KDE directory preferences 38 | .directory 39 | 40 | # Linux trash folder which might appear on any partition or disk 41 | .Trash-* 42 | 43 | 44 | # H2 databases 45 | /*.db 46 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | style = defaultWithAlign 2 | maxColumn = 139 3 | assumeStandardLibraryStripMargin = true 4 | indentOperator = spray 5 | includeCurlyBraceInSelectChains = true -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "WombatCuddlerJobApp" 2 | version := "1.0" 3 | organization := "com.theiterators" 4 | scalaVersion := "2.11.8" 5 | scalacOptions := Seq( 6 | "-feature", 7 | "-deprecation", 8 | "-unchecked", 9 | "-Xlint:_", 10 | "-Xfatal-warnings", 11 | "-encoding", 12 | "utf8" 13 | ) 14 | 15 | //debugging 16 | Revolver.enableDebugging(port = 5005, suspend = false) 17 | mainClass in reStart := Some("com.theiterators.wombatcuddler.main.Main") 18 | 19 | flywayUrl := "jdbc:h2:./wombatcuddlers" 20 | 21 | //integration tests 22 | configs(IntegrationTest) 23 | Defaults.itSettings 24 | inConfig(IntegrationTest)( 25 | ScalaFmtPlugin.configScalafmtSettings ++ FlywayPlugin.flywayBaseSettings(IntegrationTest) ++ Seq( 26 | flywayUrl := "jdbc:h2:./wombatcuddlers-it", 27 | executeTests <<= executeTests dependsOn flywayMigrate, 28 | flywayMigrate <<= flywayMigrate dependsOn flywayClean)) 29 | 30 | scalafmtConfig := Some(file(".scalafmt.conf")) 31 | reformatOnCompileWithItSettings 32 | 33 | libraryDependencies ++= { 34 | val akkaV = "2.4.8" 35 | val akkaPlayJsonV = "1.7.0" 36 | val catsV = "0.9.0" 37 | val h2V = "1.4.192" 38 | val scalaTestV = "2.2.6" 39 | val slickV = "3.1.1" 40 | val slf4jV = "1.7.21" 41 | Seq( 42 | "com.typesafe.akka" %% "akka-slf4j" % akkaV, 43 | "com.typesafe.akka" %% "akka-http-core" % akkaV, 44 | "com.typesafe.akka" %% "akka-http-experimental" % akkaV, 45 | "de.heikoseeberger" %% "akka-http-play-json" % akkaPlayJsonV, 46 | "org.typelevel" %% "cats" % catsV, 47 | "com.typesafe.slick" %% "slick" % slickV, 48 | "com.typesafe.slick" %% "slick-hikaricp" % slickV, 49 | "org.slf4j" % "slf4j-nop" % slf4jV, 50 | "com.h2database" % "h2" % h2V, 51 | "org.scalatest" %% "scalatest" % scalaTestV % "test,it", 52 | "com.typesafe.akka" %% "akka-http-testkit" % akkaV % "it" 53 | ) 54 | } 55 | addCompilerPlugin("com.milessabin" % "si2712fix-plugin" % "1.2.0" cross CrossVersion.full) 56 | addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.8.0") 57 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 0.13.12 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | logLevel := Level.Warn 2 | 3 | addSbtPlugin("com.geirsson" %% "sbt-scalafmt" % "0.4.10") 4 | 5 | resolvers += "Flyway" at "https://flywaydb.org/repo" 6 | addSbtPlugin("org.flywaydb" % "flyway-sbt" % "4.0.1") 7 | 8 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.8.0") 9 | -------------------------------------------------------------------------------- /src/it/scala/com/theiterators/WombatCuddlerDbItSpec.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators 2 | 3 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 4 | 5 | import scala.concurrent.{ExecutionContext, Future} 6 | import scala.util.{Failure, Success} 7 | 8 | object WombatCuddlerDbItSpec { 9 | private case class IntentionalRollbackException[R](result: R) extends Exception("Rolling back transaction after test") 10 | 11 | implicit class RunWithRollback(val db: Database) extends AnyVal { 12 | def runWithRollback[R](a: DBIOAction[R, NoStream, Nothing])(implicit ec: ExecutionContext): Future[R] = { 13 | val actionWithRollback = a flatMap (r => DBIO.failed(IntentionalRollbackException(r))) 14 | val testResult = db run actionWithRollback.transactionally.asTry 15 | testResult map { 16 | case Failure(IntentionalRollbackException(success)) => success.asInstanceOf[R] 17 | case Failure(t) => throw t 18 | case Success(r) => r 19 | } 20 | } 21 | } 22 | } 23 | 24 | trait WombatCuddlerDbItSpec extends WombatCuddlerItSpec { 25 | import WombatCuddlerDbItSpec._ 26 | 27 | override lazy val db = Database.forDriver(new org.h2.Driver, "jdbc:h2:./wombatcuddlers-it") 28 | protected def testWithRollback[A](action: DBIO[A]) = await(db runWithRollback action) 29 | } 30 | -------------------------------------------------------------------------------- /src/it/scala/com/theiterators/WombatCuddlerItSpec.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators 2 | 3 | import java.util.concurrent.Executors 4 | 5 | import com.theiterators.wombatcuddler.main.Setup 6 | import com.theiterators.wombatcuddler.utils.DbioMonad 7 | 8 | import scala.concurrent._ 9 | import scala.concurrent.duration._ 10 | 11 | trait WombatCuddlerItSpec extends Setup with DbioMonad { 12 | override implicit val executionContext: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor()) 13 | 14 | protected final def await[T](f: Future[T]): T = Await.result(f, 1.minute) 15 | } 16 | -------------------------------------------------------------------------------- /src/it/scala/com/theiterators/WombatCuddlerServiceItSpec.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators 2 | 3 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 4 | import com.theiterators.wombatcuddler.services.Service 5 | 6 | import scala.language.higherKinds 7 | 8 | trait WombatCuddlerServiceItSpec extends WombatCuddlerDbItSpec { 9 | 10 | protected def test[DSL[_], R](service: Service[DSL, DBIO])(program: service.Program[R]) = { 11 | val dbActions = service execute program 12 | testWithRollback(dbActions) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/it/scala/com/theiterators/wombatcuddler/services/CuddlerServiceItSpec.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.services 2 | 3 | import cats.syntax.either._ 4 | import com.theiterators.WombatCuddlerServiceItSpec 5 | import com.theiterators.wombatcuddler.actions.Cuddler._ 6 | import com.theiterators.wombatcuddler.domain._ 7 | import com.theiterators.wombatcuddler.repository.{JobApplicationRepository, JobApplicationRow} 8 | import org.scalatest._ 9 | 10 | class CuddlerServiceItSpec extends FunSpec with Matchers with Inside with WombatCuddlerServiceItSpec { 11 | val newApplicationRequest = NewApplicationRequest(Email("joe.roberts@example.com"), 12 | FullName("Joe Roberts"), 13 | CV("My name is Joe Roberts I work for the State"), 14 | Letter("I dreamt all my life about cuddling a wombat")) 15 | 16 | describe("CuddlerService") { 17 | val cuddlerService = CuddlerService(new JobApplicationRepository) 18 | //happy path 19 | it("can save job application request") { 20 | test(cuddlerService)(applyForJob(newApplicationRequest)) should matchPattern { 21 | case Right( 22 | JobApplicationRow(_, 23 | Email("joe.roberts@example.com"), 24 | _, 25 | FullName("Joe Roberts"), 26 | CV("My name is Joe Roberts I work for the State"), 27 | Letter("I dreamt all my life about cuddling a wombat"), 28 | _, 29 | None)) => 30 | } 31 | } 32 | 33 | it("can update job application request") { 34 | test(cuddlerService) { 35 | for { 36 | myApplication <- applyForJob(newApplicationRequest) 37 | pin = myApplication.map(_.pin).getOrElse(PIN("invalid PIN")) 38 | result <- updateApplication(Email("joe.roberts@example.com"), 39 | pin, 40 | UpdateApplicationRequest(newFullName = Some(FullName("Joe R. Roberts")))).value 41 | } yield result 42 | } should matchPattern { 43 | case Right( 44 | JobApplicationRow(_, 45 | Email("joe.roberts@example.com"), 46 | _, 47 | FullName("Joe R. Roberts"), 48 | CV("My name is Joe Roberts I work for the State"), 49 | Letter("I dreamt all my life about cuddling a wombat"), 50 | _, 51 | Some(_))) => 52 | } 53 | } 54 | 55 | it("can remove job application request") { 56 | test(cuddlerService) { 57 | for { 58 | myApplication <- applyForJob(newApplicationRequest) 59 | pin = myApplication.map(_.pin).getOrElse(PIN("invalid PIN")) 60 | _ <- deleteApplication(Email("joe.roberts@example.com"), pin).value 61 | triedAgain <- applyForJob(newApplicationRequest) 62 | } yield triedAgain 63 | } should matchPattern { case Right(_) => } 64 | } 65 | 66 | // unhappy path 67 | it("will not add two job applications with the same email") { 68 | test(cuddlerService) { 69 | for { 70 | _ <- applyForJob(newApplicationRequest) 71 | result <- applyForJob(newApplicationRequest) 72 | } yield result 73 | } should matchPattern { 74 | case Left(DuplicateEmail) => 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__initial.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE job_applications ( 2 | ID BIGINT IDENTITY, 3 | EMAIL VARCHAR(254) NOT NULL UNIQUE, 4 | FULL_NAME VARCHAR(255) NOT NULL, 5 | CV CLOB NOT NULL, 6 | MOTIVATION_LETTER CLOB NOT NULL, 7 | CREATED_AT TIMESTAMP NOT NULL, 8 | UPDATED_AT TIMESTAMP, 9 | PIN VARCHAR(8) NOT NULL 10 | ) -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/actions/Cuddler.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.actions 2 | 3 | import cats.data.EitherT 4 | import cats.free.Free 5 | import cats.instances.either._ 6 | import cats.instances.option._ 7 | import cats.syntax.either._ 8 | import cats.syntax.traverse._ 9 | import com.theiterators.wombatcuddler.domain._ 10 | 11 | object Cuddler { 12 | sealed trait Action[R] 13 | case class SaveApplication(req: NewApplicationRequest) extends Action[Either[Error, JobApplication]] 14 | case class UpdateApplication(email: Email, req: UpdateApplicationRequest) extends Action[Either[Error, JobApplication]] 15 | case class RemoveApplication(email: Email) extends Action[Boolean] 16 | case class CheckPIN(email: Email, pin: PIN) extends Action[Boolean] 17 | 18 | type Program[A] = Free[Action, A] 19 | type ProgramEx[A] = EitherT[Program, Error, A] 20 | 21 | private def execute[A](action: Action[A]) = Free.liftF(action) 22 | private def returns[A](value: A): Program[A] = Free.pure(value) 23 | private def fail[A](error: Error): Program[Either[Error, A]] = returns(Left(error)) 24 | 25 | private def executeOrFail[A](action: Action[Either[Error, A]]): ProgramEx[A] = EitherT(execute(action)) 26 | private def executeRight[A](action: Action[A]): ProgramEx[A] = EitherT.right(execute(action)) 27 | private def returnOrFail[A](value: Either[Error, A]): ProgramEx[A] = EitherT.fromEither(value) 28 | private def throws[A](error: Error): ProgramEx[A] = EitherT(fail(error)) 29 | 30 | def applyForJob(req: NewApplicationRequest): Program[Either[Error, JobApplication]] = validate(req) match { 31 | case Left(error) => fail(error) 32 | case Right(_) => execute(SaveApplication(req)) 33 | } 34 | 35 | def updateApplication(email: Email, pin: PIN, req: UpdateApplicationRequest): ProgramEx[JobApplication] = 36 | for { 37 | _ <- returnOrFail(validate(req)) 38 | correctPIN <- executeRight(CheckPIN(email, pin)) 39 | result <- if (correctPIN) executeOrFail(UpdateApplication(email, req)) else throws(IncorrectPIN) 40 | } yield result 41 | 42 | def deleteApplication(email: Email, pin: PIN): ProgramEx[Boolean] = 43 | for { 44 | _ <- returnOrFail(validateEmail(email)) 45 | correctPIN <- executeRight(CheckPIN(email, pin)) 46 | result <- if (correctPIN) executeRight(RemoveApplication(email)) else throws(IncorrectPIN) 47 | } yield result 48 | 49 | private def validate(request: UpdateApplicationRequest): Either[RequestError, UpdateApplicationRequest] = 50 | for { 51 | _ <- request.newFullName.traverse(validateFullName) 52 | _ <- request.newCv.traverse(validateCv) 53 | _ <- request.newMotivationLetter.traverse(validateLetter) 54 | } yield request 55 | 56 | private def validate(request: NewApplicationRequest): Either[RequestError, NewApplicationRequest] = 57 | for { 58 | _ <- validateEmail(request.email) 59 | _ <- validateFullName(request.fullName) 60 | _ <- validateCv(request.cv) 61 | _ <- validateLetter(request.motivationLetter) 62 | } yield request 63 | 64 | private def validateEmail(email: Email) = email match { 65 | case Email.Valid() => Right(email) 66 | case _ => Left(EmailFormatError) 67 | } 68 | 69 | private def validateFullName(fullName: FullName) = if (fullName.value.trim.isEmpty) Left(Empty("fullName")) else Right(fullName) 70 | private def validateCv(cv: CV) = if (cv.value.isEmpty) Left(Empty("cv")) else Right(cv) 71 | private def validateLetter(letter: Letter) = if (letter.value.isEmpty) Left(Empty("letter")) else Right(letter) 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/domain/Domain.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.domain 2 | 3 | import scala.util.Random 4 | 5 | case class Email(value: String) extends AnyVal 6 | object Email { 7 | object Valid { 8 | def `addressHas@`(address: String): Boolean = { 9 | val indexOfAt = address.trim.indexOf('@') 10 | indexOfAt > 0 && indexOfAt < address.length - 1 11 | } 12 | 13 | def unapply(arg: Email): Boolean = arg match { 14 | case Email(address) if `addressHas@`(address) => true 15 | case _ => false 16 | } 17 | } 18 | } 19 | 20 | case class FullName(value: String) extends AnyVal 21 | case class CV(value: String) extends AnyVal 22 | case class Letter(value: String) extends AnyVal 23 | 24 | case class PIN(value: String) extends AnyVal 25 | object PIN { 26 | private val PIN_LENGTH = 8 27 | def generate: PIN = PIN(Random.alphanumeric.take(PIN_LENGTH).mkString.toUpperCase) 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/domain/Errors.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.domain 2 | 3 | sealed abstract class Error { 4 | val errorCode: String = this.getClass.getSimpleName.stripSuffix("$") 5 | } 6 | case object IncorrectPIN extends Error 7 | case object EmailNotFound extends Error 8 | case object DuplicateEmail extends Error 9 | 10 | sealed abstract class RequestError extends Error 11 | case class Empty(field: String) extends RequestError { 12 | override val errorCode: String = s"$field:empty" 13 | } 14 | case object EmailFormatError extends RequestError 15 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/domain/JobApplication.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.domain 2 | 3 | trait JobApplication { 4 | def email: Email 5 | def pin: PIN 6 | def fullName: FullName 7 | def cv: CV 8 | def motivationLetter: Letter 9 | } 10 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/domain/Requests.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.domain 2 | 3 | case class NewApplicationRequest(email: Email, fullName: FullName, cv: CV, motivationLetter: Letter) 4 | case class UpdateApplicationRequest(newFullName: Option[FullName] = None, 5 | newCv: Option[CV] = None, 6 | newMotivationLetter: Option[Letter] = None) 7 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/main/H2Driver.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.main 2 | 3 | import java.sql.SQLException 4 | 5 | import org.h2.jdbc.JdbcSQLException 6 | 7 | object H2Driver extends slick.driver.H2Driver { 8 | object H2API extends API { 9 | final class DuplicateKey(reason: String, cause: Throwable) extends SQLException(reason, DuplicateKey.DUPLICATE_KEY, cause) 10 | 11 | object DuplicateKey { 12 | val DUPLICATE_KEY = "23505" 13 | 14 | def unapply(ex: Throwable): Option[DuplicateKey] = ex match { 15 | case (jdbcException: JdbcSQLException) => 16 | jdbcException.getSQLState match { 17 | case DUPLICATE_KEY => Some(new DuplicateKey(jdbcException.getMessage, jdbcException)) 18 | case _ => None 19 | } 20 | case _ => None 21 | } 22 | } 23 | } 24 | 25 | override val api = H2API 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/main/Main.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.main 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.Http 5 | import akka.stream.{ActorMaterializer, Materializer} 6 | 7 | import scala.concurrent.ExecutionContext 8 | 9 | object Main extends Setup with Server with RestInterface { 10 | override implicit val system: ActorSystem = ActorSystem("wombat-cuddler") 11 | override implicit val executionContext: ExecutionContext = system.dispatcher 12 | override implicit val materializer: Materializer = ActorMaterializer() 13 | 14 | def main(args: Array[String]): Unit = { 15 | Http() 16 | .bindAndHandle(handler = routes, interface = httpServerConfig.hostname, port = httpServerConfig.port) 17 | .map { binding => 18 | logger.info(s"HTTP server started at ${binding.localAddress}") 19 | } 20 | .recover { case ex => logger.error(ex, "Could not start HTTP server") } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/main/RestInterface.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.main 2 | 3 | import akka.http.scaladsl.server.Route 4 | import com.theiterators.wombatcuddler.resources.CuddlerResource 5 | 6 | trait RestInterface extends Resources { 7 | def routes: Route = applyForJob ~ myApplication 8 | } 9 | 10 | trait Resources extends CuddlerResource 11 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/main/Server.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.main 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.Logging 5 | import akka.stream.Materializer 6 | 7 | import scala.concurrent.ExecutionContext 8 | 9 | case class HttpServerConfig(hostname: String, port: Int) 10 | 11 | trait Server { self: Setup => 12 | 13 | implicit def executionContext: ExecutionContext 14 | implicit def system: ActorSystem 15 | implicit def materializer: Materializer 16 | 17 | lazy val logger = Logging(system, getClass) 18 | 19 | val httpServerConfig = HttpServerConfig(hostname = "localhost", port = 5000) 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/main/Setup.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.main 2 | 3 | import com.theiterators.wombatcuddler.actions.Cuddler 4 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 5 | import com.theiterators.wombatcuddler.repository.JobApplicationRepository 6 | import com.theiterators.wombatcuddler.services._ 7 | 8 | import scala.concurrent.{ExecutionContext, Future} 9 | 10 | trait Setup extends DbioServiceInstances { 11 | implicit def executionContext: ExecutionContext 12 | lazy val db = Database.forDriver(new org.h2.Driver, "jdbc:h2:./wombatcuddlers") 13 | 14 | lazy val cuddlerService: Service[Cuddler.Action, Future] = CuddlerService(new JobApplicationRepository) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/repository/JobApplicationRepository.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.repository 2 | 3 | import com.theiterators.wombatcuddler.domain.{Email, PIN} 4 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 5 | 6 | import scala.concurrent.ExecutionContext 7 | 8 | final class JobApplicationRepository { 9 | import TypeMappers.{emailMapping, pinMapping} 10 | val jobApplications = TableQuery[JobApplications] 11 | private val applicationsReturningId = jobApplications returning (jobApplications map (_.id)) 12 | 13 | private val findQuery = Compiled((id: Rep[JobApplicationId]) => jobApplications filter (_.id === id)) 14 | private val findByEmailQuery = Compiled((email: Rep[Email]) => jobApplications filter (_.email === email)) 15 | private val existsByEmailAndPINQuery = Compiled( 16 | (email: Rep[Email], pin: Rep[PIN]) => jobApplications.filter(row => row.email === email && row.pin === pin).exists) 17 | 18 | def delete(email: Email): DBIO[Int] = findByEmailQuery(email).delete 19 | def exists(email: Email, pin: PIN): DBIO[Boolean] = existsByEmailAndPINQuery((email, pin)).result 20 | def find(id: JobApplicationId): DBIO[Option[JobApplicationRow]] = findQuery(id).result.headOption 21 | def findByEmail(email: Email): DBIO[Option[JobApplicationRow]] = findByEmailQuery(email).result.headOption 22 | def findExisting(id: JobApplicationId): DBIO[JobApplicationRow] = findQuery(id).result.head 23 | def save(row: JobApplicationRow)(implicit ec: ExecutionContext): DBIO[JobApplicationId] = 24 | applicationsReturningId.insertOrUpdate(row).map { 25 | case Some(newId) => newId 26 | case None => row.id.getOrElse(sys.error(s"ReturningInsertActionComposer updated entity ($row) with no id")) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/repository/JobApplicationRow.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.repository 2 | 3 | import java.time.LocalDateTime 4 | 5 | import com.theiterators.wombatcuddler.domain._ 6 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 7 | 8 | case class JobApplicationId(value: Int) extends AnyVal 9 | object JobApplicationId { 10 | implicit val mapping: BaseColumnType[JobApplicationId] = MappedColumnType.base(_.value, JobApplicationId.apply) 11 | } 12 | 13 | case class JobApplicationRow(id: Option[JobApplicationId] = None, 14 | email: Email, 15 | pin: PIN, 16 | fullName: FullName, 17 | cv: CV, 18 | motivationLetter: Letter, 19 | createdAt: LocalDateTime, 20 | updatedAt: Option[LocalDateTime] = None) 21 | extends JobApplication { 22 | def update(updateApplicationRequest: UpdateApplicationRequest): JobApplicationRow = this.copy( 23 | fullName = updateApplicationRequest.newFullName.getOrElse(fullName), 24 | cv = updateApplicationRequest.newCv.getOrElse(cv), 25 | motivationLetter = updateApplicationRequest.newMotivationLetter.getOrElse(motivationLetter), 26 | updatedAt = Some(LocalDateTime.now()) 27 | ) 28 | } 29 | 30 | object JobApplicationRow { 31 | def fromNewApplicationRequest(newApplicationRequest: NewApplicationRequest, pin: PIN): JobApplicationRow = JobApplicationRow( 32 | email = newApplicationRequest.email, 33 | pin = pin, 34 | fullName = newApplicationRequest.fullName, 35 | cv = newApplicationRequest.cv, 36 | motivationLetter = newApplicationRequest.motivationLetter, 37 | createdAt = LocalDateTime.now() 38 | ) 39 | 40 | val tupled = (apply _).tupled 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/repository/JobApplications.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.repository 2 | 3 | import java.time.LocalDateTime 4 | 5 | import com.theiterators.wombatcuddler.domain._ 6 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 7 | import com.theiterators.wombatcuddler.repository.TypeMappers._ 8 | import slick.lifted.ProvenShape 9 | 10 | class JobApplications(tag: Tag) extends Table[JobApplicationRow](tag, Some("PUBLIC"), "JOB_APPLICATIONS") { 11 | def id: Rep[JobApplicationId] = column[JobApplicationId]("ID", O.PrimaryKey, O.AutoInc) 12 | def email: Rep[Email] = column[Email]("EMAIL") 13 | def fullName: Rep[FullName] = column[FullName]("FULL_NAME") 14 | def cv: Rep[CV] = column[CV]("CV", O.SqlType("CLOB")) 15 | def motivationLetter: Rep[Letter] = column[Letter]("MOTIVATION_LETTER", O.SqlType("CLOB")) 16 | def pin: Rep[PIN] = column[PIN]("PIN") 17 | def createdAt: Rep[LocalDateTime] = column[LocalDateTime]("CREATED_AT") 18 | def updatedAt: Rep[Option[LocalDateTime]] = column[Option[LocalDateTime]]("UPDATED_AT") 19 | 20 | override def * : ProvenShape[JobApplicationRow] = 21 | (id.?, email, pin, fullName, cv, motivationLetter, createdAt, updatedAt) <> (JobApplicationRow.tupled, JobApplicationRow.unapply) 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/repository/TypeMappers.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.repository 2 | 3 | import java.sql.Timestamp 4 | import java.time.LocalDateTime 5 | 6 | import com.theiterators.wombatcuddler.domain._ 7 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 8 | 9 | object TypeMappers { 10 | implicit val emailMapping: BaseColumnType[Email] = MappedColumnType.base(_.value, Email.apply) 11 | implicit val cvMapping: BaseColumnType[CV] = MappedColumnType.base(_.value, CV.apply) 12 | implicit val fullnameMapping: BaseColumnType[FullName] = MappedColumnType.base(_.value, FullName.apply) 13 | implicit val letterMapping: BaseColumnType[Letter] = MappedColumnType.base(_.value, Letter.apply) 14 | implicit val pinMapping: BaseColumnType[PIN] = MappedColumnType.base(_.value, PIN.apply) 15 | implicit val localDateTimeMapping: BaseColumnType[LocalDateTime] = 16 | MappedColumnType.base(Timestamp.valueOf, (ts: Timestamp) => ts.toLocalDateTime) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/resources/CuddlerResource.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.resources 2 | 3 | import akka.http.scaladsl.model.StatusCodes._ 4 | import akka.http.scaladsl.server.{Directives, Route} 5 | import com.theiterators.wombatcuddler.actions.Cuddler 6 | import com.theiterators.wombatcuddler.domain._ 7 | import com.theiterators.wombatcuddler.services.Service 8 | import de.heikoseeberger.akkahttpplayjson.PlayJsonSupport 9 | 10 | import scala.concurrent.{ExecutionContext, Future} 11 | 12 | trait CuddlerResource extends Directives with JsonProtocol with PlayJsonSupport { 13 | implicit def executionContext: ExecutionContext 14 | 15 | def cuddlerService: Service[Cuddler.Action, Future] 16 | 17 | val applyForJob: Route = (pathPrefix("cuddlers") & pathEndOrSingleSlash & post & entity(as[NewApplicationRequest])) { req => 18 | onSuccess(cuddlerService.run(Cuddler.applyForJob(req))) { 19 | case Right(_) => complete(NoContent) 20 | case Left(DuplicateEmail) => complete(Conflict) 21 | case Left(error) => complete(BadRequest -> error) 22 | } 23 | } 24 | 25 | val myApplication: Route = (pathPrefix("my-application") & pathEndOrSingleSlash & parameters(('email.as[String], 'pin.as[String]))) { 26 | (emailStr, pinStr) => 27 | val email = Email(emailStr) 28 | val pin = PIN(pinStr) 29 | 30 | (put & entity(as[UpdateApplicationRequest])) { req => 31 | onSuccess(cuddlerService.run(Cuddler.updateApplication(email, pin, req).value)) { 32 | case Right(_) => complete(NoContent) 33 | case Left(IncorrectPIN) => complete(Forbidden) 34 | case Left(EmailNotFound) => complete(NotFound) 35 | case Left(error) => complete(BadRequest -> error) 36 | } 37 | } ~ delete { 38 | onSuccess(cuddlerService.run(Cuddler.deleteApplication(email, pin).value)) { 39 | case Right(true) => complete(NoContent) 40 | case Right(false) | Left(EmailNotFound) => complete(NotFound) 41 | case Left(IncorrectPIN) => complete(Forbidden) 42 | case Left(error) => complete(BadRequest -> error) 43 | } 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/resources/JsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.resources 2 | 3 | import com.theiterators.wombatcuddler.domain._ 4 | import play.api.libs.functional.syntax._ 5 | import play.api.libs.json._ 6 | 7 | trait JsonProtocol { 8 | implicit val errorWrites = new Writes[Error] { 9 | override def writes(o: Error): JsValue = Json.obj("error" -> o.errorCode) 10 | } 11 | 12 | implicit val emailFormat = new Format[Email] { 13 | override def writes(o: Email) = JsString(o.value) 14 | override def reads(json: JsValue) = json match { 15 | case JsString(value) => JsSuccess(Email(value)) 16 | case _ => JsError() 17 | } 18 | } 19 | implicit val cvFormat = new Format[CV] { 20 | override def writes(o: CV) = JsString(o.value) 21 | override def reads(json: JsValue) = json match { 22 | case JsString(value) => JsSuccess(CV(value)) 23 | case _ => JsError() 24 | } 25 | } 26 | implicit val fullNameFormat = new Format[FullName] { 27 | override def writes(o: FullName) = JsString(o.value) 28 | override def reads(json: JsValue) = json match { 29 | case JsString(value) => JsSuccess(FullName(value)) 30 | case _ => JsError() 31 | } 32 | } 33 | implicit val letterFormat = new Format[Letter] { 34 | override def writes(o: Letter) = JsString(o.value) 35 | override def reads(json: JsValue) = json match { 36 | case JsString(value) => JsSuccess(Letter(value)) 37 | case _ => JsError() 38 | } 39 | } 40 | 41 | implicit val newApplicationRequestRead: Reads[NewApplicationRequest] = ( 42 | (__ \ "email").read[Email] and (__ \ "fullName").read[FullName] and (__ \ "cv").read[CV] and (__ \ "letter").read[Letter] 43 | )(NewApplicationRequest.apply _) 44 | implicit val updateApplicationRequestReads: Reads[UpdateApplicationRequest] = ( 45 | (__ \ "fullName").readNullable[FullName] and (__ \ "cv").readNullable[CV] and (__ \ "letter").readNullable[Letter] 46 | )(UpdateApplicationRequest.apply _) 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/services/CuddlerService.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.services 2 | 3 | import com.theiterators.wombatcuddler.actions.Cuddler 4 | import com.theiterators.wombatcuddler.actions.Cuddler._ 5 | import com.theiterators.wombatcuddler.domain._ 6 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 7 | import com.theiterators.wombatcuddler.repository._ 8 | 9 | import scala.concurrent.ExecutionContext 10 | import scala.util.{Failure, Success} 11 | 12 | abstract class CuddlerService(jobApplicationRepository: JobApplicationRepository)(implicit executionContext: ExecutionContext) 13 | extends FreeService[Cuddler.Action, DBIO] { 14 | def saveApplication(newApplicationRequest: NewApplicationRequest): DBIO[Either[Error, JobApplication]] = { 15 | val pin = PIN.generate 16 | val applicationRow = JobApplicationRow.fromNewApplicationRequest(newApplicationRequest, pin) 17 | 18 | val insertAction = for { 19 | id <- jobApplicationRepository.save(applicationRow) 20 | application <- jobApplicationRepository.findExisting(id) 21 | } yield application 22 | 23 | insertAction.asTry.flatMap { 24 | case Success(right) => DBIO.successful(Right(right)) 25 | case Failure(DuplicateKey(_)) => DBIO.successful(Left(DuplicateEmail)) 26 | case Failure(other) => DBIO.failed(other) 27 | } 28 | } 29 | 30 | def checkPIN(email: Email, pin: PIN): DBIO[Boolean] = jobApplicationRepository.exists(email, pin) 31 | 32 | def removeApplication(email: Email): DBIO[Boolean] = jobApplicationRepository.delete(email).map(_ == 1) 33 | 34 | def updateApplication(email: Email, updateApplicationRequest: UpdateApplicationRequest): DBIO[Either[Error, JobApplication]] = 35 | jobApplicationRepository 36 | .findByEmail(email) 37 | .flatMap { 38 | case None => DBIO.successful(Left(EmailNotFound)) 39 | case Some(row) => 40 | val updatedRow = row.update(updateApplicationRequest) 41 | jobApplicationRepository.save(updatedRow).map(_ => Right(updatedRow)) 42 | } 43 | .transactionally 44 | } 45 | 46 | object CuddlerService { 47 | def apply(jobApplicationRepository: JobApplicationRepository)(implicit executionContext: ExecutionContext): CuddlerService = 48 | new CuddlerService(jobApplicationRepository) { 49 | override def apply[A](fa: Action[A]): DBIO[A] = fa match { 50 | case SaveApplication(newApplicationRequest) => saveApplication(newApplicationRequest) 51 | case CheckPIN(email, pin) => checkPIN(email, pin) 52 | case RemoveApplication(email) => removeApplication(email) 53 | case UpdateApplication(email, updateApplicationRequest) => updateApplication(email, updateApplicationRequest) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/services/DbioServiceInstances.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.services 2 | 3 | import cats.Monad 4 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 5 | import com.theiterators.wombatcuddler.utils.DbioMonad 6 | 7 | import scala.concurrent.{ExecutionContext, Future} 8 | import scala.language.{higherKinds, implicitConversions} 9 | 10 | trait DbioServiceInstances extends DbioMonad { 11 | def db: Database 12 | implicit def executionContext: ExecutionContext 13 | 14 | implicit def toFuture[DSL[_]](dbioService: Service[DSL, DBIO]): Service[DSL, Future] = new Service[DSL, Future] { 15 | override def execute[A](program: Program[A])(implicit M: Monad[Future]): Future[A] = db.run(dbioService.execute(program)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/services/Service.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.services 2 | 3 | import cats.free.Free 4 | import cats.{Monad, ~>} 5 | 6 | import scala.concurrent.{ExecutionContext, Future} 7 | import scala.language.higherKinds 8 | 9 | trait Service[DSL[_], M[_]] { self => 10 | final type Program[Result] = Free[DSL, Result] 11 | 12 | def execute[A](program: Program[A])(implicit M: Monad[M]): M[A] 13 | } 14 | 15 | object Service { 16 | def apply[DSL[_], M[_]](f: DSL ~> M): FreeService[DSL, M] = new FreeService[DSL, M] { 17 | override def apply[A](fa: DSL[A]): M[A] = f(fa) 18 | } 19 | 20 | implicit class FutureServiceOps[DSL[_]](val self: Service[DSL, Future]) extends AnyVal { 21 | import cats.instances.future._ 22 | def run[A](action: self.Program[A])(implicit ec: ExecutionContext): Future[A] = self.execute(action) 23 | def runWithResultHandler[A, U](action: self.Program[A])(handler: PartialFunction[A, U])(implicit ec: ExecutionContext): Future[A] = { 24 | val fut = run(action) 25 | fut.onSuccess(handler) 26 | fut 27 | } 28 | } 29 | } 30 | 31 | abstract class FreeService[DSL[_], M[_]] extends (DSL ~> M) with Service[DSL, M] { self => 32 | final val nat: DSL ~> M = this 33 | override final def execute[A](program: Program[A])(implicit M: Monad[M]): M[A] = program foldMap this 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/theiterators/wombatcuddler/utils/DbioMonad.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler.utils 2 | 3 | import cats.Monad 4 | import com.theiterators.wombatcuddler.main.H2Driver.api._ 5 | 6 | import scala.concurrent.ExecutionContext 7 | 8 | trait DbioMonad { 9 | implicit def DBIOMonad(implicit executionContext: ExecutionContext): Monad[DBIO] = new Monad[DBIO] { 10 | override def pure[A](x: A): DBIO[A] = DBIO.successful(x) 11 | override def flatMap[A, B](fa: DBIO[A])(f: (A) => DBIO[B]): DBIO[B] = fa flatMap f 12 | override def tailRecM[A, B](a: A)(f: (A) => DBIO[Either[A, B]]): DBIO[B] = f(a) flatMap { 13 | case Left(a1) => tailRecM(a1)(f) 14 | case Right(b) => DBIO.successful(b) 15 | } 16 | } 17 | } 18 | 19 | object DbioMonad extends DbioMonad 20 | -------------------------------------------------------------------------------- /src/test/scala/com/theiterators/wombatcuddler/CuddlerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.theiterators.wombatcuddler 2 | 3 | import cats.{Id, ~>} 4 | import com.theiterators.wombatcuddler.actions.Cuddler 5 | import com.theiterators.wombatcuddler.domain._ 6 | import org.scalatest.{FunSuite, Matchers} 7 | 8 | class CuddlerSpec extends FunSuite with Matchers { 9 | 10 | case object FakeJobApplication extends JobApplication { 11 | override def email: Email = Email("joe.roberts@example.com") 12 | override def fullName: FullName = FullName("Joe Roberts") 13 | override def cv: CV = CV("My name is Joe Roberts I work for the State") 14 | override def motivationLetter: Letter = Letter("I dreamt all my life about cuddling a wombat") 15 | override def pin: PIN = PIN("1000") 16 | } 17 | import Cuddler._ 18 | 19 | val testInterpreter = new (Action ~> Id) { 20 | override def apply[A](fa: Action[A]): A = fa match { 21 | case SaveApplication(_) => Right(FakeJobApplication) 22 | case UpdateApplication(_, _) => Right(FakeJobApplication) 23 | case CheckPIN(Email("joe.roberts@example.com"), pin) => pin == PIN("1000") 24 | case CheckPIN(_, _) => false 25 | case RemoveApplication(Email("joe.roberts@example.com")) => true 26 | case RemoveApplication(_) => false 27 | } 28 | } 29 | 30 | val newApplicationRequest = NewApplicationRequest(Email("joe.roberts@example.com"), 31 | FullName("Joe Roberts"), 32 | CV("My name is Joe Roberts I work for the State"), 33 | Letter("I dreamt all my life about cuddling a wombat")) 34 | 35 | test("Trying to apply for wombat cuddler and giving invalid e-mail is a no-go") { 36 | applyForJob(newApplicationRequest.copy(email = Email("joe.roberts2example.com"))) foldMap testInterpreter shouldBe Left( 37 | EmailFormatError) 38 | } 39 | 40 | test("Trying to apply for wombat cuddler and giving empty name is a no-go") { 41 | applyForJob(newApplicationRequest.copy(fullName = FullName(""))) foldMap testInterpreter shouldBe Left(Empty("fullName")) 42 | } 43 | 44 | test("Trying to apply for wombat cuddler and giving empty CV is a no-go") { 45 | applyForJob(newApplicationRequest.copy(cv = CV(""))) foldMap testInterpreter shouldBe Left(Empty("cv")) 46 | } 47 | 48 | test("Trying to apply for wombat cuddler and giving empty motivation letter CV is a no-go") { 49 | applyForJob(newApplicationRequest.copy(motivationLetter = Letter(""))) foldMap testInterpreter shouldBe Left(Empty("letter")) 50 | } 51 | 52 | test("otherwise application can be processed") { 53 | applyForJob(newApplicationRequest) foldMap testInterpreter shouldBe Right(FakeJobApplication) 54 | } 55 | 56 | test("Trying to update job application with wrong data should fail") { 57 | updateApplication(Email("joe.roberts@example.com"), pin = PIN("1000"), UpdateApplicationRequest(newCv = Some(CV("")))).value foldMap testInterpreter shouldBe Left( 58 | Empty("cv")) 59 | } 60 | 61 | test("Trying to update job application giving wrong PIN should fail") { 62 | updateApplication(Email("joe.roberts@example.com"), pin = PIN("1111"), UpdateApplicationRequest()).value foldMap testInterpreter shouldBe Left( 63 | IncorrectPIN) 64 | } 65 | 66 | test("Trying to update job application giving email that was not registered should fail") { 67 | updateApplication(Email("john.doe@example.com"), pin = PIN("1000"), UpdateApplicationRequest()).value foldMap testInterpreter shouldBe Left( 68 | IncorrectPIN) 69 | } 70 | 71 | test("otherwise it should succeed") { 72 | updateApplication(Email("joe.roberts@example.com"), pin = PIN("1000"), UpdateApplicationRequest()).value foldMap testInterpreter shouldBe Right( 73 | FakeJobApplication) 74 | } 75 | 76 | test("Trying to remove job application giving wrong PIN should fail") { 77 | deleteApplication(Email("joe.roberts@example.com"), pin = PIN("0000")).value foldMap testInterpreter shouldBe Left(IncorrectPIN) 78 | } 79 | 80 | test("Trying to remove job application giving email that was not registered should fail") { 81 | deleteApplication(Email("joe@example.com"), pin = PIN("1000")).value foldMap testInterpreter shouldBe Left(IncorrectPIN) 82 | } 83 | 84 | test("otherwise it should remove an application") { 85 | deleteApplication(Email("joe.roberts@example.com"), pin = PIN("1000")).value foldMap testInterpreter shouldBe Right(true) 86 | } 87 | 88 | } 89 | --------------------------------------------------------------------------------