├── .gitignore ├── .scalafmt.conf ├── README.md ├── build.sbt ├── docker-compose.yml ├── modules ├── common │ └── src │ │ └── main │ │ └── scala │ │ └── name │ │ └── aloise │ │ └── core │ │ ├── Dep.scala │ │ └── utils │ │ └── hlist │ │ └── Detach.scala ├── db │ ├── build.sbt │ └── src │ │ └── main │ │ └── scala │ │ └── name │ │ └── aloise │ │ └── db │ │ └── connector │ │ └── DatabaseConnector.scala ├── models │ ├── build.sbt │ └── src │ │ └── main │ │ └── scala │ │ └── name │ │ └── aloise │ │ └── models │ │ ├── User.scala │ │ ├── WithId.scala │ │ └── package.scala └── services │ ├── build.sbt │ └── src │ └── main │ └── scala │ └── name │ └── aloise │ └── service │ ├── DoobieServiceHelper.scala │ └── UserService.scala ├── project ├── Dependencies.scala ├── Versions.scala ├── build.properties └── plugins.sbt └── src └── main ├── resources ├── application.conf ├── db │ └── data.sql ├── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico └── logback.xml └── scala └── name └── aloise ├── http └── api │ ├── FaviconHttpApi.scala │ ├── HealthHttpApi.scala │ ├── HttpApi.scala │ └── UserHttpApi.scala ├── server ├── AbstractAppServer.scala ├── AppServer.scala └── HttpServer.scala └── utils └── Logging.scala /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | style = defaultWithAlign 2 | maxColumn = 100 3 | 4 | continuationIndent.callSite = 2 5 | 6 | newlines { 7 | sometimesBeforeColonInMethodReturnType = false 8 | } 9 | 10 | align { 11 | arrowEnumeratorGenerator = false 12 | ifWhileOpenParen = false 13 | openParenCallSite = false 14 | openParenDefnSite = false 15 | } 16 | 17 | docstrings = JavaDoc 18 | 19 | rewrite { 20 | rules = [SortImports, RedundantBraces] 21 | redundantBraces.maxLines = 1 22 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # functional-web-service 2 | Scala, http4s, Cats, Cats-Effects, Doobie with bells and whistles. 3 | 4 | Project Goals: 5 | 6 | Create a usable functional prototype with the best Scala functional practices. Implements a sample REST service. 7 | 8 | Implementation should include: 9 | 1) User authentication with cookie/token based access control 10 | 2) User authorization and flexible permissions 11 | 3) Abstract DB access layer 12 | 4) Extensive tests 13 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | addCompilerPlugin("org.scalameta" %% "paradise" % "3.0.0-M11" cross CrossVersion.full) 2 | addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.8") 3 | 4 | scalaVersion := "2.12.8" 5 | 6 | scalacOptions ++= Seq( 7 | "-encoding", "UTF-8", 8 | "-feature", 9 | "-unchecked", 10 | "-Ypartial-unification", 11 | "-Xfatal-warnings", 12 | "-language:higherKinds", 13 | "-Xplugin-require:macroparadise" 14 | ) 15 | 16 | scalacOptions in (Compile, console) ~= (_ filterNot (_ contains "paradise")) 17 | 18 | resolvers += Resolver.sonatypeRepo("releases") 19 | 20 | lazy val common = (project in file("modules/common")).settings( 21 | libraryDependencies := Dependencies.Common 22 | ) 23 | 24 | lazy val db = (project in file("modules/db")).settings( 25 | libraryDependencies := Dependencies.DB 26 | ) 27 | 28 | lazy val models = (project in file("modules/models")).settings( 29 | libraryDependencies := Dependencies.Common 30 | ) 31 | 32 | lazy val services = (project in file("modules/services")) 33 | .dependsOn(common, models, db) 34 | .settings( 35 | libraryDependencies := Dependencies.Services 36 | ) 37 | 38 | lazy val root = (project in file(".")) 39 | .enablePlugins(BuildInfoPlugin, JavaServerAppPackaging, GraalVMNativeImagePlugin) 40 | .dependsOn(services) 41 | .settings( 42 | name := "functional-web-service", 43 | version := "1.0", 44 | graalVMNativeImageOptions ++= Seq("-da"), 45 | libraryDependencies ++= Dependencies.Server, 46 | buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, "revision" -> scala.sys.process.Process("git rev-parse HEAD").!!.trim), 47 | buildInfoPackage := "name.aloise.build" 48 | ) 49 | 50 | 51 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | localdb: 4 | image: postgres:11.2 5 | ports: 6 | - 5432:5432 7 | environment: 8 | - POSTGRES_USER=username 9 | - POSTGRES_PASSWORD=password 10 | - POSTGRES_DB=database 11 | volumes: 12 | - ./src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/init.sql 13 | 14 | -------------------------------------------------------------------------------- /modules/common/src/main/scala/name/aloise/core/Dep.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.core 2 | 3 | import cats.{FlatMap, Functor, Id} 4 | import name.aloise.core.utils.hlist.Detach 5 | import shapeless._ 6 | import shapeless.ops.hlist.Union 7 | import scala.language.higherKinds 8 | 9 | trait DepT[F[_], A] { self => 10 | 11 | type Env <: HList 12 | 13 | protected def runWithEnv(environment: Env): F[A] 14 | 15 | def run[IN](single: IN)(implicit ev: (IN :: HNil) =:= Env, ne: IN =:!= HList): F[A] = 16 | runWithEnv(ev(single :: HNil)) 17 | 18 | def run[E <: Product](env: E)(implicit gen: Generic.Aux[E, Env]): F[A] = 19 | runWithEnv(gen.to(env)) 20 | 21 | def run(implicit ev: HNil =:= Env): F[A] = 22 | runWithEnv(ev(HNil)) 23 | 24 | def map[B](f: A => B)(implicit functorF: Functor[F], dist: IsDistinctConstraint[Env]): DepT.Aux[Env, F, B] = new DepT[F, B] { 25 | override type Env = self.Env 26 | override def runWithEnv(environment: Env): F[B] = functorF.map(self.runWithEnv(environment))(f) 27 | } 28 | 29 | def flatMap[B, Env2 <: HList, CombinedEnv <: HList](f: A => DepT.Aux[Env2, F, B])( 30 | implicit unionOp: Union.Aux[Env, Env2, CombinedEnv], 31 | detach: Detach[Env, Env2, CombinedEnv], 32 | flatMapF: FlatMap[F]): DepT.Aux[CombinedEnv, F, B] = new DepT[F, B] { 33 | 34 | override type Env = CombinedEnv 35 | 36 | override def runWithEnv(environment: CombinedEnv): F[B] = { 37 | val (env1, env2) = detach(environment) 38 | flatMapF.flatMap(self.runWithEnv(env1))(a => f(a).runWithEnv(env2)) 39 | } 40 | } 41 | } 42 | 43 | object DepT { 44 | type Aux[E, F[_], A] = DepT[F, A] { type Env = E } 45 | 46 | def pure[F[_], A](value: => F[A]): DepT.Aux[HNil, F, A] = new DepT[F, A] { 47 | override type Env = HNil 48 | override def runWithEnv(env: Env): F[A] = value 49 | } 50 | 51 | def inject[F[_]: FlatMap, E, A](reader: E => F[A]): DepT.Aux[E :: HNil, F, A] = new DepT[F, A] { 52 | override type Env = E :: HNil 53 | override def runWithEnv(env: Env): F[A] = reader(env.head) 54 | } 55 | } 56 | 57 | trait Dep[A] extends DepT[Id, A] 58 | 59 | object Dep { 60 | 61 | type Aux[Req0, A0] = DepT.Aux[Req0, cats.Id, A0] 62 | 63 | def pure[A](value: => A) = DepT.pure[Id, A](value) 64 | def inject[E, A](reader: E => A) = DepT.inject[Id, E, A](reader) 65 | 66 | } 67 | -------------------------------------------------------------------------------- /modules/common/src/main/scala/name/aloise/core/utils/hlist/Detach.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.core.utils.hlist 2 | 3 | import shapeless.{::, HList, HNil} 4 | import shapeless.ops.hlist.{FilterNot, Remove} 5 | 6 | /** 7 | * Splitting type C into A and B 8 | * 9 | * @tparam L left side 10 | * @tparam R right side 11 | * @tparam C combined 12 | */ 13 | trait Detach[L, R, C] { 14 | def apply(c: C): (L, R) 15 | } 16 | 17 | 18 | trait LowPriorityDetach { 19 | implicit def hnilDetach[M <: HList]: Detach[HNil, M, M] = (c: M) => (HNil, c) 20 | } 21 | 22 | trait MediumPriorityDetach { 23 | implicit def hlistDetach[A, T <: HList, M <: HList, U <: HList]( 24 | implicit detachedTail: Detach[T, M, U], 25 | filtered: FilterNot.Aux[M, A, M] 26 | ): Detach[A :: T, M, A :: U] = (c: A :: U) => { 27 | val (tail, data) = detachedTail(c.tail) 28 | (c.head :: tail, data) 29 | } 30 | } 31 | 32 | object Detach extends MediumPriorityDetach with LowPriorityDetach { 33 | 34 | def apply[A, B, C](implicit ev: Detach[A, B, C]): Detach[A, B, C] = ev 35 | 36 | implicit def hlistDetachUnfiltered[H, T <: HList, M <: HList, MR <: HList, U <: HList]( 37 | implicit removed: Remove.Aux[M, H, (H, MR)], 38 | detached: Detach[T, MR, U] 39 | ): Detach[H :: T, M, H :: U] = (c: H :: U) => { 40 | val (t, mr) = detached(c.tail) 41 | (c.head :: t, removed.reinsert((c.head, mr))) 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /modules/db/build.sbt: -------------------------------------------------------------------------------- 1 | name := "functional-web-services-db" 2 | 3 | -------------------------------------------------------------------------------- /modules/db/src/main/scala/name/aloise/db/connector/DatabaseConnector.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.db.connector 2 | import cats.effect.{Async, ContextShift, Resource} 3 | import doobie.hikari.HikariTransactor 4 | import doobie.util.ExecutionContexts 5 | import doobie.util.transactor.Transactor 6 | 7 | trait DatabaseConnector { 8 | def open[F[_] : Async : ContextShift](configurationF: DatabaseConfiguration): Resource[F, Transactor[F]] = 9 | for { 10 | configuration <- Resource.pure(configurationF) 11 | connectionEC <- ExecutionContexts.fixedThreadPool[F](configuration.connectionPoolSize) // our connect EC 12 | transactionEC <- ExecutionContexts.cachedThreadPool[F] // our transaction TE 13 | hikariTransactor <- HikariTransactor.newHikariTransactor[F]( 14 | driverClassName = configuration.driverClassName, 15 | url = configuration.jdbcURL, 16 | user = configuration.user, 17 | pass = configuration.password, 18 | connectionEC, 19 | transactionEC 20 | ) 21 | _ <- Resource.liftF( 22 | hikariTransactor.configure(ds => implicitly[Async[F]].pure { 23 | ds.setMaximumPoolSize(configuration.maxPoolSize) 24 | ds.setMinimumIdle(configuration.minPoolSize) 25 | }) 26 | ) 27 | } yield hikariTransactor 28 | } 29 | 30 | object DatabaseConnector extends DatabaseConnector 31 | 32 | case class DatabaseConfiguration( 33 | jdbcURL: String, // jdbc:postgresql://host/database 34 | driverClassName: String, // "org.postgresql.Driver" 35 | user: String, 36 | password: String, 37 | connectionPoolSize: Int = 64, 38 | maxPoolSize: Int = 64, 39 | minPoolSize: Int = 8 40 | ) -------------------------------------------------------------------------------- /modules/models/build.sbt: -------------------------------------------------------------------------------- 1 | name := "functional-web-service-model" 2 | -------------------------------------------------------------------------------- /modules/models/src/main/scala/name/aloise/models/User.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.models 2 | 3 | 4 | final case class UserId(value: Int) extends Id[User, UserId] 5 | 6 | case class User(id: UserId, email: Email) extends WithId[User, UserId] -------------------------------------------------------------------------------- /modules/models/src/main/scala/name/aloise/models/WithId.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.models 2 | 3 | 4 | trait Id[OBJ, X <: Id[OBJ, X]] { self: X => 5 | def value: Int 6 | } 7 | 8 | trait WithId[OBJ, X <: Id[OBJ, X]] { self: OBJ => 9 | def id: X 10 | } 11 | -------------------------------------------------------------------------------- /modules/models/src/main/scala/name/aloise/models/package.scala: -------------------------------------------------------------------------------- 1 | package name.aloise 2 | import java.util.regex.Pattern 3 | 4 | import eu.timepit.refined.api.Refined 5 | 6 | package object models { 7 | 8 | case class MatchesEmail() 9 | 10 | object MatchesEmail { 11 | // I don't want to use the RFC822 compatible one here 12 | private val regex = Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE) 13 | 14 | import eu.timepit.refined.api.Validate 15 | 16 | implicit val emailValidate: Validate[String, MatchesEmail] = 17 | Validate.fromPredicate( 18 | str => regex.matcher(str).matches(), 19 | str => s"$str is not a value email", 20 | MatchesEmail() 21 | ) 22 | } 23 | 24 | type Email = String Refined MatchesEmail 25 | 26 | final case class Password(value: String) extends AnyVal 27 | 28 | } 29 | -------------------------------------------------------------------------------- /modules/services/build.sbt: -------------------------------------------------------------------------------- 1 | name := "functional-web-service-service" 2 | 3 | addCompilerPlugin("org.scalameta" %% "paradise" % "3.0.0-M11" cross CrossVersion.full) -------------------------------------------------------------------------------- /modules/services/src/main/scala/name/aloise/service/DoobieServiceHelper.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.service 2 | 3 | import doobie.util.Meta 4 | import name.aloise.models.UserId 5 | 6 | trait DoobieServiceHelper { 7 | private implicit val doobieUserIdMeta: Meta[UserId] = Meta[Int] 8 | .imap(UserId)(_.value) 9 | } 10 | -------------------------------------------------------------------------------- /modules/services/src/main/scala/name/aloise/service/UserService.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.service 2 | 3 | import name.aloise.models._ 4 | import cats.tagless._ 5 | import cats._ 6 | import cats.effect._ 7 | import cats.effect.concurrent.{MVar, Ref} 8 | import cats.implicits._ 9 | import doobie._ 10 | import doobie.implicits._ 11 | import doobie.util.transactor.Transactor 12 | import name.aloise.service.UserService.UserDraft 13 | 14 | import scala.util.Random 15 | 16 | trait UserService[F[_]] { 17 | def login(email: Email, password: Password): F[Option[User]] 18 | def create(user: UserDraft): F[User] 19 | def remove(userId: UserId): F[Boolean] 20 | def update(user: User): F[Boolean] 21 | def updatePassword(userId: UserId, password: Password): F[Boolean] 22 | def get(userId: UserId): F[Option[User]] 23 | def insertRandom(email: Long => String): F[User] 24 | } 25 | 26 | trait DoobieUserService extends DoobieServiceHelper { 27 | import doobie.refined.implicits._ 28 | import eu.timepit.refined._ 29 | import eu.timepit.refined.api.Refined 30 | import eu.timepit.refined.auto._ 31 | import eu.timepit.refined.numeric._ 32 | 33 | case class UserRecord(user: User, password: Password) 34 | 35 | def userServiceDoobieImpl[F[_]: Sync](transactor: Transactor[F]): UserService[F] = 36 | new UserService[F] { 37 | 38 | private object Transactions { 39 | 40 | def login(email: Email, password: Password) = 41 | sql"SELECT id, email FROM users WHERE (email = ${email.value}) AND (password = crypt(${password.value}, password))".query[User] 42 | 43 | def get(id: UserId) = 44 | sql"""SELECT id, email FROM users WHERE id = $id""".query[User] 45 | 46 | def create(email: Email, password: Password) = 47 | sql"INSERT INTO users(email, password) VALUES (${email.value}, crypt(${password.value}, gen_salt('bf')))".update 48 | 49 | def remove(id: UserId) = 50 | sql"DELETE FROM users WHERE id = ${id.value}".update 51 | 52 | def updateEmail(userId: UserId, email: Email) = 53 | sql"UPDATE users SET email = ${email.value} WHERE id = ${userId.value}".update 54 | 55 | def updatePassword(userId: UserId, password: Password) = 56 | sql"UPDATE users SET password = crypt(${password.value}, gen_salt('bf'))) WHERE id = ${userId.value}".update 57 | } 58 | 59 | override def login(email: Email, password: Password): F[Option[User]] = 60 | Transactions.login(email, password).option.transact(transactor) 61 | 62 | override def create(user: UserDraft): F[User] = 63 | (for { 64 | userId <- Transactions 65 | .create(user.email, user.password) 66 | .withUniqueGeneratedKeys[UserId]("id") 67 | user <- Transactions.get(userId).unique 68 | } yield user).transact(transactor) 69 | 70 | override def get(userId: UserId): F[Option[User]] = 71 | Transactions.get(userId).option.transact(transactor) 72 | 73 | override def remove(userId: UserId): F[Boolean] = 74 | Transactions.remove(userId).run.map(_>0).transact(transactor) 75 | 76 | override def updatePassword(userId: UserId, password: Password): F[Boolean] = 77 | Transactions.updatePassword(userId, password).run.map(_>0).transact(transactor) 78 | 79 | override def update(user: User): F[Boolean] = 80 | Transactions.updateEmail(user.id, user.email).run.map(_>0).transact(transactor) 81 | 82 | def insertRandom(emailGen: Long => String): F[User] = { 83 | val rnd = Random.nextLong() 84 | val Right(email) = refineV[MatchesEmail](emailGen(rnd)) 85 | create(UserDraft(email, Password("pass" + rnd))) 86 | } 87 | } 88 | 89 | def userServiceMemoryImpl[F[_] : Concurrent: Sync](usersRef: Ref[F, Map[UserId, UserRecord]]): UserService[F] = new UserService[F] { 90 | private val F = implicitly[Sync[F]] 91 | private implicit def orderingUserId(implicit ord: Ordering[Int]): Ordering[UserId] = 92 | (x: UserId, y: UserId) => ord.compare(x.value, y.value) 93 | 94 | 95 | override def login(email: Email, password: Password): F[Option[User]] = 96 | for { 97 | users <- usersRef.get 98 | userOpt = users.valuesIterator.find(usr => ( usr.user.email == email ) && (usr.password == password)) 99 | } yield userOpt.map(_.user) 100 | 101 | override def create(userDraft: UserDraft): F[User] = 102 | for { 103 | users <- usersRef.get 104 | existing = users.exists( _._2.user.email == userDraft.email) 105 | _ <- if(existing) F.raiseError(new IllegalArgumentException("User exists")) else F.pure(Unit) 106 | maxId = if(users.isEmpty) 0 else users.keysIterator.max.value 107 | newId = UserId(maxId+1) 108 | user = User(newId, userDraft.email) 109 | updatedUsers = users + ( newId -> UserRecord(user, userDraft.password) ) 110 | _ <- usersRef.set(updatedUsers) 111 | 112 | } yield user 113 | 114 | override def remove(userId: UserId): F[Boolean] = 115 | for { 116 | users <- usersRef.get 117 | exists = users.contains(userId) 118 | _ <- usersRef.set(users - userId) 119 | } yield exists 120 | 121 | override def update(user: User): F[Boolean] = 122 | for { 123 | users <- usersRef.get 124 | existing = users.get(user.id) 125 | _ <- existing.map(rec => usersRef.set(users + (user.id -> rec.copy(user = user)))).getOrElse(implicitly[Sync[F]].unit) 126 | } yield existing.nonEmpty 127 | 128 | override def get(userId: UserId): F[Option[User]] = 129 | for { 130 | users <- usersRef.get 131 | } yield users.get(userId).map(_.user) 132 | 133 | override def updatePassword(userId: UserId, password: Password): F[Boolean] = 134 | for { 135 | users <- usersRef.get 136 | existing = users.get(userId) 137 | _ <- existing.map(rec => usersRef.set(users + (userId -> rec.copy(password = password)))).getOrElse(implicitly[Sync[F]].unit) 138 | } yield existing.nonEmpty 139 | 140 | def insertRandom(emailGen: Long => String): F[User] = { 141 | val rnd = Random.nextLong() 142 | val Right(email) = refineV[MatchesEmail](emailGen(rnd)) 143 | create(UserDraft(email, Password("pass" + rnd))) 144 | } 145 | 146 | } 147 | } 148 | 149 | object UserService extends DoobieUserService { 150 | case class UserDraft(email: Email, password: Password) 151 | case class UserLogin(email: Email, password: Password) 152 | } 153 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | 5 | val Common = Seq[ModuleID]( 6 | "org.typelevel" %% "cats-core" % Versions.cats, 7 | "org.typelevel" %% "cats-free" % Versions.cats, 8 | "org.typelevel" %% "cats-effect" % Versions.catsEffects, 9 | "eu.timepit" %% "refined" % Versions.refined, 10 | "eu.timepit" %% "refined-cats" % Versions.refined, 11 | "org.typelevel" %% "cats-tagless-macros" % Versions.catsTagless, 12 | "org.slf4j" % "slf4j-simple" % "1.7.21" 13 | ) 14 | 15 | val Server = Common ++ Seq[ModuleID]( 16 | "org.http4s" %% "http4s-blaze-server" % Versions.http4s, 17 | "org.http4s" %% "http4s-dsl" % Versions.http4s, 18 | "org.http4s" %% "http4s-circe" % Versions.http4s, 19 | "io.circe" %% "circe-generic" % Versions.circe, 20 | "io.circe" %% "circe-refined" % Versions.circe, 21 | "io.circe" %% "circe-generic-extras" % Versions.circe, 22 | "io.chrisdavenport" %% "log4cats-core" % Versions.log4cats, 23 | "io.chrisdavenport" %% "log4cats-slf4j" % Versions.log4cats, 24 | "com.github.pureconfig" %% "pureconfig" % Versions.pureconfig, 25 | "com.github.pureconfig" %% "pureconfig-cats-effect" % Versions.pureconfig, 26 | "io.github.jmcardon" %% "tsec-common" % Versions.tsecV, 27 | "io.github.jmcardon" %% "tsec-password" % Versions.tsecV, 28 | "io.github.jmcardon" %% "tsec-cipher-jca" % Versions.tsecV, 29 | "io.github.jmcardon" %% "tsec-cipher-bouncy" % Versions.tsecV, 30 | "eu.timepit" %% "refined-pureconfig" % Versions.refined, 31 | "com.github.vladimir-bukhtoyarov" % "bucket4j-core" % Versions.bucket4j 32 | ) 33 | 34 | val DB = Common ++ Seq[ModuleID]( 35 | "org.tpolecat" %% "doobie-core" % Versions.doobie, 36 | // And add any of these as needed 37 | // "org.tpolecat" %% "doobie-h2" % Versions.doobie, // H2 driver 1.4.197 + type mappings. 38 | "org.tpolecat" %% "doobie-hikari" % Versions.doobie, // HikariCP transactor. 39 | "org.tpolecat" %% "doobie-postgres" % Versions.doobie, 40 | "org.tpolecat" %% "doobie-refined" % Versions.doobie 41 | ) 42 | 43 | val Services = Common 44 | } 45 | -------------------------------------------------------------------------------- /project/Versions.scala: -------------------------------------------------------------------------------- 1 | object Versions { 2 | val http4s = "0.20.0-M5" 3 | val circe = "0.11.1" 4 | val doobie = "0.7.0-M2" 5 | val log4cats = "0.3.0-M2" 6 | val pureconfig = "0.10.2" 7 | val tsecV = "0.0.1-M11" 8 | val cats = "1.6.0" 9 | val catsEffects = "1.2.0" 10 | val refined = "0.9.4" 11 | val catsTagless = "0.1.0" 12 | val bucket4j = "4.3.0" 13 | } -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.8 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.17") 2 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") 3 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | db { 2 | configuration { 3 | jdbc-url: "jdbc:postgresql://localhost:5432/database" 4 | driver-class-name: "org.postgresql.Driver" 5 | user: "username" 6 | password: "password" 7 | connection-pool-size: 64 8 | max-pool-size: 32 9 | min-pool-size: 8 10 | } 11 | } -------------------------------------------------------------------------------- /src/main/resources/db/data.sql: -------------------------------------------------------------------------------- 1 | create extension if not exists pgcrypto; 2 | 3 | create table users( 4 | id SERIAL PRIMARY KEY, 5 | password VARCHAR(64) NOT NULL, 6 | email VARCHAR(64) NOT NULL UNIQUE 7 | ); 8 | 9 | -------------------------------------------------------------------------------- /src/main/resources/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloise/functional-web-service/703fb846cd4d2a3f469a0de3d61b4e27e82215b0/src/main/resources/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/main/resources/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloise/functional-web-service/703fb846cd4d2a3f469a0de3d61b4e27e82215b0/src/main/resources/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/main/resources/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloise/functional-web-service/703fb846cd4d2a3f469a0de3d61b4e27e82215b0/src/main/resources/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /src/main/resources/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloise/functional-web-service/703fb846cd4d2a3f469a0de3d61b4e27e82215b0/src/main/resources/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /src/main/resources/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloise/functional-web-service/703fb846cd4d2a3f469a0de3d61b4e27e82215b0/src/main/resources/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /src/main/resources/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloise/functional-web-service/703fb846cd4d2a3f469a0de3d61b4e27e82215b0/src/main/resources/favicon/favicon.ico -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/scala/name/aloise/http/api/FaviconHttpApi.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.http.api 2 | 3 | import cats.effect.{Async, ContextShift} 4 | import org.http4s.{HttpRoutes, StaticFile} 5 | 6 | import scala.concurrent.ExecutionContext 7 | 8 | case class FaviconHttpApi[F[_] : Async : ContextShift](blockingFileAccessEC: ExecutionContext) extends HttpApi[F] { 9 | override val routes: HttpRoutes[F] = HttpRoutes.of { 10 | case req @ GET -> Root / "favicon.ico" => 11 | StaticFile.fromResource("/favicon/favicon-32x32.png", blockingFileAccessEC, Some(req)).getOrElseF(NotFound()) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/scala/name/aloise/http/api/HealthHttpApi.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.http.api 2 | 3 | import cats.effect.Async 4 | import io.circe.ObjectEncoder 5 | import io.circe.generic.semiauto.deriveEncoder 6 | import io.circe.syntax._ 7 | import name.aloise.build.BuildInfo 8 | import name.aloise.http.api.HealthHttpApi.Health 9 | import org.http4s.HttpRoutes 10 | 11 | 12 | case class HealthHttpApi[F[_] : Async]() extends HttpApi[F] { 13 | 14 | import org.http4s.circe._ 15 | 16 | private val healthResponse = Ok(Health(BuildInfo.name, BuildInfo.version, BuildInfo.revision).asJson) 17 | 18 | override val routes: HttpRoutes[F] = HttpRoutes.of[F] { 19 | case GET -> Root => 20 | healthResponse 21 | } 22 | } 23 | 24 | case object HealthHttpApi { 25 | 26 | case class Health(service: String, version: String, revision: String, healthy: Boolean = true) 27 | 28 | case object Health { 29 | implicit val encoder: ObjectEncoder[Health] = deriveEncoder[Health] 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/main/scala/name/aloise/http/api/HttpApi.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.http.api 2 | 3 | import cats.effect.Async 4 | import org.http4s.HttpRoutes 5 | import org.http4s.dsl.Http4sDsl 6 | 7 | abstract class HttpApi[F[_] : Async] extends Http4sDsl[F] { 8 | def routes: HttpRoutes[F] 9 | } -------------------------------------------------------------------------------- /src/main/scala/name/aloise/http/api/UserHttpApi.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.http.api 2 | 3 | import cats.effect.Async 4 | import cats.implicits._ 5 | import io.circe.{Decoder, Encoder, HCursor, Json} 6 | import name.aloise.models.{Email, Password, User, UserId} 7 | import name.aloise.service.UserService 8 | import org.http4s.HttpRoutes 9 | import io.circe.syntax._ 10 | import name.aloise.service.UserService.{UserDraft, UserLogin} 11 | 12 | import scala.util.Try 13 | import org.http4s.circe._ 14 | 15 | case class UserHttpApi[F[_] : Async](service: UserService[F]) extends HttpApi[F] { 16 | import UserHttpApiHelper._ 17 | 18 | implicit val userDraftEntityDecoder = jsonOf[F, UserDraft] 19 | implicit val loginDraftEntityDecoder = jsonOf[F, UserLogin] 20 | 21 | override val routes: HttpRoutes[F] = HttpRoutes.of[F] { 22 | case GET -> Root / UserIdVar(userId) => 23 | Ok(service.get(userId).map(_.asJson)) 24 | 25 | case GET -> Root / "random" => 26 | Ok(service.insertRandom(l => "test"+l+"@test.com").map(_.asJson)) 27 | 28 | case req @ POST -> Root => 29 | for { 30 | userDraft <- req.as[UserDraft] 31 | user <- service.create(userDraft) 32 | resp <- Ok(user.asJson) 33 | } yield resp 34 | 35 | case req @ POST -> Root / "login" => 36 | for { 37 | login <- req.as[UserLogin] 38 | user <- service.login(login.email, login.password) 39 | resp <- Ok(user.asJson) 40 | } yield resp 41 | } 42 | 43 | } 44 | 45 | object UserHttpApiHelper { 46 | 47 | import eu.timepit.refined.auto._ 48 | import io.circe.refined._ 49 | import io.circe.generic.extras.semiauto.{deriveUnwrappedDecoder, deriveUnwrappedEncoder} 50 | import io.circe.generic.semiauto._ 51 | 52 | implicit val passwordDecoder: Decoder[Password] = deriveUnwrappedDecoder[Password] 53 | implicit val passwordEncoder: Encoder[Password] = deriveUnwrappedEncoder[Password] 54 | 55 | implicit val userIdEncoder: Encoder[UserId] = (a: UserId) => Json.fromInt(a.value) 56 | implicit val userIdDecoder: Decoder[UserId] = (c: HCursor) => c.as[Int].map(UserId) 57 | implicit val userEncoder: Encoder[User] = deriveEncoder[User] 58 | implicit val userDecoder: Decoder[User] = deriveDecoder[User] 59 | implicit val userDraftDecoder: Decoder[UserDraft] = deriveDecoder[UserDraft] 60 | implicit val userLoginDecoder: Decoder[UserLogin] = deriveDecoder[UserLogin] 61 | 62 | object UserIdVar { 63 | def unapply(str: String): Option[UserId] = 64 | Try(str.toInt).map(UserId).toOption 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /src/main/scala/name/aloise/server/AbstractAppServer.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.server 2 | import java.util.concurrent.Executors 3 | 4 | import cats.effect._ 5 | import cats.effect.concurrent.Ref 6 | import name.aloise.db.connector.{DatabaseConfiguration, DatabaseConnector} 7 | import name.aloise.http.api.{FaviconHttpApi, HealthHttpApi, UserHttpApi} 8 | import name.aloise.models.UserId 9 | import name.aloise.service.UserService 10 | import org.http4s.HttpRoutes 11 | import org.http4s.server.{Router, Server} 12 | 13 | import scala.concurrent.ExecutionContext 14 | 15 | abstract class AbstractAppServer[F[_]: Async] { 16 | 17 | private def routes( 18 | userService: UserService[F] 19 | )( 20 | blockingFilesAccessEC: ExecutionContext 21 | )(implicit CF: ContextShift[F]): HttpRoutes[F] = Router[F]( 22 | "/health" -> HealthHttpApi[F]().routes, 23 | "/" -> FaviconHttpApi[F](blockingFilesAccessEC).routes, 24 | "/user" -> UserHttpApi[F](userService).routes 25 | ) 26 | 27 | private val blockingFilesAccessEC = 28 | Resource.make( 29 | implicitly[Sync[F]].delay( 30 | ExecutionContext.fromExecutorService( 31 | Executors.newFixedThreadPool(Runtime.getRuntime.availableProcessors())) 32 | ) 33 | )(ec => implicitly[Sync[F]].delay(ec.shutdown())) 34 | 35 | private def getDoobieUserService(dbConfig: DatabaseConfiguration)( 36 | implicit CF: ContextShift[F]): Resource[F, UserService[F]] = 37 | for { 38 | transactor <- DatabaseConnector.open(dbConfig) 39 | service = UserService.userServiceDoobieImpl(transactor) 40 | } yield service 41 | 42 | protected def serverResource( 43 | httpConfig: HttpServerConfiguration, 44 | dbConfig: DatabaseConfiguration)( 45 | implicit CF: ContextShift[F], T: Timer[F], CE: ConcurrentEffect[F]): Resource[F, Server[F]] = 46 | for { 47 | blockingEC <- blockingFilesAccessEC 48 | // userServices <- getDoobieUserService(dbConfig) 49 | userRef <- Resource.liftF(Ref.of[F, Map[UserId, UserService.UserRecord]](Map.empty)) 50 | userServices = UserService.userServiceMemoryImpl[F](userRef) 51 | allRoutes = routes(userServices)(blockingEC) 52 | server <- HttpServer(allRoutes)(httpConfig).server 53 | } yield server 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/scala/name/aloise/server/AppServer.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.server 2 | 3 | import cats.effect._ 4 | import name.aloise.db.connector.DatabaseConfiguration 5 | import name.aloise.utils.Logging 6 | import pureconfig.module.catseffect._ 7 | 8 | object AppServer extends AbstractAppServer[IO] with IOApp with Logging[IO] { 9 | import pureconfig.generic.auto._ 10 | 11 | private val dbConfig = Resource.liftF(loadConfigF[IO, DatabaseConfiguration]("db.configuration")) 12 | private val httpConfig = HttpServerConfiguration() 13 | 14 | def run(args: List[String]): IO[ExitCode] = { 15 | 16 | dbConfig flatMap (serverResource(httpConfig, _)) use { srv => 17 | for { 18 | _ <- log.info("Server is Running on " + srv.address) 19 | _ <- IO(Console.println("Press a key to exit.")) 20 | _ <- IO(scala.io.StdIn.readLine()) 21 | _ <- log.info("Shutting Down on key press") 22 | } yield ExitCode.Success 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/name/aloise/server/HttpServer.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.server 2 | 3 | import cats.effect._ 4 | import org.http4s.HttpRoutes 5 | import org.http4s.implicits._ 6 | import org.http4s.server.Server 7 | import org.http4s.server.blaze.BlazeServerBuilder 8 | 9 | final case class HttpServerConfiguration(host: String = "localhost", port: Int = 8080) 10 | 11 | case class HttpServer[F[_]: ConcurrentEffect: Timer](services: HttpRoutes[F])(config: HttpServerConfiguration) { 12 | 13 | val server: Resource[F, Server[F]] = BlazeServerBuilder[F] 14 | .bindHttp(config.port, config.host) 15 | .withHttpApp(services.orNotFound) 16 | .withNio2(true) 17 | .withWebSockets(false) 18 | .resource 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/name/aloise/utils/Logging.scala: -------------------------------------------------------------------------------- 1 | package name.aloise.utils 2 | 3 | import cats.effect.{IO, Sync} 4 | import io.chrisdavenport.log4cats.Logger 5 | import io.chrisdavenport.log4cats.slf4j.Slf4jLogger 6 | 7 | trait Logging[F[_]] { 8 | @inline 9 | def log(implicit F: Sync[F]) = Logger[F](Slf4jLogger.getLogger[F]) 10 | } 11 | --------------------------------------------------------------------------------