├── .gitignore ├── .scalafmt.conf ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── build.sbt ├── docker-compose.yml ├── project ├── build.properties └── plugins.sbt └── src ├── main ├── resources │ ├── application.conf │ └── db │ │ └── migration │ │ ├── V1__Create_profiles_table.sql │ │ └── V2__Create_auth_table.sql └── scala │ └── me │ └── archdev │ └── restapi │ ├── Boot.scala │ ├── core │ ├── auth │ │ ├── AuthDataStorage.scala │ │ ├── AuthDataTable.scala │ │ └── AuthService.scala │ ├── package.scala │ └── profiles │ │ ├── UserProfileService.scala │ │ ├── UserProfileStorage.scala │ │ └── UserProfileTable.scala │ ├── http │ ├── HttpRoute.scala │ └── routes │ │ ├── AuthRoute.scala │ │ └── ProfileRoute.scala │ └── utils │ ├── Config.scala │ ├── MonadTransformers.scala │ ├── SecurityDirectives.scala │ └── db │ ├── DatabaseConnector.scala │ └── DatabaseMigrationManager.scala └── test ├── resources └── application.conf └── scala └── me └── archdev ├── BaseServiceTest.scala ├── BootIT.scala ├── core ├── auth │ ├── AuthDataStorageSpec.scala │ └── AuthServiceTest.scala └── profiles │ ├── UserProfileServiceTest.scala │ └── UserProfileStorageSpec.scala ├── http ├── HttpRouteTest.scala └── routes │ ├── AuthRouteTest.scala │ └── ProfileRouteTest.scala └── utils └── InMemoryPostgresStorage.scala /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # sbt specific 5 | .cache 6 | .history 7 | .lib/ 8 | dist/* 9 | target/ 10 | lib_managed/ 11 | src_managed/ 12 | project/boot/ 13 | project/plugins/project/ 14 | 15 | # Scala-IDE specific 16 | .idea/ 17 | rest.iml 18 | .DS_Store -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | style = defaultWithAlign 2 | 3 | project.git = true 4 | maxColumn = 120 5 | danglingParentheses = true 6 | indentOperator = spray 7 | project.excludeFilters = [".*\\.sbt"] 8 | rewrite.rules = [RedundantBraces, RedundantParens, AvoidInfix, SortImports] 9 | spaces.inImportCurlyBraces = true 10 | unindentTopLevelOperators = true -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | jdk: openjdk8 4 | 5 | scala: 6 | - 2.13.5 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Arthur Kushka 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 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 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: target/universal/stage/bin/akka-http-rest -Dhttp.port=${PORT} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Akka Slick REST service template 2 | ========================= 3 | 4 | Goal of example is to show how create reactive REST services on Lightbend stack with Akka and Slick. 5 | 6 | Example contains complete REST service for entity interaction. 7 | 8 | ### Features: 9 | * CRUD operations 10 | * Entity partial updates 11 | * CORS support 12 | * Authentication with *JWT* tokens 13 | * Test coverage with *ScalaTest* 14 | * Migrations with *FlyWay* 15 | * Ready for *Docker* 16 | * Testing with in-memory postgres instance that launch automatically 17 | * *HikaryCP* as connection pool 18 | 19 | ## Requirements 20 | * JDK 8 (e.g. [http://www.oracle.com/technetwork/java/javase/downloads/index.html](http://www.oracle.com/technetwork/java/javase/downloads/index.html)); 21 | * sbt ([http://www.scala-sbt.org/release/docs/Getting-Started/Setup.html](http://www.scala-sbt.org/release/docs/Getting-Started/Setup.html)); 22 | 23 | ## Development guide 24 | This application is fully tested with Unit and IT tests. 25 | You don't need to launch server locally for development. 26 | My recommendation is to write a test before changes and work via TDD. 27 | To ensure that application working properly, you should run it: `sbt test`. 28 | 29 | ### Structure 30 | All business logic is located in `core` package, every package inside is related to some domain. 31 | Service classes contains high level logic that related to data manipulation, 32 | that means that service MUST NOT implement storaging and querying for the data. 33 | For storaging there are Storage classes that always have interface with two implementation, production one and 34 | in-memory one. That's needed to fasten tests of services and make it independent from each other. 35 | 36 | ### Code formatting 37 | There are [Scalafmt](https://scalameta.org/scalafmt/) integrated to the project. Its a opinionated code formatter that 38 | formats a code automatically instead of you. To use it, please run `sbt scalafmt` before commit or enable format on save 39 | in IntelijIdea (should be available in other editors too). 40 | 41 | ### Checking code coverage 42 | To generate code coverage report, please run: `sbt clean coverage test coverageReport`. 43 | Then you will have HTML pages with reports in `/target/scala-2.12/scoverage-report` 44 | 45 | ### Packaging 46 | Application packaging implemented via [sbt-native-packager](https://github.com/sbt/sbt-native-packager) plugin. 47 | Currently in `build.sbt` enabled two types: docker and universal. 48 | 49 | **Universal packager** 50 | To package application as a universal app, use: `sbt universal:packageBin`. 51 | Application zip archive will be generated in `/target/universal/` folder. 52 | 53 | **Docker packager** 54 | To package application as docker image, use `sbt docker:publishLocal`. 55 | It will generate and push application image into your local docker store. 56 | For information about publishing to external store, please, read [plugin documentation](http://www.scala-sbt.org/sbt-native-packager/formats/docker.html). 57 | 58 | ### Running 59 | If you want to launch application locally (its not recommended) you need to start Postgres instance locally and fulfill 60 | some env variables: 61 | - `JDBC_URL` - url to your database 62 | - `JDBC_USER` - database username 63 | - `JDBC_PASSWORD` - database password 64 | 65 | After that, just run `sbt run` and enjoy hacking. For better expirience you can use `sbt reStart` that will give you ability to 66 | restart application without restarting of sbt. 67 | 68 | ### Deployment on production 69 | Easiest way to deliver your application, is to do it with docker. Publish image into the store and then use 70 | docker-compose file with structure like in `docker-compose.yml`. 71 | 72 | ## Live example 73 | Application deployed on heroku and can be accessed by URL [http://akka-http-rest.herokuapp.com/](http://akka-http-rest.herokuapp.com/). 74 | First request can take some time, because heroku launch up project. 75 | You can see documentation for this example on [Apiary](http://docs.akkahttprest.apiary.io). 76 | 77 | ## Copyright 78 | Copyright (C) 2017 Arthur Kushka. 79 | Distributed under the MIT License. 80 | 81 | ## Contact 82 | Wanna ask me something or stay in touch? 83 | Follow my Twitter [@arhelmus](https://twitter.com/Arhelmus) 84 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "akka-http-rest" 2 | organization := "me.archdev" 3 | version := "1.0.0" 4 | scalaVersion := "2.13.5" 5 | 6 | resolvers += "jitpack" at "https://jitpack.io" 7 | 8 | libraryDependencies ++= { 9 | val akkaV = "2.6.13" 10 | val akkaHttpV = "10.2.4" 11 | val scalaTestV = "3.2.5" 12 | val slickVersion = "3.3.3" 13 | val circeV = "0.12.3" 14 | val sttpV = "3.2.0" 15 | Seq( 16 | "com.typesafe.akka" %% "akka-actor" % akkaV, 17 | "com.typesafe.akka" %% "akka-stream" % akkaV, 18 | // HTTP server 19 | "com.typesafe.akka" %% "akka-http" % akkaHttpV, 20 | "com.typesafe.akka" %% "akka-http-core" % akkaHttpV, 21 | 22 | // Support of CORS requests, version depends on akka-http 23 | "ch.megard" %% "akka-http-cors" % "1.1.1", 24 | 25 | // SQL generator 26 | "com.typesafe.slick" %% "slick" % slickVersion, 27 | 28 | // Postgres driver 29 | "org.postgresql" % "postgresql" % "42.1.4", 30 | 31 | // Migration for SQL databases 32 | "org.flywaydb" % "flyway-core" % "4.2.0", 33 | 34 | // Connection pool for database 35 | "com.zaxxer" % "HikariCP" % "2.7.0", 36 | 37 | // Encoding decoding sugar, used in passwords hashing 38 | "com.github.fdietze.hasher" %% "hasher" % "75be8ed", 39 | 40 | // Parsing and generating of JWT tokens 41 | "com.pauldijou" %% "jwt-core" % "5.0.0", 42 | 43 | // Config file parser 44 | "com.github.pureconfig" %% "pureconfig" % "0.14.1", 45 | 46 | // JSON serialization library 47 | "io.circe" %% "circe-core" % circeV, 48 | "io.circe" %% "circe-generic" % circeV, 49 | "io.circe" %% "circe-parser" % circeV, 50 | 51 | // Sugar for serialization and deserialization in akka-http with circe 52 | "de.heikoseeberger" %% "akka-http-circe" % "1.36.0", 53 | 54 | // Validation library 55 | "com.wix" %% "accord-core" % "0.7.6", 56 | 57 | // Http client, used currently only for IT test 58 | "com.softwaremill.sttp.client3" %% "core" % sttpV % Test, 59 | "com.softwaremill.sttp.client3" %% "akka-http-backend" % sttpV % Test, 60 | 61 | "com.typesafe.akka" %% "akka-testkit" % akkaV % Test, 62 | "com.typesafe.akka" %% "akka-stream-testkit" % akkaV % Test, 63 | "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpV % Test, 64 | "org.scalatest" %% "scalatest" % scalaTestV % Test, 65 | "org.mockito" %% "mockito-scala-scalatest" % "1.16.32" % Test, 66 | "ru.yandex.qatools.embed" % "postgresql-embedded" % "2.10" % Test 67 | ) 68 | } 69 | 70 | enablePlugins(UniversalPlugin) 71 | enablePlugins(DockerPlugin) 72 | 73 | // Needed for Heroku deployment, can be removed 74 | enablePlugins(JavaAppPackaging) 75 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | apiserver: 4 | restart: always 5 | image: your.image.store:5000/services/akka-http-rest:1.0.0 6 | ports: 7 | - 80:9000 8 | environment: 9 | APP_SECRET: "some-secret-value" 10 | JDBC_URL: "jdbc:postgres://postgres:5432/postgres" 11 | JDBC_USER: "postgres" 12 | JDBC_PASSWORD: "database-password" 13 | postgres: 14 | image: postgres 15 | restart: always 16 | environment: 17 | POSTGRES_PASSWORD: "database-password" -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.4.9 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers += Classpaths.sbtPluginReleases 2 | 3 | addSbtPlugin("io.spray" %% "sbt-revolver" % "0.9.1") 4 | addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.7.6") 5 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1") 6 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | secret-key = "secret" 2 | secret-key = ${?SECRET_KEY} 3 | 4 | http { 5 | host = "0.0.0.0" 6 | port = 9000 7 | } 8 | 9 | database = { 10 | jdbc-url = "jdbc:postgresql://localhost/akka-http-rest" 11 | jdbc-url = ${?JDBC_URL} 12 | username = "postgres" 13 | username = ${?JDBC_USER} 14 | password = "test" 15 | password = ${?JDBC_PASSWORD} 16 | } -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__Create_profiles_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "profiles" ( 2 | "id" VARCHAR PRIMARY KEY, 3 | "first_name" VARCHAR NOT NULL, 4 | "last_name" VARCHAR NOT NULL 5 | ); -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2__Create_auth_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "auth" ( 2 | "id" VARCHAR PRIMARY KEY, 3 | "username" VARCHAR NOT NULL, 4 | "email" VARCHAR NOT NULL, 5 | "password" VARCHAR NOT NULL 6 | ); -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/Boot.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.Http 5 | import me.archdev.restapi.core.auth.{ AuthService, JdbcAuthDataStorage } 6 | import me.archdev.restapi.core.profiles.{ JdbcUserProfileStorage, UserProfileService } 7 | import me.archdev.restapi.http.HttpRoute 8 | import me.archdev.restapi.utils.Config 9 | import me.archdev.restapi.utils.db.{ DatabaseConnector, DatabaseMigrationManager } 10 | 11 | import scala.concurrent.ExecutionContext 12 | 13 | object Boot extends App { 14 | 15 | def startApplication() = { 16 | implicit val actorSystem = ActorSystem() 17 | implicit val executor: ExecutionContext = actorSystem.dispatcher 18 | 19 | val config = Config.load() 20 | 21 | new DatabaseMigrationManager( 22 | config.database.jdbcUrl, 23 | config.database.username, 24 | config.database.password 25 | ).migrateDatabaseSchema() 26 | 27 | val databaseConnector = new DatabaseConnector( 28 | config.database.jdbcUrl, 29 | config.database.username, 30 | config.database.password 31 | ) 32 | 33 | val userProfileStorage = new JdbcUserProfileStorage(databaseConnector) 34 | val authDataStorage = new JdbcAuthDataStorage(databaseConnector) 35 | 36 | val usersService = new UserProfileService(userProfileStorage) 37 | val authService = new AuthService(authDataStorage, config.secretKey) 38 | val httpRoute = new HttpRoute(usersService, authService, config.secretKey) 39 | 40 | Http().newServerAt(config.http.host, config.http.port).bind(httpRoute.route) 41 | } 42 | 43 | startApplication() 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/core/auth/AuthDataStorage.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.core.auth 2 | 3 | import me.archdev.restapi.core.AuthData 4 | import me.archdev.restapi.utils.db.DatabaseConnector 5 | import me.archdev.restapi.core.profiles.UserProfileStorage 6 | 7 | import scala.concurrent.{ ExecutionContext, Future } 8 | 9 | sealed trait AuthDataStorage { 10 | 11 | def findAuthData(login: String): Future[Option[AuthData]] 12 | 13 | def saveAuthData(authData: AuthData): Future[AuthData] 14 | 15 | } 16 | 17 | class JdbcAuthDataStorage( 18 | val databaseConnector: DatabaseConnector 19 | )(implicit executionContext: ExecutionContext) 20 | extends AuthDataTable 21 | with AuthDataStorage { 22 | 23 | import databaseConnector._ 24 | import databaseConnector.profile.api._ 25 | 26 | override def findAuthData(login: String): Future[Option[AuthData]] = 27 | db.run(auth.filter(d => d.username === login || d.email === login).result.headOption) 28 | 29 | override def saveAuthData(authData: AuthData): Future[AuthData] = 30 | db.run(auth.insertOrUpdate(authData)).map(_ => authData) 31 | 32 | } 33 | 34 | class InMemoryAuthDataStorage extends AuthDataStorage { 35 | 36 | private var state: Seq[AuthData] = Nil 37 | 38 | override def findAuthData(login: String): Future[Option[AuthData]] = 39 | Future.successful(state.find(d => d.username == login || d.email == login)) 40 | 41 | override def saveAuthData(authData: AuthData): Future[AuthData] = 42 | Future.successful { 43 | state = state :+ authData 44 | authData 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/core/auth/AuthDataTable.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.core.auth 2 | 3 | import me.archdev.restapi.core.AuthData 4 | import me.archdev.restapi.utils.db.DatabaseConnector 5 | 6 | private[auth] trait AuthDataTable { 7 | 8 | protected val databaseConnector: DatabaseConnector 9 | import databaseConnector.profile.api._ 10 | 11 | class AuthDataSchema(tag: Tag) extends Table[AuthData](tag, "auth") { 12 | def id = column[String]("id", O.PrimaryKey) 13 | def username = column[String]("username") 14 | def email = column[String]("email") 15 | def password = column[String]("password") 16 | 17 | def * = (id, username, email, password) <> ((AuthData.apply _).tupled, AuthData.unapply) 18 | } 19 | 20 | protected val auth = TableQuery[AuthDataSchema] 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/core/auth/AuthService.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.core.auth 2 | 3 | import java.util.UUID 4 | 5 | import me.archdev.restapi.core.{ AuthData, AuthToken, AuthTokenContent, UserId } 6 | import me.archdev.restapi.utils.MonadTransformers._ 7 | import com.roundeights.hasher.Implicits._ 8 | import pdi.jwt.{ Jwt, JwtAlgorithm } 9 | import io.circe.syntax._ 10 | import io.circe.generic.auto._ 11 | 12 | import scala.concurrent.{ ExecutionContext, Future } 13 | 14 | class AuthService( 15 | authDataStorage: AuthDataStorage, 16 | secretKey: String 17 | )(implicit executionContext: ExecutionContext) { 18 | 19 | def signIn(login: String, password: String): Future[Option[AuthToken]] = 20 | authDataStorage 21 | .findAuthData(login) 22 | .filterT(_.password == password.sha256.hex) 23 | .mapT(authData => encodeToken(authData.id)) 24 | 25 | def signUp(login: String, email: String, password: String): Future[AuthToken] = 26 | authDataStorage 27 | .saveAuthData(AuthData(UUID.randomUUID().toString, login, email, password.sha256.hex)) 28 | .map(authData => encodeToken(authData.id)) 29 | 30 | private def encodeToken(userId: UserId): AuthToken = 31 | Jwt.encode(AuthTokenContent(userId).asJson.noSpaces, secretKey, JwtAlgorithm.HS256) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/core/package.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi 2 | 3 | package object core { 4 | 5 | type UserId = String 6 | type AuthToken = String 7 | 8 | final case class AuthTokenContent(userId: UserId) 9 | 10 | final case class AuthData(id: UserId, username: String, email: String, password: String) { 11 | require(id.nonEmpty, "id.empty") 12 | require(username.nonEmpty, "username.empty") 13 | require(email.nonEmpty, "email.empty") 14 | require(password.nonEmpty, "password.empty") 15 | } 16 | 17 | final case class UserProfile(id: UserId, firstName: String, lastName: String) { 18 | require(id.nonEmpty, "firstName.empty") 19 | require(firstName.nonEmpty, "firstName.empty") 20 | require(lastName.nonEmpty, "lastName.empty") 21 | } 22 | 23 | final case class UserProfileUpdate(firstName: Option[String] = None, lastName: Option[String] = None) { 24 | def merge(profile: UserProfile): UserProfile = 25 | UserProfile(profile.id, firstName.getOrElse(profile.firstName), lastName.getOrElse(profile.lastName)) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/core/profiles/UserProfileService.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.core.profiles 2 | 3 | import me.archdev.restapi.core.{ UserProfile, UserProfileUpdate } 4 | import me.archdev.restapi.utils.MonadTransformers._ 5 | import scala.concurrent.{ ExecutionContext, Future } 6 | 7 | class UserProfileService( 8 | userProfileStorage: UserProfileStorage 9 | )(implicit executionContext: ExecutionContext) { 10 | 11 | def getProfiles(): Future[Seq[UserProfile]] = 12 | userProfileStorage.getProfiles() 13 | 14 | def getProfile(id: String): Future[Option[UserProfile]] = 15 | userProfileStorage.getProfile(id) 16 | 17 | def createProfile(profile: UserProfile): Future[UserProfile] = 18 | userProfileStorage.saveProfile(profile) 19 | 20 | def updateProfile(id: String, profileUpdate: UserProfileUpdate): Future[Option[UserProfile]] = 21 | userProfileStorage 22 | .getProfile(id) 23 | .mapT(profileUpdate.merge) 24 | .flatMapTOuter(userProfileStorage.saveProfile) 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/core/profiles/UserProfileStorage.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.core.profiles 2 | 3 | import me.archdev.restapi.core.UserProfile 4 | import me.archdev.restapi.utils.db.DatabaseConnector 5 | 6 | import scala.concurrent.{ ExecutionContext, Future } 7 | 8 | sealed trait UserProfileStorage { 9 | 10 | def getProfiles(): Future[Seq[UserProfile]] 11 | 12 | def getProfile(id: String): Future[Option[UserProfile]] 13 | 14 | def saveProfile(profile: UserProfile): Future[UserProfile] 15 | 16 | } 17 | 18 | class JdbcUserProfileStorage( 19 | val databaseConnector: DatabaseConnector 20 | )(implicit executionContext: ExecutionContext) 21 | extends UserProfileTable 22 | with UserProfileStorage { 23 | 24 | import databaseConnector._ 25 | import databaseConnector.profile.api._ 26 | 27 | def getProfiles(): Future[Seq[UserProfile]] = db.run(profiles.result) 28 | 29 | def getProfile(id: String): Future[Option[UserProfile]] = db.run(profiles.filter(_.id === id).result.headOption) 30 | 31 | def saveProfile(profile: UserProfile): Future[UserProfile] = 32 | db.run(profiles.insertOrUpdate(profile)).map(_ => profile) 33 | 34 | } 35 | 36 | class InMemoryUserProfileStorage extends UserProfileStorage { 37 | 38 | private var state: Seq[UserProfile] = Nil 39 | 40 | override def getProfiles(): Future[Seq[UserProfile]] = 41 | Future.successful(state) 42 | 43 | override def getProfile(id: String): Future[Option[UserProfile]] = 44 | Future.successful(state.find(_.id == id)) 45 | 46 | override def saveProfile(profile: UserProfile): Future[UserProfile] = 47 | Future.successful { 48 | state = state.filterNot(_.id == profile.id) 49 | state = state :+ profile 50 | profile 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/core/profiles/UserProfileTable.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.core.profiles 2 | 3 | import me.archdev.restapi.core.UserProfile 4 | import me.archdev.restapi.utils.db.DatabaseConnector 5 | 6 | private[profiles] trait UserProfileTable { 7 | 8 | protected val databaseConnector: DatabaseConnector 9 | import databaseConnector.profile.api._ 10 | 11 | class Profiles(tag: Tag) extends Table[UserProfile](tag, "profiles") { 12 | def id = column[String]("id", O.PrimaryKey) 13 | def firstName = column[String]("first_name") 14 | def lastName = column[String]("last_name") 15 | 16 | def * = (id, firstName, lastName) <> ((UserProfile.apply _).tupled, UserProfile.unapply) 17 | } 18 | 19 | protected val profiles = TableQuery[Profiles] 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/http/HttpRoute.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.http 2 | 3 | import akka.http.scaladsl.server.Directives._ 4 | import akka.http.scaladsl.server.Route 5 | import me.archdev.restapi.core.profiles.UserProfileService 6 | import me.archdev.restapi.http.routes.{ AuthRoute, ProfileRoute } 7 | import ch.megard.akka.http.cors.scaladsl.CorsDirectives._ 8 | import me.archdev.restapi.core.auth.AuthService 9 | 10 | import scala.concurrent.ExecutionContext 11 | 12 | class HttpRoute( 13 | userProfileService: UserProfileService, 14 | authService: AuthService, 15 | secretKey: String 16 | )(implicit executionContext: ExecutionContext) { 17 | 18 | private val usersRouter = new ProfileRoute(secretKey, userProfileService) 19 | private val authRouter = new AuthRoute(authService) 20 | 21 | val route: Route = 22 | cors() { 23 | pathPrefix("v1") { 24 | usersRouter.route ~ 25 | authRouter.route 26 | } ~ 27 | pathPrefix("healthcheck") { 28 | get { 29 | complete("OK") 30 | } 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/http/routes/AuthRoute.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.http.routes 2 | 3 | import akka.http.scaladsl.model.StatusCodes 4 | import akka.http.scaladsl.server.Directives._ 5 | import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport 6 | import io.circe.generic.auto._ 7 | import io.circe.syntax._ 8 | import me.archdev.restapi.core.auth.AuthService 9 | 10 | import scala.concurrent.ExecutionContext 11 | 12 | class AuthRoute(authService: AuthService)(implicit executionContext: ExecutionContext) extends FailFastCirceSupport { 13 | 14 | import StatusCodes._ 15 | import authService._ 16 | 17 | val route = pathPrefix("auth") { 18 | path("signIn") { 19 | pathEndOrSingleSlash { 20 | post { 21 | entity(as[LoginPassword]) { loginPassword => 22 | complete( 23 | signIn(loginPassword.login, loginPassword.password).map { 24 | case Some(token) => OK -> token.asJson 25 | case None => BadRequest -> None.asJson 26 | } 27 | ) 28 | } 29 | } 30 | } 31 | } ~ 32 | path("signUp") { 33 | pathEndOrSingleSlash { 34 | post { 35 | entity(as[UsernamePasswordEmail]) { userEntity => 36 | complete(Created -> signUp(userEntity.username, userEntity.email, userEntity.password)) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | private case class LoginPassword(login: String, password: String) 44 | private case class UsernamePasswordEmail(username: String, email: String, password: String) 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/http/routes/ProfileRoute.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.http.routes 2 | 3 | import akka.http.scaladsl.model.StatusCodes 4 | import akka.http.scaladsl.server.Directives._ 5 | import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport 6 | import io.circe.generic.auto._ 7 | import io.circe.syntax._ 8 | import me.archdev.restapi.core.UserProfileUpdate 9 | import me.archdev.restapi.core.profiles.UserProfileService 10 | import me.archdev.restapi.utils.SecurityDirectives 11 | 12 | import scala.concurrent.ExecutionContext 13 | 14 | class ProfileRoute( 15 | secretKey: String, 16 | usersService: UserProfileService 17 | )(implicit executionContext: ExecutionContext) 18 | extends FailFastCirceSupport { 19 | 20 | import SecurityDirectives._ 21 | import StatusCodes._ 22 | import usersService._ 23 | 24 | val route = pathPrefix("profiles") { 25 | pathEndOrSingleSlash { 26 | get { 27 | complete(getProfiles().map(_.asJson)) 28 | } 29 | } ~ 30 | pathPrefix("me") { 31 | pathEndOrSingleSlash { 32 | authenticate(secretKey) { userId => 33 | get { 34 | complete(getProfile(userId)) 35 | } ~ 36 | post { 37 | entity(as[UserProfileUpdate]) { userUpdate => 38 | complete(updateProfile(userId, userUpdate).map(_.asJson)) 39 | } 40 | } 41 | } 42 | } 43 | } ~ 44 | pathPrefix(Segment) { id => 45 | pathEndOrSingleSlash { 46 | get { 47 | complete(getProfile(id).map { 48 | case Some(profile) => 49 | OK -> profile.asJson 50 | case None => 51 | BadRequest -> None.asJson 52 | }) 53 | } ~ 54 | post { 55 | entity(as[UserProfileUpdate]) { userUpdate => 56 | complete(updateProfile(id, userUpdate).map { 57 | case Some(profile) => 58 | OK -> profile.asJson 59 | case None => 60 | BadRequest -> None.asJson 61 | }) 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/utils/Config.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.utils 2 | 3 | import pureconfig.loadConfig 4 | import pureconfig.generic.auto._ 5 | 6 | case class Config(secretKey: String, http: HttpConfig, database: DatabaseConfig) 7 | 8 | object Config { 9 | def load() = 10 | loadConfig[Config] match { 11 | case Right(config) => config 12 | case Left(error) => 13 | throw new RuntimeException("Cannot read config file, errors:\n" + error.toList.mkString("\n")) 14 | } 15 | } 16 | 17 | private[utils] case class HttpConfig(host: String, port: Int) 18 | private[utils] case class DatabaseConfig(jdbcUrl: String, username: String, password: String) 19 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/utils/MonadTransformers.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.utils 2 | 3 | import scala.concurrent.{ ExecutionContext, Future } 4 | 5 | /** 6 | * Monad transformers its classes that extends default monad's like Future or Option. 7 | * They handle situation when monad's contain each other, its helping to reduce amount of boilerplate code. 8 | * For example in that project there are lots of Future[Option[T]] classes that must be handled somehow. 9 | */ 10 | object MonadTransformers { 11 | 12 | implicit class FutureOptionMonadTransformer[A](t: Future[Option[A]])(implicit executionContext: ExecutionContext) { 13 | 14 | def mapT[B](f: A => B): Future[Option[B]] = 15 | t.map(_.map(f)) 16 | 17 | def filterT(f: A => Boolean): Future[Option[A]] = 18 | t.map { 19 | case Some(data) if f(data) => 20 | Some(data) 21 | case _ => 22 | None 23 | } 24 | 25 | def flatMapT[B](f: A => Future[Option[B]]): Future[Option[B]] = 26 | t.flatMap { 27 | case Some(data) => 28 | f(data) 29 | case None => 30 | Future.successful(None) 31 | } 32 | 33 | def flatMapTOuter[B](f: A => Future[B]): Future[Option[B]] = 34 | t.flatMap { 35 | case Some(data) => 36 | f(data).map(Some.apply) 37 | case None => 38 | Future.successful(None) 39 | } 40 | 41 | def flatMapTInner[B](f: A => Option[B]): Future[Option[B]] = 42 | t.map(_.flatMap(f)) 43 | 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/utils/SecurityDirectives.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.utils 2 | 3 | import akka.http.scaladsl.server.Directive1 4 | import akka.http.scaladsl.server.directives.{ BasicDirectives, HeaderDirectives, RouteDirectives } 5 | import me.archdev.restapi.core.{ AuthTokenContent, UserId } 6 | import pdi.jwt._ 7 | import io.circe.parser._ 8 | import io.circe.generic.auto._ 9 | 10 | object SecurityDirectives { 11 | 12 | import BasicDirectives._ 13 | import HeaderDirectives._ 14 | import RouteDirectives._ 15 | 16 | def authenticate(secretKey: String): Directive1[UserId] = 17 | headerValueByName("Token") 18 | .map(Jwt.decodeRaw(_, secretKey, Seq(JwtAlgorithm.HS256))) 19 | .map(_.toOption.flatMap(decode[AuthTokenContent](_).toOption)) 20 | .flatMap { 21 | case Some(result) => 22 | provide(result.userId) 23 | case None => 24 | reject 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/utils/db/DatabaseConnector.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.utils.db 2 | 3 | import com.zaxxer.hikari.{ HikariConfig, HikariDataSource } 4 | 5 | class DatabaseConnector(jdbcUrl: String, dbUser: String, dbPassword: String) { 6 | 7 | private val hikariDataSource = { 8 | val hikariConfig = new HikariConfig() 9 | hikariConfig.setJdbcUrl(jdbcUrl) 10 | hikariConfig.setUsername(dbUser) 11 | hikariConfig.setPassword(dbPassword) 12 | 13 | new HikariDataSource(hikariConfig) 14 | } 15 | 16 | val profile = slick.jdbc.PostgresProfile 17 | import profile.api._ 18 | 19 | val db = Database.forDataSource(hikariDataSource, None) 20 | db.createSession() 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/me/archdev/restapi/utils/db/DatabaseMigrationManager.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.restapi.utils.db 2 | 3 | import org.flywaydb.core.Flyway 4 | 5 | class DatabaseMigrationManager(jdbcUrl: String, dbUser: String, dbPassword: String) { 6 | 7 | private val flyway = new Flyway() 8 | flyway.setDataSource(jdbcUrl, dbUser, dbPassword) 9 | 10 | def migrateDatabaseSchema(): Unit = flyway.migrate() 11 | 12 | def dropDatabase(): Unit = flyway.clean() 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | database = { 2 | jdbc-url = "jdbc:postgresql://localhost:25535/database-name" 3 | username = "user" 4 | password = "password" 5 | } -------------------------------------------------------------------------------- /src/test/scala/me/archdev/BaseServiceTest.scala: -------------------------------------------------------------------------------- 1 | package me.archdev 2 | 3 | import akka.http.scaladsl.testkit.ScalatestRouteTest 4 | import org.scalatest._ 5 | import matchers.should._ 6 | import wordspec._ 7 | import org.mockito.MockitoSugar 8 | 9 | import scala.concurrent.duration._ 10 | import scala.concurrent.{Await, Future} 11 | 12 | trait BaseServiceTest extends AnyWordSpec with Matchers with ScalatestRouteTest with MockitoSugar { 13 | 14 | def awaitForResult[T](futureResult: Future[T]): T = 15 | Await.result(futureResult, 5.seconds) 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/BootIT.scala: -------------------------------------------------------------------------------- 1 | package me.archdev 2 | 3 | import me.archdev.restapi.Boot 4 | import me.archdev.utils.InMemoryPostgresStorage 5 | import sttp.client3._ 6 | import sttp.client3.akkahttp._ 7 | 8 | class BootIT extends BaseServiceTest { 9 | 10 | InMemoryPostgresStorage 11 | implicit val sttpBackend = AkkaHttpBackend() 12 | 13 | "Service" should { 14 | 15 | "bind on port successfully and answer on health checks" in { 16 | awaitForResult(for { 17 | serverBinding <- Boot.startApplication() 18 | healthCheckResponse <- basicRequest.get(uri"http://localhost:9000/healthcheck").send() 19 | _ <- serverBinding.unbind() 20 | } yield { 21 | healthCheckResponse.isSuccess shouldBe true 22 | healthCheckResponse.body shouldBe Right("OK") 23 | }) 24 | } 25 | 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/core/auth/AuthDataStorageSpec.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.core.auth 2 | 3 | import java.util.UUID 4 | 5 | import me.archdev.BaseServiceTest 6 | import me.archdev.restapi.core.AuthData 7 | import me.archdev.restapi.core.auth.{AuthDataStorage, InMemoryAuthDataStorage, JdbcAuthDataStorage} 8 | import me.archdev.utils.InMemoryPostgresStorage 9 | 10 | import scala.util.Random 11 | 12 | class JdbcAuthDataStorageTest extends AuthDataStorageSpec { 13 | override def authDataStorageBuilder(): AuthDataStorage = 14 | new JdbcAuthDataStorage(InMemoryPostgresStorage.databaseConnector) 15 | } 16 | 17 | class InMemoryAuthDataStorageTest extends AuthDataStorageSpec { 18 | override def authDataStorageBuilder(): AuthDataStorage = 19 | new InMemoryAuthDataStorage() 20 | } 21 | 22 | abstract class AuthDataStorageSpec extends BaseServiceTest { 23 | 24 | def authDataStorageBuilder(): AuthDataStorage 25 | 26 | "AuthDataStorage" when { 27 | 28 | "saveAuthData" should { 29 | 30 | "return saved auth data" in new Context { 31 | awaitForResult(for { 32 | authData <- authDataStorage.saveAuthData(testAuthData) 33 | } yield authData shouldBe testAuthData) 34 | } 35 | 36 | "override already saved data" in new Context { 37 | awaitForResult(for { 38 | _ <- authDataStorage.saveAuthData(testAuthData.copy(username = "123", email = "123", password = "123")) 39 | authData <- authDataStorage.saveAuthData(testAuthData) 40 | } yield authData shouldBe testAuthData) 41 | } 42 | 43 | } 44 | 45 | "findAuthData" should { 46 | 47 | "return auth data where username or email equals to login" in new Context { 48 | awaitForResult(for { 49 | _ <- authDataStorage.saveAuthData(testAuthData) 50 | maybeAuthDataUsername <- authDataStorage.findAuthData(testAuthData.username) 51 | maybeAuthDataEmail <- authDataStorage.findAuthData(testAuthData.email) 52 | } yield { 53 | maybeAuthDataUsername shouldBe Some(testAuthData) 54 | maybeAuthDataEmail shouldBe Some(testAuthData) 55 | }) 56 | } 57 | 58 | "return None if user with such login don't exists" in new Context { 59 | awaitForResult(for { 60 | maybeAuthData <- authDataStorage.findAuthData(testAuthData.username) 61 | } yield maybeAuthData shouldBe None) 62 | } 63 | 64 | } 65 | 66 | } 67 | 68 | trait Context { 69 | val authDataStorage: AuthDataStorage = authDataStorageBuilder() 70 | val testAuthData = AuthData(UUID.randomUUID().toString, Random.nextString(10), Random.nextString(10), Random.nextString(10)) 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/core/auth/AuthServiceTest.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.core.auth 2 | 3 | import java.util.UUID 4 | 5 | import com.roundeights.hasher.Implicits._ 6 | import me.archdev.BaseServiceTest 7 | import me.archdev.restapi.core.AuthData 8 | import me.archdev.restapi.core.auth.{AuthService, InMemoryAuthDataStorage} 9 | import pdi.jwt.{Jwt, JwtAlgorithm} 10 | 11 | import scala.util.Random 12 | 13 | class AuthServiceTest extends BaseServiceTest { 14 | 15 | "AuthServiceTest" when { 16 | 17 | "signIn" should { 18 | 19 | "return valid auth token" in new Context { 20 | awaitForResult(for { 21 | _ <- authDataStorage.saveAuthData(testAuthData) 22 | Some(token) <- authService.signIn(testUsername, testPassword) 23 | } yield Jwt.decodeRaw(token, secretKey, Seq(JwtAlgorithm.HS256)).isSuccess shouldBe true) 24 | } 25 | 26 | "return None if password is incorrect" in new Context { 27 | awaitForResult(for { 28 | _ <- authDataStorage.saveAuthData(testAuthData) 29 | result <- authService.signIn(testUsername, "wrongpassword") 30 | } yield result shouldBe None) 31 | } 32 | 33 | "return None if user not exists" in new Context { 34 | awaitForResult(for { 35 | result <- authService.signIn(testUsername, testPassword) 36 | } yield result shouldBe None) 37 | } 38 | 39 | } 40 | 41 | "signUp" should { 42 | 43 | "return valid auth token" in new Context { 44 | awaitForResult(for { 45 | token <- authService.signUp(testUsername, testEmail, testPassword) 46 | } yield Jwt.decodeRaw(token, secretKey, Seq(JwtAlgorithm.HS256)).isSuccess shouldBe true) 47 | } 48 | 49 | "store auth data with encrypted password in database" in new Context { 50 | awaitForResult(for { 51 | _ <- authService.signUp(testUsername, testEmail, testPassword) 52 | Some(authData) <- authDataStorage.findAuthData(testUsername) 53 | } yield { 54 | authData.username shouldBe testUsername 55 | authData.email shouldBe testEmail 56 | authData.password shouldBe testPassword.sha256.hex 57 | }) 58 | } 59 | 60 | } 61 | 62 | } 63 | 64 | trait Context { 65 | val secretKey = "secret" 66 | val authDataStorage = new InMemoryAuthDataStorage 67 | val authService = new AuthService(authDataStorage, secretKey) 68 | 69 | val testId: String = UUID.randomUUID().toString 70 | val testUsername: String = Random.nextString(10) 71 | val testEmail: String = Random.nextString(10) 72 | val testPassword: String = Random.nextString(10) 73 | 74 | val testAuthData = AuthData(testId, testUsername, testEmail, testPassword.sha256.hex) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/core/profiles/UserProfileServiceTest.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.core.profiles 2 | 3 | import java.util.UUID 4 | 5 | import me.archdev.BaseServiceTest 6 | import me.archdev.restapi.core.{UserProfile, UserProfileUpdate} 7 | import me.archdev.restapi.core.profiles.{InMemoryUserProfileStorage, UserProfileService} 8 | 9 | import scala.util.Random 10 | 11 | class UserProfileServiceTest extends BaseServiceTest { 12 | 13 | "UserProfileService" when { 14 | 15 | "getProfiles" should { 16 | 17 | "return all stored profiles" in new Context { 18 | awaitForResult(for { 19 | _ <- userProfileStorage.saveProfile(testProfile1) 20 | _ <- userProfileStorage.saveProfile(testProfile2) 21 | profiles <- userProfileService.getProfiles() 22 | } yield profiles shouldBe Seq(testProfile1, testProfile2)) 23 | } 24 | 25 | } 26 | 27 | "getProfile" should { 28 | 29 | "return profile by id" in new Context { 30 | awaitForResult(for { 31 | _ <- userProfileStorage.saveProfile(testProfile1) 32 | _ <- userProfileStorage.saveProfile(testProfile2) 33 | maybeProfile <- userProfileService.getProfile(testProfileId1) 34 | } yield maybeProfile shouldBe Some(testProfile1)) 35 | } 36 | 37 | "return None if profile not exists" in new Context { 38 | awaitForResult(for { 39 | _ <- userProfileStorage.saveProfile(testProfile1) 40 | _ <- userProfileStorage.saveProfile(testProfile2) 41 | maybeProfile <- userProfileService.getProfile("wrongId") 42 | } yield maybeProfile shouldBe None) 43 | } 44 | 45 | } 46 | 47 | "createProfile" should { 48 | 49 | "store profile" in new Context { 50 | awaitForResult(for { 51 | _ <- userProfileService.createProfile(testProfile1) 52 | maybeProfile <- userProfileStorage.getProfile(testProfileId1) 53 | } yield maybeProfile shouldBe Some(testProfile1)) 54 | } 55 | 56 | } 57 | 58 | "updateProfile" should { 59 | 60 | "merge profile with partial update" in new Context { 61 | awaitForResult(for { 62 | _ <- userProfileService.createProfile(testProfile1) 63 | _ <- userProfileService.updateProfile(testProfileId1, UserProfileUpdate(Some("test"), Some("test"))) 64 | maybeProfile <- userProfileStorage.getProfile(testProfileId1) 65 | } yield maybeProfile shouldBe Some(testProfile1.copy(firstName = "test", lastName = "test"))) 66 | } 67 | 68 | "return None if profile is not exists" in new Context { 69 | awaitForResult(for { 70 | maybeProfile <- userProfileService.updateProfile(testProfileId1, UserProfileUpdate(Some("test"), Some("test"))) 71 | } yield maybeProfile shouldBe None) 72 | } 73 | 74 | } 75 | 76 | } 77 | 78 | trait Context { 79 | val userProfileStorage = new InMemoryUserProfileStorage() 80 | val userProfileService = new UserProfileService(userProfileStorage) 81 | 82 | val testProfileId1: String = UUID.randomUUID().toString 83 | val testProfileId2: String = UUID.randomUUID().toString 84 | val testProfile1: UserProfile = testProfile(testProfileId1) 85 | val testProfile2: UserProfile = testProfile(testProfileId2) 86 | 87 | def testProfile(id: String) = UserProfile(id, Random.nextString(10), Random.nextString(10)) 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/core/profiles/UserProfileStorageSpec.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.core.profiles 2 | 3 | import java.util.UUID 4 | 5 | import me.archdev.BaseServiceTest 6 | import me.archdev.restapi.core.UserProfile 7 | import me.archdev.restapi.core.profiles.{InMemoryUserProfileStorage, JdbcUserProfileStorage, UserProfileStorage} 8 | import me.archdev.utils.InMemoryPostgresStorage 9 | 10 | import scala.util.Random 11 | 12 | class JdbcUserProfileStorageTest extends UserProfileStorageSpec { 13 | override def userProfileStorageBuilder(): UserProfileStorage = 14 | new JdbcUserProfileStorage(InMemoryPostgresStorage.databaseConnector) 15 | } 16 | 17 | class InMemoryUserProfileStorageTest extends UserProfileStorageSpec { 18 | override def userProfileStorageBuilder(): UserProfileStorage = 19 | new InMemoryUserProfileStorage() 20 | } 21 | 22 | abstract class UserProfileStorageSpec extends BaseServiceTest { 23 | 24 | def userProfileStorageBuilder(): UserProfileStorage 25 | 26 | "UserProfileStorage" when { 27 | 28 | "getProfile" should { 29 | 30 | "return profile by id" in new Context { 31 | awaitForResult(for { 32 | _ <- userProfileStorage.saveProfile(testProfile1) 33 | _ <- userProfileStorage.saveProfile(testProfile2) 34 | maybeProfile <- userProfileStorage.getProfile(testProfileId1) 35 | } yield maybeProfile shouldBe Some(testProfile1)) 36 | } 37 | 38 | "return None if profile not found" in new Context { 39 | awaitForResult(for { 40 | maybeProfile <- userProfileStorage.getProfile("smth") 41 | } yield maybeProfile shouldBe None) 42 | } 43 | 44 | } 45 | 46 | "getProfiles" should { 47 | 48 | "return all profiles from database" in new Context { 49 | awaitForResult(for { 50 | _ <- userProfileStorage.saveProfile(testProfile1) 51 | _ <- userProfileStorage.saveProfile(testProfile2) 52 | profiles <- userProfileStorage.getProfiles() 53 | } yield profiles.nonEmpty shouldBe true) 54 | } 55 | 56 | } 57 | 58 | "saveProfile" should { 59 | 60 | "save profile to database" in new Context { 61 | awaitForResult(for { 62 | _ <- userProfileStorage.saveProfile(testProfile1) 63 | maybeProfile <- userProfileStorage.getProfile(testProfileId1) 64 | } yield maybeProfile shouldBe Some(testProfile1)) 65 | } 66 | 67 | "overwrite profile if it exists" in new Context { 68 | awaitForResult(for { 69 | _ <- userProfileStorage.saveProfile(testProfile1.copy(firstName = "test", lastName = "test")) 70 | _ <- userProfileStorage.saveProfile(testProfile1) 71 | maybeProfile <- userProfileStorage.getProfile(testProfileId1) 72 | } yield maybeProfile shouldBe Some(testProfile1)) 73 | } 74 | 75 | } 76 | 77 | } 78 | 79 | trait Context { 80 | val userProfileStorage: UserProfileStorage = userProfileStorageBuilder() 81 | val testProfileId1: String = UUID.randomUUID().toString 82 | val testProfileId2: String = UUID.randomUUID().toString 83 | val testProfile1: UserProfile = testProfile(testProfileId1) 84 | val testProfile2: UserProfile = testProfile(testProfileId2) 85 | 86 | def testProfile(id: String) = UserProfile(id, Random.nextString(10), Random.nextString(10)) 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/http/HttpRouteTest.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.http 2 | 3 | import akka.http.scaladsl.server.Route 4 | import me.archdev.BaseServiceTest 5 | import me.archdev.restapi.core.auth.AuthService 6 | import me.archdev.restapi.core.profiles.UserProfileService 7 | import me.archdev.restapi.http.HttpRoute 8 | 9 | class HttpRouteTest extends BaseServiceTest { 10 | 11 | "HttpRoute" when { 12 | 13 | "GET /healthcheck" should { 14 | 15 | "return 200 OK" in new Context { 16 | Get("/healthcheck") ~> httpRoute ~> check { 17 | responseAs[String] shouldBe "OK" 18 | status.intValue() shouldBe 200 19 | } 20 | } 21 | 22 | } 23 | 24 | } 25 | 26 | trait Context { 27 | val secretKey = "secret" 28 | val userProfileService: UserProfileService = mock[UserProfileService] 29 | val authService: AuthService = mock[AuthService] 30 | 31 | val httpRoute: Route = new HttpRoute(userProfileService, authService, secretKey).route 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/http/routes/AuthRouteTest.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.http.routes 2 | 3 | import akka.http.scaladsl.model.{HttpEntity, MediaTypes} 4 | import akka.http.scaladsl.server.Route 5 | import me.archdev.BaseServiceTest 6 | import me.archdev.restapi.core.auth.AuthService 7 | import me.archdev.restapi.http.routes.AuthRoute 8 | import org.mockito.Mockito._ 9 | 10 | import scala.concurrent.Future 11 | 12 | class AuthRouteTest extends BaseServiceTest { 13 | 14 | "AuthRoute" when { 15 | 16 | "POST /auth/signIn" should { 17 | 18 | "return 200 and token if sign in successful" in new Context { 19 | when(authService.signIn("test", "test")).thenReturn(Future.successful(Some("token"))) 20 | val requestEntity = HttpEntity(MediaTypes.`application/json`, s"""{"login": "test", "password": "test"}""") 21 | 22 | Post("/auth/signIn", requestEntity) ~> authRoute ~> check { 23 | responseAs[String] shouldBe "\"token\"" 24 | status.intValue() shouldBe 200 25 | } 26 | } 27 | 28 | "return 400 if signIn unsuccessful" in new Context { 29 | when(authService.signIn("test", "test")).thenReturn(Future.successful(None)) 30 | val requestEntity = HttpEntity(MediaTypes.`application/json`, s"""{"login": "test", "password": "test"}""") 31 | 32 | Post("/auth/signIn", requestEntity) ~> authRoute ~> check { 33 | status.intValue() shouldBe 400 34 | } 35 | } 36 | 37 | } 38 | 39 | "POST /auth/signUp" should { 40 | 41 | "return 201 and token" in new Context { 42 | when(authService.signUp("test", "test", "test")).thenReturn(Future.successful("token")) 43 | val requestEntity = HttpEntity(MediaTypes.`application/json`, s"""{"username": "test", "email": "test", "password": "test"}""") 44 | 45 | Post("/auth/signUp", requestEntity) ~> authRoute ~> check { 46 | responseAs[String] shouldBe "\"token\"" 47 | status.intValue() shouldBe 201 48 | } 49 | } 50 | 51 | } 52 | 53 | } 54 | 55 | trait Context { 56 | val authService: AuthService = mock[AuthService] 57 | val authRoute: Route = new AuthRoute(authService).route 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/http/routes/ProfileRouteTest.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.http.routes 2 | 3 | import java.util.UUID 4 | 5 | import akka.http.scaladsl.model.{HttpEntity, MediaTypes} 6 | import akka.http.scaladsl.model.headers.RawHeader 7 | import akka.http.scaladsl.server.Route 8 | import io.circe.generic.auto._ 9 | import io.circe.syntax._ 10 | import me.archdev.BaseServiceTest 11 | import me.archdev.restapi.core.{AuthTokenContent, UserProfile, UserProfileUpdate} 12 | import me.archdev.restapi.core.profiles.UserProfileService 13 | import me.archdev.restapi.http.routes.ProfileRoute 14 | import org.mockito.Mockito.when 15 | import pdi.jwt.{Jwt, JwtAlgorithm} 16 | 17 | import scala.concurrent.Future 18 | import scala.util.Random 19 | 20 | class ProfileRouteTest extends BaseServiceTest { 21 | 22 | "ProfileRoute" when { 23 | 24 | "GET /profiles" should { 25 | 26 | "return 200 and all profiles JSON" in new Context { 27 | when(userProfileService.getProfiles()).thenReturn(Future.successful(Seq(testProfile1, testProfile2))) 28 | 29 | Get("/profiles") ~> profileRoute ~> check { 30 | responseAs[String] shouldBe Seq(testProfile1, testProfile2).asJson.noSpaces 31 | status.intValue() shouldBe 200 32 | } 33 | } 34 | 35 | } 36 | 37 | "GET /profiles/me with Token header" should { 38 | 39 | "return 200 and current user profile JSON" in new Context { 40 | when(userProfileService.getProfile(testProfile1.id)).thenReturn(Future.successful(Some(testProfile1))) 41 | val header = RawHeader("Token", buildAuthToken(testProfile1.id)) 42 | 43 | Get("/profiles/me").withHeaders(header) ~> profileRoute ~> check { 44 | responseAs[String] shouldBe testProfile1.asJson.noSpaces 45 | status.intValue() shouldBe 200 46 | } 47 | } 48 | 49 | "return 500 if token is incorrect" in new Context { 50 | val header = RawHeader("Token", "token") 51 | 52 | Get("/profiles/me").withHeaders(header) ~> profileRoute ~> check { 53 | status.intValue() shouldBe 500 54 | } 55 | } 56 | 57 | } 58 | 59 | "POST /profiles/me" should { 60 | 61 | "update profile and return 200" in new Context { 62 | when(userProfileService.updateProfile(testProfile1.id, UserProfileUpdate(Some(""), Some("")))).thenReturn(Future.successful(Some(testProfile1))) 63 | val header = RawHeader("Token", buildAuthToken(testProfile1.id)) 64 | val requestEntity = HttpEntity(MediaTypes.`application/json`, s"""{"firstName": "", "lastName": ""}""") 65 | 66 | Post("/profiles/me", requestEntity).withHeaders(header) ~> profileRoute ~> check { 67 | responseAs[String] shouldBe testProfile1.asJson.noSpaces 68 | status.intValue() shouldBe 200 69 | } 70 | } 71 | 72 | "return 500 if token is incorrect" in new Context { 73 | val header = RawHeader("Token", "token") 74 | val requestEntity = HttpEntity(MediaTypes.`application/json`, s"""{"firstName": "", "lastName": ""}""") 75 | 76 | Post("/profiles/me", requestEntity).withHeaders(header) ~> profileRoute ~> check { 77 | status.intValue() shouldBe 500 78 | } 79 | } 80 | 81 | } 82 | 83 | "GET /profiles/:id" should { 84 | 85 | "return 200 and user profile JSON" in new Context { 86 | when(userProfileService.getProfile(testProfile1.id)).thenReturn(Future.successful(Some(testProfile1))) 87 | 88 | Get("/profiles/" + testProfileId1) ~> profileRoute ~> check { 89 | responseAs[String] shouldBe testProfile1.asJson.noSpaces 90 | status.intValue() shouldBe 200 91 | } 92 | } 93 | 94 | "return 400 if profile not exists" in new Context { 95 | when(userProfileService.getProfile(testProfile1.id)).thenReturn(Future.successful(None)) 96 | 97 | Get("/profiles/" + testProfileId1) ~> profileRoute ~> check { 98 | status.intValue() shouldBe 400 99 | } 100 | } 101 | 102 | } 103 | 104 | "POST /profiles/:id" should { 105 | 106 | "update user profile and return 200" in new Context { 107 | when(userProfileService.updateProfile(testProfile1.id, UserProfileUpdate(Some(""), Some("")))).thenReturn(Future.successful(Some(testProfile1))) 108 | val requestEntity = HttpEntity(MediaTypes.`application/json`, s"""{"firstName": "", "lastName": ""}""") 109 | 110 | Post("/profiles/" + testProfileId1, requestEntity) ~> profileRoute ~> check { 111 | responseAs[String] shouldBe testProfile1.asJson.noSpaces 112 | status.intValue() shouldBe 200 113 | } 114 | } 115 | 116 | "return 400 if profile not exists" in new Context { 117 | when(userProfileService.updateProfile(testProfile1.id, UserProfileUpdate(Some(""), Some("")))).thenReturn(Future.successful(None)) 118 | val requestEntity = HttpEntity(MediaTypes.`application/json`, s"""{"firstName": "", "lastName": ""}""") 119 | 120 | Post("/profiles/" + testProfileId1, requestEntity) ~> profileRoute ~> check { 121 | status.intValue() shouldBe 400 122 | } 123 | } 124 | 125 | } 126 | 127 | } 128 | 129 | trait Context { 130 | val secretKey = "secret" 131 | val userProfileService: UserProfileService = mock[UserProfileService] 132 | val profileRoute: Route = new ProfileRoute(secretKey, userProfileService).route 133 | 134 | val testProfileId1: String = UUID.randomUUID().toString 135 | val testProfileId2: String = UUID.randomUUID().toString 136 | val testProfile1: UserProfile = testProfile(testProfileId1) 137 | val testProfile2: UserProfile = testProfile(testProfileId2) 138 | 139 | def testProfile(id: String) = UserProfile(id, Random.nextString(10), Random.nextString(10)) 140 | 141 | def buildAuthToken(id: String): String = Jwt.encode(AuthTokenContent(id).asJson.noSpaces, secretKey, JwtAlgorithm.HS256) 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /src/test/scala/me/archdev/utils/InMemoryPostgresStorage.scala: -------------------------------------------------------------------------------- 1 | package me.archdev.utils 2 | 3 | import de.flapdoodle.embed.process.runtime.Network._ 4 | import me.archdev.restapi.utils.db.{DatabaseConnector, DatabaseMigrationManager} 5 | import ru.yandex.qatools.embed.postgresql.PostgresStarter 6 | import ru.yandex.qatools.embed.postgresql.config.AbstractPostgresConfig.{Credentials, Net, Storage, Timeout} 7 | import ru.yandex.qatools.embed.postgresql.config.PostgresConfig 8 | import ru.yandex.qatools.embed.postgresql.distribution.Version 9 | 10 | object InMemoryPostgresStorage { 11 | val dbHost = getLocalHost.getHostAddress 12 | val dbPort = 25535 13 | val dbName = "database-name" 14 | val dbUser = "user" 15 | val dbPassword = "password" 16 | val jdbcUrl = s"jdbc:postgresql://$dbHost:$dbPort/$dbName" 17 | 18 | val psqlConfig = new PostgresConfig( 19 | Version.Main.V10, new Net(dbHost, dbPort), 20 | new Storage(dbName), new Timeout(), 21 | new Credentials(dbUser, dbPassword) 22 | ) 23 | val psqlInstance = PostgresStarter.getDefaultInstance 24 | val flywayService = new DatabaseMigrationManager(jdbcUrl, dbUser, dbPassword) 25 | 26 | val process = psqlInstance.prepare(psqlConfig).start() 27 | flywayService.dropDatabase() 28 | flywayService.migrateDatabaseSchema() 29 | 30 | val databaseConnector = new DatabaseConnector( 31 | InMemoryPostgresStorage.jdbcUrl, 32 | InMemoryPostgresStorage.dbUser, 33 | InMemoryPostgresStorage.dbPassword 34 | ) 35 | } 36 | --------------------------------------------------------------------------------