diff --git a/.gitignore b/.gitignore index cc9152f8..f62dc42a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ TAGS tests.iml # Auto-copied by sbt-microsites docs/src/main/tut/contributing.md +.sdkmanrc +.bloop +.vscode +.metals +metals.sbt +logs/ diff --git a/users/.scalafmt.conf b/users/.scalafmt.conf index 0958531d..27736420 100644 --- a/users/.scalafmt.conf +++ b/users/.scalafmt.conf @@ -1,18 +1,64 @@ +version = "3.7.12" +runner.dialect = scala3 style = defaultWithAlign maxColumn = 120 -danglingParentheses = true -align.openParenCallSite = false -spaces.inImportCurlyBraces = true -rewrite.rules = [PreferCurlyFors, RedundantBraces, SortImports] -binPack.parentConstructors = false -unindentTopLevelOperators = true -newlines.afterImplicitKWInVerticalMultiline = true -newlines.beforeImplicitKWInVerticalMultiline = true -rewriteTokens { - "=>" = "⇒" - "->" = "→" - "<-" = "←" +align = most + +continuationIndent.defnSite = 2 +align.arrowEnumeratorGenerator = true +align.openParenCallSite = false +align.openParenDefnSite = false + +assumeStandardLibraryStripMargin = true +align.stripMargin = true + +danglingParentheses.defnSite = true +danglingParentheses.callSite = true +danglingParentheses.exclude = [class, trait, enum, def] + +docstrings.style = Asterisk +docstrings.wrap = "no" +docstrings.forceBlankLineBefore = true + +rewrite.rules = [RedundantBraces, RedundantParens, SortImports, PreferCurlyFors, SortModifiers] +rewrite.redundantBraces.includeUnitMethods = true +rewrite.redundantBraces.stringInterpolation = true +rewrite.redundantBraces.methodBodies = false + +rewrite.imports.sort = ascii +rewrite.imports.groups = [ + ["javax?\\..*"] + ["akka\\..*"] + ["org\\..*"] + ["com\\..*"] + ["scala\\..*"] + ["io\\..*", "cats\\..*"] + ["scala\\.concurrent\\..*"] +] + +rewrite.sortModifiers { + order = [ + override + private + protected + final + sealed + abstract + lazy + implicit + ] } + +rewrite.trailingCommas.style = "never" + +newlines.topLevelStatementBlankLines = [ + { + blanks { before = 1 } + } +] + +# removed tokens because they are deprecated + align.tokens = ["%", "%%", {code = "⇒", owner = "Case"}, ] project.excludeFilters = [ .scalafmt.conf diff --git a/users/build.sbt b/users/build.sbt index 9eb74719..6e35fa9f 100644 --- a/users/build.sbt +++ b/users/build.sbt @@ -1,22 +1,22 @@ -name := "users" -version := "1.0.0" +import sbt._ -scalaVersion := "2.12.3" -scalacOptions ++= Seq( - "-deprecation", - "-encoding", - "UTF-8", - "-feature", - "-language:existentials", - "-language:higherKinds", - "-Ypartial-unification" +inThisBuild( + List( + scalaVersion := "3.3.0", + semanticdbEnabled := true, + name := "users", + version := "1.0.0" + ) ) resolvers += "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots" libraryDependencies ++= Seq( - "com.softwaremill.quicklens" %% "quicklens" % "1.4.11", - "org.typelevel" %% "cats-core" % "1.0.0-MF", - compilerPlugin("org.spire-math" %% "kind-projector" % "0.9.4") -) + Dependencies.CatsCore, + Dependencies.CatsEffect, + Dependencies.QuickLens, + Dependencies.Logback +) ++ Dependencies.Http4s ++ + Dependencies.Circe ++ + Dependencies.Tests diff --git a/users/project/Dependencies.scala b/users/project/Dependencies.scala new file mode 100644 index 00000000..5591cbf6 --- /dev/null +++ b/users/project/Dependencies.scala @@ -0,0 +1,39 @@ +import sbt._ + +object Dependencies { + + object Versions { + val catsCore = "2.9.0" + val catsEffect = "3.5.1" + val quckLens = "1.9.6" + val http4sVersion = "1.0.0-M40" + val logback = "1.2.3" + val circe = "0.14.5" + + val catsEffectScalatest = "1.5.0" + val scalatest = "3.2.16" + } + + lazy val CatsCore = "org.typelevel" %% "cats-core" % Versions.catsCore + lazy val CatsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect + lazy val QuickLens = "com.softwaremill.quicklens" %% "quicklens" % Versions.quckLens + lazy val Logback = "ch.qos.logback" % "logback-classic" % Versions.logback + + lazy val Http4s = Seq( + "org.http4s" %% "http4s-ember-client", + "org.http4s" %% "http4s-ember-server", + "org.http4s" %% "http4s-dsl", + "org.http4s" %% "http4s-circe" + ).map(_ % Versions.http4sVersion) + + lazy val Circe = Seq( + "io.circe" %% "circe-generic", + "io.circe" %% "circe-core", + "io.circe" %% "circe-literal" + ).map(_ % Versions.circe) + + lazy val Tests = Seq( + "org.typelevel" %% "cats-effect-testing-scalatest" % Versions.catsEffectScalatest, + "org.scalatest" %% "scalatest" % Versions.scalatest + ).map(_ % Test) +} diff --git a/users/project/build.properties b/users/project/build.properties index 7b6213bd..875b706a 100644 --- a/users/project/build.properties +++ b/users/project/build.properties @@ -1 +1 @@ -sbt.version=1.0.1 +sbt.version=1.9.2 diff --git a/users/project/plugins.sbt b/users/project/plugins.sbt index ecc02e83..492228c2 100644 --- a/users/project/plugins.sbt +++ b/users/project/plugins.sbt @@ -1 +1,8 @@ -addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "1.3.0") + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.7") +addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.4") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") +addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.2") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.1") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4") diff --git a/users/src/main/resources/logback.xml b/users/src/main/resources/logback.xml new file mode 100644 index 00000000..e9a18d79 --- /dev/null +++ b/users/src/main/resources/logback.xml @@ -0,0 +1,27 @@ + + + + + + + + %d %highlight([%level]) %logger - %msg%n + + + + + logs/api.log + + %d [%thread] %-5level %logger - %msg%n + + + + + + + \ No newline at end of file diff --git a/users/src/main/scala/users/Main.scala b/users/src/main/scala/users/Main.scala index 8c3b23a8..3be87276 100644 --- a/users/src/main/scala/users/Main.scala +++ b/users/src/main/scala/users/Main.scala @@ -1,12 +1,26 @@ package users -import cats.data._ -import cats.implicits._ +import java.time.OffsetDateTime -import users.config._ -import users.main._ +import org.http4s.server.Server +import org.typelevel.log4cats.slf4j.Slf4jFactory +import org.typelevel.log4cats.syntax._ +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.LoggerFactory +import org.typelevel.log4cats.SelfAwareStructuredLogger -object Main extends App { +import cats.* +import cats.effect.* +import cats.implicits.* + +import fs2.io.net.Network +import users.config.* +import users.domain.* +import users.main.* + +object Main extends IOApp: + + private val adminUsername = UserName("admin") val config = ApplicationConfig( executors = ExecutorsConfig( @@ -22,6 +36,42 @@ object Main extends App { ) ) - val application = Application.fromApplicationConfig.run(config) + override def run(args: List[String]): IO[ExitCode] = { + implicit val loggerFactory: LoggerFactory[IO] = Slf4jFactory.create[IO] + implicit val logger: SelfAwareStructuredLogger[IO] = loggerFactory.getLogger + (for + app <- appBuilder[IO](config) + server <- program[IO](app) + admin <- Resource.eval(generateAdmin[IO](app)) + yield server) + .use(_ => IO.never) + .as(ExitCode.Success) + + } + + private def appBuilder[F[_]: Async](config: ApplicationConfig): Resource[F, Application[F]] = + Resource.eval(Application.fromApplicationConfig[F].run(config)) + + private def program[F[_]: Async: Network: LoggerFactory: Logger](app: Application[F]): Resource[F, Server] = + app.services.httpService.server[F](app.services.userManagement) -} + private def generateAdmin[F[_]: Monad: Logger](app: Application[F]): F[User] = + app.services.repositories.userRepository.getByUserName(adminUsername).flatMap { + case Some(u) => u.pure[F] + case None => + for { + _ <- info"Creating admin" + id <- app.services.userManagement.generateId() + now = OffsetDateTime.now + u = User( + id, + UserName("admin"), + EmailAddress("admin@users.com"), + Some(Password("admin")), + User.Metadata(1, now, now, None, None), + isAdmin = true + ) + _ <- app.services.repositories.userRepository.insert(u) + _ <- info"Admin auth info header: Token: ${id.value}" + } yield u + } diff --git a/users/src/main/scala/users/config/config.scala b/users/src/main/scala/users/config/config.scala index 6f6fec2a..8df8348e 100644 --- a/users/src/main/scala/users/config/config.scala +++ b/users/src/main/scala/users/config/config.scala @@ -1,35 +1,45 @@ package users.config -import cats.data._ +import cats.* +import cats.data.* +import cats.implicits.* case class ApplicationConfig( - executors: ExecutorsConfig, - services: ServicesConfig + executors: ExecutorsConfig, + services: ServicesConfig, + httpConfig: HttpConfig = HttpConfig.default ) case class ExecutorsConfig( - services: ExecutorsConfig.ServicesConfig + services: ExecutorsConfig.ServicesConfig ) object ExecutorsConfig { - val fromApplicationConfig: Reader[ApplicationConfig, ExecutorsConfig] = - Reader(_.executors) + + def fromApplicationConfig[F[_]: Applicative]: ReaderT[F, ApplicationConfig, ExecutorsConfig] = + ReaderT(_.executors.pure) case class ServicesConfig( - parallellism: Int + parallellism: Int ) } case class ServicesConfig( - users: ServicesConfig.UsersConfig + users: ServicesConfig.UsersConfig ) object ServicesConfig { - val fromApplicationConfig: Reader[ApplicationConfig, ServicesConfig] = - Reader(_.services) + + def fromApplicationConfig[F[_]: Applicative]: ReaderT[F, ApplicationConfig, ServicesConfig] = + ReaderT(_.services.pure) case class UsersConfig( - failureProbability: Double, - timeoutProbability: Double + failureProbability: Double, + timeoutProbability: Double ) } + +object HttpConfig: + val default = HttpConfig(host = "localhost", port = 8080) + +case class HttpConfig(host: String, port: Int) diff --git a/users/src/main/scala/users/domain/Done.scala b/users/src/main/scala/users/domain/Done.scala index f12a5fbe..c5108543 100644 --- a/users/src/main/scala/users/domain/Done.scala +++ b/users/src/main/scala/users/domain/Done.scala @@ -1,3 +1,3 @@ package users.domain -final case object Done +case object Done diff --git a/users/src/main/scala/users/domain/Protocol.scala b/users/src/main/scala/users/domain/Protocol.scala new file mode 100644 index 00000000..af7a6465 --- /dev/null +++ b/users/src/main/scala/users/domain/Protocol.scala @@ -0,0 +1,30 @@ +package users.domain + +import io.circe.* +import io.circe.derivation.* +import io.circe.generic.semiauto.* + +object Protocol: + + implicit val idCodec: Codec[User.Id] = Codec.from( + Decoder.decodeString.map(User.Id.apply), + Encoder.encodeString.contramap(_.value) + ) + + implicit val userNameCodec: Codec[UserName] = Codec.from( + Decoder.decodeString.map(UserName.apply), + Encoder.encodeString.contramap(_.value) + ) + + implicit val emailAddressCodec: Codec[EmailAddress] = Codec.from( + Decoder.decodeString.map(EmailAddress.apply), + Encoder.encodeString.contramap(_.value) + ) + + implicit val passwordCodec: Codec[Password] = Codec.from( + Decoder.decodeString.map(Password.apply), + Encoder.encodeString.contramap(_.value) + ) + implicit val metadataCodec: Codec[User.Metadata] = deriveCodec[User.Metadata] + + implicit val userCodec: Codec[User] = deriveCodec[User] diff --git a/users/src/main/scala/users/domain/User.scala b/users/src/main/scala/users/domain/User.scala index ff893916..698d8f47 100644 --- a/users/src/main/scala/users/domain/User.scala +++ b/users/src/main/scala/users/domain/User.scala @@ -1,18 +1,22 @@ package users.domain import java.time.OffsetDateTime +import java.util.UUID +import com.softwaremill.quicklens.* + +import cats.implicits.* import cats.kernel.Eq -import cats.implicits._ -import com.softwaremill.quicklens._ final case class User( - id: User.Id, - userName: UserName, - emailAddress: EmailAddress, - password: Option[Password], - metadata: User.Metadata + id: User.Id, + userName: UserName, + emailAddress: EmailAddress, + password: Option[Password], + metadata: User.Metadata, + isAdmin: Boolean = false ) { + def status: User.Status = User.status(this) @@ -40,29 +44,35 @@ final case class User( } object User { + def apply( - id: User.Id, - userName: UserName, - emailAddress: EmailAddress, - password: Option[Password], - at: OffsetDateTime + id: User.Id, + userName: UserName, + emailAddress: EmailAddress, + password: Option[Password], + at: OffsetDateTime ): User = User(id, userName, emailAddress, password, Metadata(1, at, at, None, None)) + object Id { + def gen = Id(UUID.randomUUID().toString()) + } + final case class Id(value: String) extends AnyVal final case class Metadata( - version: Int, - createdAt: OffsetDateTime, - updatedAt: OffsetDateTime, - blockedAt: Option[OffsetDateTime], - deletedAt: Option[OffsetDateTime] + version: Int, + createdAt: OffsetDateTime, + updatedAt: OffsetDateTime, + blockedAt: Option[OffsetDateTime], + deletedAt: Option[OffsetDateTime] ) sealed trait Status + object Status { - final case object Active extends Status - final case object Blocked extends Status - final case object Deleted extends Status + case object Active extends Status + case object Blocked extends Status + case object Deleted extends Status implicit val eq: Eq[Status] = Eq.fromUniversalEquals diff --git a/users/src/main/scala/users/domain/UserName.scala b/users/src/main/scala/users/domain/UserName.scala index bdf862c8..0211b118 100644 --- a/users/src/main/scala/users/domain/UserName.scala +++ b/users/src/main/scala/users/domain/UserName.scala @@ -5,6 +5,7 @@ import cats.kernel.Eq final case class UserName(value: String) extends AnyVal object UserName { + implicit val eq: Eq[UserName] = Eq.fromUniversalEquals } diff --git a/users/src/main/scala/users/http/AdminRoutes.scala b/users/src/main/scala/users/http/AdminRoutes.scala new file mode 100644 index 00000000..0290c9fa --- /dev/null +++ b/users/src/main/scala/users/http/AdminRoutes.scala @@ -0,0 +1,84 @@ +package users.http + +import org.http4s.* +import org.http4s.circe.CirceEntityDecoder.* +import org.http4s.circe.CirceEntityEncoder.* +import org.http4s.dsl.* +import org.http4s.server.Router + +import cats.* +import cats.data.* +import cats.effect.Async +import cats.implicits.* + +import users.domain.User +import users.http.dto.* +import users.http.validation.* +import users.services.usermanagement +import users.services.UserManagement + +object AdminRoutes: + def make[F[_]: Async](service: UserManagement[F]): Routes[F] = AdminRoutes[F](service) + +final class AdminRoutes[F[_]: Async](val userService: UserManagement[F]) + extends Routes[F] + with Http4sDsl[F] + with Auth[F] + with RouteHelpers[F]: + + import users.domain.Protocol.* + + private implicit val idQueryParam: QueryParamDecoder[User.Id] = + QueryParamDecoder[String].map(User.Id.apply) + + private object UserIdParam extends QueryParamDecoderMatcher[User.Id]("id") + + private val pathPrefix = "admin" + + private val adminRoutes = AuthedRoutes.of[User, F] { + case GET -> Root :? UserIdParam(id) as admin => + complete(admin)(userService.get(id)) + case req @ POST -> Root / "update-email" :? UserIdParam(id) as admin => + val f = for + data <- EitherT(req.req.as[UpdateEmail].attempt).leftMap(_ => InvalidRequest: ValidationError) + validated <- EitherT.fromEither(Validation.validateEmail(data.emailAddress)) + user <- EitherT(userService.updateEmail(id, validated)).leftMap(ValidationError.fromPersistenceError) + yield user + f.foldF( + errorToResponse, + u => + Response( + headers = Headers(tokenHeader(admin.id)) + ).withEntity(u.withoutPassword).pure[F] + ) + case POST -> Root / "reset-password" :? UserIdParam(id) as admin => + complete(admin)(userService.resetPassword(id)) + case POST -> Root / "block" :? UserIdParam(id) as admin => + if (id == admin.id) BadRequest("Admin can't block itself") + else complete(admin)(userService.block(id)) + case POST -> Root / "unblock" :? UserIdParam(id) as admin => + if (id == admin.id) BadRequest("Admin can't unblock itself") + else complete(admin)(userService.unblock(id)) + case DELETE -> Root / "delete" :? UserIdParam(id) as admin => + if (id == admin.id) BadRequest("Admin can't delete itself") + else EitherT(userService.delete(id)).foldF(errorToResponse, _ => Ok()) + case GET -> Root / "all" as admin => + EitherT(userService.all()).foldF( + errorToResponse, + users => + Response( + headers = Headers(tokenHeader(admin.id)) + ).withEntity(users.map(_.withoutPassword)).pure[F] + ) + } + + private def complete(admin: User)(func: => F[Either[usermanagement.Error, User]]): F[Response[F]] = + EitherT(func).foldF( + errorToResponse, + user => + Response( + headers = Headers(tokenHeader(admin.id)) + ).withEntity(user.withoutPassword).pure[F] + ) + + val routes = Router(pathPrefix -> adminMiddleware(adminRoutes)) diff --git a/users/src/main/scala/users/http/Auth.scala b/users/src/main/scala/users/http/Auth.scala new file mode 100644 index 00000000..232f6193 --- /dev/null +++ b/users/src/main/scala/users/http/Auth.scala @@ -0,0 +1,54 @@ +package users.http + +import org.http4s.* +import org.http4s.dsl.Http4sDsl +import org.http4s.server.* +import org.typelevel.ci.CIString + +import cats.* +import cats.data.* +import cats.implicits.* + +import users.domain.User +import users.services.UserManagement + +trait Auth[F[_]: Monad] extends Http4sDsl[F]: + + def userService: UserManagement[F] + + private val authUserEither: Kleisli[F, Request[F], Either[String, User]] = + Kleisli { request => + val token = for { + header <- EitherT( + request.headers + .get(CIString("Token")) + .map(_.head.value) + .toRight("Failed to authenticate a user") + .pure[F] + ) + id <- EitherT( + Either + .catchNonFatal(User.Id(header)) + .leftMap(_.toString) + .pure[F] + ) + user <- EitherT(userService.get(id)).leftMap { case _ => + "Can't find the user" + } + } yield user + token.value + } + + private val adminUser = + authUserEither.andThen(Kleisli[F, Either[String, User], Either[String, User]] { + case Left(err) => err.asLeft.pure + case Right(u) if u.isAdmin => u.asRight.pure + case Right(u) => "This user doesn't have access here".asLeft.pure + }) + + private val onFailure: AuthedRoutes[String, F] = + Kleisli(req => OptionT.liftF(Forbidden(req.context))) + + val authMiddleware = AuthMiddleware(authUserEither, onFailure) + + val adminMiddleware = AuthMiddleware(adminUser, onFailure) diff --git a/users/src/main/scala/users/http/RouteHelpers.scala b/users/src/main/scala/users/http/RouteHelpers.scala new file mode 100644 index 00000000..098f50b8 --- /dev/null +++ b/users/src/main/scala/users/http/RouteHelpers.scala @@ -0,0 +1,30 @@ +package users.http + +import org.http4s.dsl.Http4sDsl +import org.http4s.Header +import org.http4s.Response +import org.typelevel.ci.CIString + +import cats.Applicative + +import users.domain.User +import users.http.validation.* +import users.services.usermanagement + +trait RouteHelpers[F[_]: Applicative]: + self: Http4sDsl[F] => + + protected val tokenHeader: User.Id => Header.Raw = id => Header.Raw(CIString("token"), id.value) + + protected def errorToResponse(error: ValidationError | usermanagement.Error): F[Response[F]] = error match + case InvalidEmail => BadRequest(InvalidEmail.error) + case InvalidRequest => BadRequest(InvalidRequest.error) + case UnprocessableRequest(error) => BadRequest(error) + case UserNotFound => BadRequest(UserNotFound.error) + case err: InternalError => UnprocessableEntity(err.error) + case usermanagement.Error.System(underlying) => UnprocessableEntity("Internal error happened") + case usermanagement.Error.Active => BadRequest("The user is active already") + case usermanagement.Error.Blocked => BadRequest("The user is blocked already") + case usermanagement.Error.Deleted => NotFound("The user does not exist") + case usermanagement.Error.Exists => BadRequest("A user with this username is already exist") + case usermanagement.Error.NotFound => NotFound("The user does not exist") diff --git a/users/src/main/scala/users/http/UserRoutes.scala b/users/src/main/scala/users/http/UserRoutes.scala new file mode 100644 index 00000000..d705287b --- /dev/null +++ b/users/src/main/scala/users/http/UserRoutes.scala @@ -0,0 +1,85 @@ +package users.http + +import org.http4s.* +import org.http4s.circe.CirceEntityDecoder.* +import org.http4s.circe.CirceEntityEncoder.* +import org.http4s.dsl +import org.http4s.dsl.Http4sDsl +import org.typelevel.log4cats.Logger + +import cats.* +import cats.data.* +import cats.effect.Async +import cats.implicits.* + +import users.domain.* +import users.http.dto.* +import users.http.validation.* +import users.services.usermanagement.Error +import users.services.UserManagement + +object UserRoutes: + def make[F[_]: Async: Logger](service: UserManagement[F]): Routes[F] = new UserRoutes[F](service) + +final class UserRoutes[F[_]: Async: Logger](val userService: UserManagement[F]) + extends Routes[F] + with Http4sDsl[F] + with Auth[F] + with RouteHelpers[F]: + + import users.domain.Protocol.* + + private val publicRoutes = HttpRoutes.of[F] { case req @ POST -> Root / "signup" => + val f: EitherT[F, ValidationError, User] = for + data <- EitherT(req.as[SignupForm].attempt).leftMap(_ => InvalidRequest: ValidationError) + validated <- EitherT.fromEither(Validation.validateSignupForm(data)) + user <- EitherT(userService.signUp(data.userName, data.emailAddress, data.password)) + .leftMap(ValidationError.fromPersistenceError) + yield user + + f.foldF( + errorToResponse, + u => + Response( + headers = Headers(tokenHeader(u.id)) + ).withEntity(u.short).pure[F] + ) + } + + private val authedRoutes: AuthedRoutes[User, F] = AuthedRoutes.of { + case GET -> Root / "me" as user => Ok(user) + case req @ POST -> Root / "update-email" as user => + val f = for + data <- EitherT(req.req.as[UpdateEmail].attempt).leftMap(_ => InvalidRequest: ValidationError) + validated <- EitherT.fromEither(Validation.validateEmail(data.emailAddress)) + user <- EitherT(userService.updateEmail(user.id, validated)).leftMap(ValidationError.fromPersistenceError) + yield user + f.foldF( + errorToResponse, + u => + Response( + headers = Headers(tokenHeader(u.id)) + ).withEntity(u.short).pure[F] + ) + case req @ POST -> Root / "update-password" as user => + val f = for + data <- EitherT(req.req.as[UpdatePassword].attempt).leftMap(_ => InvalidRequest: ValidationError) + user <- + EitherT(userService.updatePassword(user.id, data.password)).leftMap(ValidationError.fromPersistenceError) + yield user + f.foldF( + errorToResponse, + u => Response(headers = Headers(tokenHeader(u.id))).withEntity(u.short).pure[F] + ) + + case POST -> Root / "reset-password" as user => + val f = + for user <- EitherT(userService.resetPassword(user.id)).leftMap(ValidationError.fromPersistenceError) + yield user + f.foldF( + errorToResponse, + u => Response(headers = Headers(tokenHeader(u.id))).withEntity(u.short).pure[F] + ) + } + + val routes = publicRoutes <+> authMiddleware(authedRoutes) diff --git a/users/src/main/scala/users/http/dto/SignupForm.scala b/users/src/main/scala/users/http/dto/SignupForm.scala new file mode 100644 index 00000000..18118198 --- /dev/null +++ b/users/src/main/scala/users/http/dto/SignupForm.scala @@ -0,0 +1,9 @@ +package users.http.dto + +import users.domain.* + +case class SignupForm( + userName: UserName, + emailAddress: EmailAddress, + password: Option[Password] +) derives ConfiguredCodec diff --git a/users/src/main/scala/users/http/dto/UpdateEmail.scala b/users/src/main/scala/users/http/dto/UpdateEmail.scala new file mode 100644 index 00000000..b716de10 --- /dev/null +++ b/users/src/main/scala/users/http/dto/UpdateEmail.scala @@ -0,0 +1,7 @@ +package users.http.dto + +import users.domain.EmailAddress + +final case class UpdateEmail( + emailAddress: EmailAddress +) derives ConfiguredCodec diff --git a/users/src/main/scala/users/http/dto/UpdatePassword.scala b/users/src/main/scala/users/http/dto/UpdatePassword.scala new file mode 100644 index 00000000..9cd0ce80 --- /dev/null +++ b/users/src/main/scala/users/http/dto/UpdatePassword.scala @@ -0,0 +1,5 @@ +package users.http.dto + +import users.domain.Password + +final case class UpdatePassword(password: Password) derives ConfiguredCodec diff --git a/users/src/main/scala/users/http/dto/UserInfo.scala b/users/src/main/scala/users/http/dto/UserInfo.scala new file mode 100644 index 00000000..77cc3de9 --- /dev/null +++ b/users/src/main/scala/users/http/dto/UserInfo.scala @@ -0,0 +1,9 @@ +package users.http.dto + +import users.domain.* + +final case class UserInfo(username: UserName, email: EmailAddress) derives ConfiguredCodec + +extension (u: User) + def short: UserInfo = UserInfo(u.userName, u.emailAddress) + def withoutPassword: User = u.copy(password = None) diff --git a/users/src/main/scala/users/http/dto/package.scala b/users/src/main/scala/users/http/dto/package.scala new file mode 100644 index 00000000..a94ade5d --- /dev/null +++ b/users/src/main/scala/users/http/dto/package.scala @@ -0,0 +1,10 @@ +package users.http + +import io.circe.derivation.Configuration + +package object dto: + given Configuration = Configuration.default + + export io.circe.derivation.ConfiguredCodec + + export users.domain.Protocol.* diff --git a/users/src/main/scala/users/http/package.scala b/users/src/main/scala/users/http/package.scala new file mode 100644 index 00000000..a043073e --- /dev/null +++ b/users/src/main/scala/users/http/package.scala @@ -0,0 +1,8 @@ +package users + +import org.http4s.HttpRoutes + +package object http: + + trait Routes[F[_]]: + def routes: HttpRoutes[F] diff --git a/users/src/main/scala/users/http/validation/Validation.scala b/users/src/main/scala/users/http/validation/Validation.scala new file mode 100644 index 00000000..89749e92 --- /dev/null +++ b/users/src/main/scala/users/http/validation/Validation.scala @@ -0,0 +1,19 @@ +package users.http.validation + +import cats.implicits.* + +import users.domain.EmailAddress +import users.http.dto.SignupForm + +object Validation: + + private val emailRegex = + """^[a-zA-Z0-9\.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r + + def validateEmail(email: EmailAddress): Either[ValidationError, EmailAddress] = + emailRegex.findFirstIn(email.value) match + case Some(_) => email.asRight[ValidationError] + case None => InvalidEmail.asLeft[EmailAddress] + + def validateSignupForm(form: SignupForm): Either[ValidationError, SignupForm] = + validateEmail(form.emailAddress).map(_ => form) diff --git a/users/src/main/scala/users/http/validation/ValidationError.scala b/users/src/main/scala/users/http/validation/ValidationError.scala new file mode 100644 index 00000000..747f4f04 --- /dev/null +++ b/users/src/main/scala/users/http/validation/ValidationError.scala @@ -0,0 +1,30 @@ +package users.http.validation + +import users.services.usermanagement.Error + +sealed trait ValidationError: + def error: String + +object ValidationError: + + def fromPersistenceError(err: Error): ValidationError = err match + case Error.Active => UnprocessableRequest("The user is active already") + case Error.Blocked => UnprocessableRequest("The user is blocked already") + case Error.Deleted => UnprocessableRequest("The user is deleted already") + case Error.Exists => UnprocessableRequest("Such username is already exists") + case Error.NotFound => UserNotFound + case Error.System(error) => InternalError(error) + +case object InvalidEmail extends ValidationError: + val error = "Email is incorrect" + +case object InvalidRequest extends ValidationError: + val error = "Can't decode json" + +case class UnprocessableRequest(error: String) extends ValidationError + +case object UserNotFound extends ValidationError: + val error = "The user was not found" + +case class InternalError(underlying: Throwable) extends ValidationError: + val error = "We've got an incident." diff --git a/users/src/main/scala/users/main/Application.scala b/users/src/main/scala/users/main/Application.scala index 8f059d35..47eca984 100644 --- a/users/src/main/scala/users/main/Application.scala +++ b/users/src/main/scala/users/main/Application.scala @@ -1,16 +1,17 @@ package users.main -import cats.data._ -import users.config._ +import cats.* +import cats.data.* +import cats.effect.* +import cats.implicits.* -object Application { - val reader: Reader[Services, Application] = - Reader(Application.apply) +import users.config.* - val fromApplicationConfig: Reader[ApplicationConfig, Application] = - Services.fromApplicationConfig andThen reader -} +object Application: -case class Application( - services: Services -) + def reader[F[_]: Async]: ReaderT[F, Services[F], Application[F]] = ReaderT(Application[F].apply(_).pure) + + def fromApplicationConfig[F[_]: Async]: ReaderT[F, ApplicationConfig, Application[F]] = + Services.fromApplicationConfig[F].andThen(reader) + +case class Application[F[_]: Async](services: Services[F]) diff --git a/users/src/main/scala/users/main/Executors.scala b/users/src/main/scala/users/main/Executors.scala index efb244fa..80745afd 100644 --- a/users/src/main/scala/users/main/Executors.scala +++ b/users/src/main/scala/users/main/Executors.scala @@ -1,25 +1,22 @@ package users.main -import cats.data.Reader +import java.util.concurrent.ForkJoinPool -import users.config._ +import cats.* +import cats.data.* +import cats.implicits.* -import java.util.concurrent.ForkJoinPool import scala.concurrent.ExecutionContext -object Executors { - val reader: Reader[ExecutorsConfig, Executors] = - Reader(Executors.apply) +import users.config.* - val fromApplicationConfig: Reader[ApplicationConfig, Executors] = - reader.local[ApplicationConfig](_.executors) -} +object Executors: + def reader[F[_]: Applicative]: ReaderT[F, ExecutorsConfig, Executors] = ReaderT(Executors.apply(_).pure) -final case class Executors( - config: ExecutorsConfig -) { + def fromApplicationConfig[F[_]: Applicative]: ReaderT[F, ApplicationConfig, Executors] = + reader[F].local[ApplicationConfig](_.executors) + +final case class Executors(config: ExecutorsConfig): final val serviceExecutor: ExecutionContext = ExecutionContext.fromExecutor(new ForkJoinPool(config.services.parallellism)) - -} diff --git a/users/src/main/scala/users/main/HttpService.scala b/users/src/main/scala/users/main/HttpService.scala new file mode 100644 index 00000000..50f4a4d1 --- /dev/null +++ b/users/src/main/scala/users/main/HttpService.scala @@ -0,0 +1,46 @@ +package users.main + +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Server +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.LoggerFactory + +import com.comcast.ip4s.{Host, Port} + +import cats.* +import cats.data.* +import cats.effect.* +import cats.syntax.all.* + +import fs2.io.net.Network +import users.config.ApplicationConfig +import users.config.HttpConfig +import users.services.UserManagement + +object HttpService: + def reader[F[_]: Applicative]: ReaderT[F, HttpConfig, HttpService] = ReaderT(HttpService.apply(_).pure) + def fromApplicationConfig[F[_]: Applicative]: ReaderT[F, ApplicationConfig, HttpService] = reader.local(_.httpConfig) + +final case class HttpService(config: HttpConfig): + + def server[F[_]: Async: Network: LoggerFactory: Logger](service: UserManagement[F]): Resource[F, Server] = + for { + host <- Resource.eval( + Async[F].fromOption( + Host.fromString(config.host), + new IllegalArgumentException(s"Wrong host value: ${config.host}") + ) + ) + port <- Resource.eval( + Async[F].fromOption( + Port.fromInt(config.port), + new IllegalArgumentException(s"Wrong port value: ${config.port}") + ) + ) + app <- EmberServerBuilder + .default[F] + .withHost(host) + .withPort(port) + .withHttpApp(Routes.make[F](service)) + .build + } yield app diff --git a/users/src/main/scala/users/main/Repositories.scala b/users/src/main/scala/users/main/Repositories.scala index f29f5089..b99c9b42 100644 --- a/users/src/main/scala/users/main/Repositories.scala +++ b/users/src/main/scala/users/main/Repositories.scala @@ -1,21 +1,19 @@ package users.main -import cats.data.Reader +import cats.data.* +import cats.implicits.* +import cats.Applicative -import users.config._ -import users.persistence.repositories._ +import users.config.* +import users.persistence.repositories.* -object Repositories { - val reader: Reader[Unit, Repositories] = - Reader((_: Unit) ⇒ Repositories()) +object Repositories: - val fromApplicationConfig: Reader[ApplicationConfig, Repositories] = - reader.local[ApplicationConfig](_ ⇒ ()) -} + def reader[F[_]: Applicative]: ReaderT[F, Unit, Repositories[F]] = + ReaderT((_: Unit) => Repositories().pure) -final case class Repositories() { + def fromApplicationConfig[F[_]: Applicative]: ReaderT[F, ApplicationConfig, Repositories[F]] = + reader[F].local[ApplicationConfig](_ => ()) - final val userRepository: UserRepository = - UserRepository.inMemory() - -} +final case class Repositories[F[_]: Applicative](): + val userRepository: UserRepository[F] = UserRepository.inMemory() diff --git a/users/src/main/scala/users/main/Routes.scala b/users/src/main/scala/users/main/Routes.scala new file mode 100644 index 00000000..459eb70b --- /dev/null +++ b/users/src/main/scala/users/main/Routes.scala @@ -0,0 +1,24 @@ +package users.main + +import org.http4s.server.middleware.* +import org.typelevel.log4cats.Logger + +import cats.effect.* +import cats.implicits.* + +import scala.concurrent.duration.* + +import users.http.{AdminRoutes, UserRoutes} +import users.services.UserManagement + +object Routes: + + def make[F[_]: Async: Logger](service: UserManagement[F]) = + ErrorHandling.Recover.total( + Timeout(5.seconds)( + List( + UserRoutes.make[F], + AdminRoutes.make[F] + ).map(_.apply(service)).map(_.routes).reduce(_ <+> _).orNotFound + ) + ) diff --git a/users/src/main/scala/users/main/Services.scala b/users/src/main/scala/users/main/Services.scala index 869d2fcb..a2f246fd 100644 --- a/users/src/main/scala/users/main/Services.scala +++ b/users/src/main/scala/users/main/Services.scala @@ -1,38 +1,41 @@ package users.main -import cats.data._ - -import users.config._ -import users.services._ - -import scala.concurrent.Future - -object Services { - val reader: Reader[(ServicesConfig, Executors, Repositories), Services] = - Reader((Services.apply _).tupled) - - val fromApplicationConfig: Reader[ApplicationConfig, Services] = - (for { - config ← ServicesConfig.fromApplicationConfig - executors ← Executors.fromApplicationConfig - repositories ← Repositories.fromApplicationConfig - } yield (config, executors, repositories)) andThen reader -} - -final case class Services( - config: ServicesConfig, - executors: Executors, - repositories: Repositories -) { - import executors._ - import repositories._ - - implicit val ec = serviceExecutor - - final val userManagement: UserManagement[Future[?]] = - UserManagement.unreliable( - UserManagement.default(userRepository), +import cats.data.* +import cats.effect.* +import cats.implicits.* + +import scala.concurrent.ExecutionContext + +import users.config.* +import users.services.* + +object Services: + + def reader[F[_]: Async]: ReaderT[F, (ServicesConfig, Executors, Repositories[F], HttpService), Services[F]] = + ReaderT(Services[F].apply.tupled(_).pure) + + def fromApplicationConfig[F[_]: Async]: ReaderT[F, ApplicationConfig, Services[F]] = + (for + config <- ServicesConfig.fromApplicationConfig + executors <- Executors.fromApplicationConfig + repositories <- Repositories.fromApplicationConfig + httpService <- HttpService.fromApplicationConfig + yield (config, executors, repositories, httpService)).andThen(reader) + +final case class Services[F[_]: Async]( + config: ServicesConfig, + executors: Executors, + repositories: Repositories[F], + httpService: HttpService +): + + import executors.* + import repositories.* + + implicit val ec: ExecutionContext = serviceExecutor + + final val userManagement: UserManagement[F] = + UserManagement.unreliable[F]( + UserManagement.default[F](userRepository), config.users ) - -} diff --git a/users/src/main/scala/users/persistence/repositories/package.scala b/users/src/main/scala/users/persistence/repositories/package.scala index 8c771ee4..7834420e 100644 --- a/users/src/main/scala/users/persistence/repositories/package.scala +++ b/users/src/main/scala/users/persistence/repositories/package.scala @@ -1,9 +1,7 @@ package users.persistence -package object repositories { +package object repositories: // User Repository - type UserRepository = users.Repository + type UserRepository[F[_]] = users.Repository[F] val UserRepository = users.Repository - -} diff --git a/users/src/main/scala/users/persistence/repositories/users/InMemoryRepository.scala b/users/src/main/scala/users/persistence/repositories/users/InMemoryRepository.scala index c63265f7..eb7b20a4 100644 --- a/users/src/main/scala/users/persistence/repositories/users/InMemoryRepository.scala +++ b/users/src/main/scala/users/persistence/repositories/users/InMemoryRepository.scala @@ -1,37 +1,31 @@ package users.persistence.repositories.users -import cats.implicits._ +import scala.collection.concurrent.TrieMap -import users.domain._ -import users.persistence.repositories._ +import cats.implicits.* +import cats.Applicative -import scala.collection.concurrent.TrieMap -import scala.concurrent.Future +import users.domain.* +import users.persistence.repositories.* -private[users] object InMemoryRepository { - private final val UserMap: TrieMap[User.Id, User] = - TrieMap.empty -} +private[users] object InMemoryRepository: + private final val UserMap: TrieMap[User.Id, User] = TrieMap.empty -private[users] class InMemoryRepository extends UserRepository { - import InMemoryRepository._ +private[users] class InMemoryRepository[F[_]: Applicative] extends UserRepository[F]: + import InMemoryRepository.* - def insert(user: User): Future[Done] = - Future.successful { - UserMap + (user.id → user) - Done - } + def insert(user: User): F[Done] = { + UserMap.update(user.id, user) + Done + }.pure[F] - def get(id: User.Id): Future[Option[User]] = - Future.successful(UserMap.get(id)) + def get(id: User.Id): F[Option[User]] = UserMap.get(id).pure[F] - def getByUserName(userName: UserName): Future[Option[User]] = - Future.successful { - UserMap.collectFirst { - case (_, user) if user.userName === userName ⇒ user + def getByUserName(userName: UserName): F[Option[User]] = + UserMap + .collectFirst { + case (_, user) if user.userName === userName => user } - } + .pure[F] - def all(): Future[List[User]] = - Future.successful(UserMap.values.toList) -} + def all(): F[List[User]] = UserMap.values.toList.pure[F] diff --git a/users/src/main/scala/users/persistence/repositories/users/Repository.scala b/users/src/main/scala/users/persistence/repositories/users/Repository.scala index cc24124b..53afaf16 100644 --- a/users/src/main/scala/users/persistence/repositories/users/Repository.scala +++ b/users/src/main/scala/users/persistence/repositories/users/Repository.scala @@ -1,17 +1,14 @@ package users.persistence.repositories.users -import users.domain._ +import cats.Applicative -import scala.concurrent.Future +import users.domain.* -private[repositories] trait Repository { - def insert(user: User): Future[Done] - def get(id: User.Id): Future[Option[User]] - def getByUserName(userName: UserName): Future[Option[User]] - def all(): Future[List[User]] -} +private[repositories] trait Repository[F[_]]: + def insert(user: User): F[Done] + def get(id: User.Id): F[Option[User]] + def getByUserName(userName: UserName): F[Option[User]] + def all(): F[List[User]] -object Repository { - def inMemory(): Repository = - new InMemoryRepository() -} +object Repository: + def inMemory[F[_]: Applicative](): Repository[F] = new InMemoryRepository[F]() diff --git a/users/src/main/scala/users/services/package.scala b/users/src/main/scala/users/services/package.scala index cb854210..e63b8ed5 100644 --- a/users/src/main/scala/users/services/package.scala +++ b/users/src/main/scala/users/services/package.scala @@ -1,8 +1,5 @@ package users -package object services { - +package object services: type UserManagement[F[_]] = usermanagement.Algebra[F] val UserManagement = usermanagement.Interpreters - -} diff --git a/users/src/main/scala/users/services/usermanagement/Algebra.scala b/users/src/main/scala/users/services/usermanagement/Algebra.scala index 06d34fc7..3a2044e4 100644 --- a/users/src/main/scala/users/services/usermanagement/Algebra.scala +++ b/users/src/main/scala/users/services/usermanagement/Algebra.scala @@ -1,48 +1,30 @@ package users.services.usermanagement -import users.domain._ +import users.domain.* -trait Algebra[F[_]] { - import User._ +trait Algebra[F[_]]: + import User.* def generateId(): F[Id] - def get( - id: Id - ): F[Error Either User] + def get(id: Id): F[Error Either User] def signUp( - userName: UserName, - emailAddress: EmailAddress, - password: Option[Password] + userName: UserName, + emailAddress: EmailAddress, + password: Option[Password] ): F[Error Either User] - def updateEmail( - id: Id, - emailAddress: EmailAddress - ): F[Error Either User] + def updateEmail(id: Id, emailAddress: EmailAddress): F[Error Either User] - def updatePassword( - id: Id, - password: Password - ): F[Error Either User] + def updatePassword(id: Id, password: Password): F[Error Either User] - def resetPassword( - id: Id - ): F[Error Either User] + def resetPassword(id: Id): F[Error Either User] - def block( - id: Id - ): F[Error Either User] + def block(id: Id): F[Error Either User] - def unblock( - id: Id - ): F[Error Either User] + def unblock(id: Id): F[Error Either User] - def delete( - id: Id - ): F[Error Either Done] + def delete(id: Id): F[Error Either Done] def all(): F[Error Either List[User]] - -} diff --git a/users/src/main/scala/users/services/usermanagement/domain.scala b/users/src/main/scala/users/services/usermanagement/domain.scala index c2a6a0f8..ab3dc684 100644 --- a/users/src/main/scala/users/services/usermanagement/domain.scala +++ b/users/src/main/scala/users/services/usermanagement/domain.scala @@ -3,11 +3,11 @@ package users.services.usermanagement import scala.util.control.NoStackTrace sealed trait Error extends Throwable with NoStackTrace -object Error { - final case object Exists extends Error - final case object NotFound extends Error - final case object Active extends Error - final case object Deleted extends Error - final case object Blocked extends Error - final case class System(underlying: Throwable) extends Error -} + +object Error: + case object Exists extends Error + case object NotFound extends Error + case object Active extends Error + case object Deleted extends Error + case object Blocked extends Error + case class System(underlying: Throwable) extends Error diff --git a/users/src/main/scala/users/services/usermanagement/interpreters.scala b/users/src/main/scala/users/services/usermanagement/interpreters.scala index 8b37d477..9c79e699 100644 --- a/users/src/main/scala/users/services/usermanagement/interpreters.scala +++ b/users/src/main/scala/users/services/usermanagement/interpreters.scala @@ -1,239 +1,187 @@ package users.services.usermanagement -import java.util.UUID import java.time.OffsetDateTime +import java.util.UUID + +import scala.util.Random import cats.data.EitherT -import cats.implicits._ -import users.config._ -import users.domain._ -import users.persistence.repositories._ +import cats.effect.Async +import cats.implicits.* +import cats.Monad +import cats.MonadThrow -import scala.concurrent._ -import scala.util.Random +import users.config.* +import users.domain.* +import users.persistence.repositories.* + +object Interpreters: -object Interpreters { - def default( - userRepository: UserRepository - )( - implicit - ec: ExecutionContext - ): Algebra[Future[?]] = + def default[F[_]: Monad](userRepository: UserRepository[F]): Algebra[F] = new DefaultInterpreter(userRepository) - def unreliable( - underlying: Algebra[Future[?]], - config: ServicesConfig.UsersConfig - )( - implicit - ec: ExecutionContext - ): Algebra[Future[?]] = - new UnreliableInterpreter(underlying, config) -} - -final class DefaultInterpreter private[usermanagement] ( - userRepository: UserRepository -)( - implicit - ec: ExecutionContext -) extends Algebra[Future[?]] { - import User._ - - def generateId(): Future[Id] = - Future.successful(Id(UUID.randomUUID().toString)) - - def get( - id: Id - ): Future[Error Either User] = - for { - maybeUser ← userRepository.get(id) + def unreliable[F[_]: Async]( + underlying: Algebra[F[*]], + config: ServicesConfig.UsersConfig + ): Algebra[F[*]] = new UnreliableInterpreter[F](underlying, config) + +final class DefaultInterpreter[F[_]: Monad] private[usermanagement] ( + userRepository: UserRepository[F] +) extends Algebra[F]: + import User.* + + def generateId(): F[Id] = + Id(UUID.randomUUID().toString).pure[F] + + def get(id: Id): F[Error Either User] = + for + maybeUser <- userRepository.get(id) result = maybeUser.toRight(Error.NotFound) - } yield result + yield result def signUp( - userName: UserName, - emailAddress: EmailAddress, - password: Option[Password] - ): Future[Error Either User] = - (for { - maybeUser ← EitherT[Future, Error, Option[User]] { - userRepository.getByUserName(userName).map(Right.apply) - } - id ← EitherT[Future, Error, Id](generateId().map(Right.apply)) - result ← EitherT.fromEither[Future] { - if (maybeUser.nonEmpty) Left(Error.Exists: Error) - else - Right(User(id, userName, emailAddress, password, OffsetDateTime.now())) - } - _ ← EitherT(save(result)) - } yield result).value - - def updateEmail( - id: Id, - emailAddress: EmailAddress - ): Future[Error Either User] = - (for { - user ← EitherT(get(id)) - result ← EitherT.fromEither[Future] { - if (user.isDeleted) Left(Error.Deleted) - else Right(user.updateEmailAddress(emailAddress, OffsetDateTime.now())) - } - _ ← EitherT(save(result)) - } yield result).value - - def updatePassword( - id: Id, - password: Password - ): Future[Error Either User] = - (for { - user ← EitherT(get(id)) - result ← EitherT.fromEither[Future] { - if (user.isDeleted) Left(Error.Deleted) - else Right(user.updatePassword(password, OffsetDateTime.now())) - } - _ ← EitherT(save(result)) - } yield result).value - - def resetPassword( - id: Id - ): Future[Error Either User] = - (for { - user ← EitherT(get(id)) - result ← EitherT.fromEither[Future] { - if (user.isDeleted) Left(Error.Deleted) - else Right(user.resetPassword(OffsetDateTime.now())) - } - _ ← EitherT(save(result)) - } yield result).value - - def block( - id: Id - ): Future[Error Either User] = - (for { - user ← EitherT(get(id)) - result ← EitherT.fromEither[Future] { - if (user.isDeleted) Left(Error.Deleted) - else if (user.isBlocked) Left(Error.Blocked) - else Right(user.block(OffsetDateTime.now)) - } - _ ← EitherT(save(result)) - } yield result).value - - def unblock( - id: Id - ): Future[Error Either User] = - (for { - user ← EitherT(get(id)) - result ← EitherT.fromEither[Future] { - if (user.isDeleted) Left(Error.Deleted) - else if (user.isActive) Left(Error.Active) - else Right(user.unblock(OffsetDateTime.now)) - } - _ ← EitherT(save(result)) - } yield result).value - - def delete( - id: Id - ): Future[Error Either Done] = - (for { - user ← EitherT(get(id)) - result ← EitherT.fromEither[Future] { - if (user.isDeleted) Left(Error.Deleted) - else if (user.isActive) Left(Error.Active) - else Right(user.delete(OffsetDateTime.now)) - } - _ ← EitherT(save(result)) - } yield Done).value - - def all(): Future[Error Either List[User]] = - for { - result ← userRepository.all() - } yield Right(result) - - private def save( - user: User - ): Future[Error Either Done] = - for { - result ← userRepository.insert(user) - } yield Right(result) - -} - -object UnreliableInterpreter { - - private final def nonCompletingFuture[A] = Promise[A]().future - - private def failWithProbability[A](probability: Double)(f: Future[A]) = - if (Random.nextDouble < probability) Future.failed(new Exception) else f - - private def timeoutWithProbability[A](probability: Double)(f: Future[A]) = - if (Random.nextDouble < probability) nonCompletingFuture[A] else f - - private def failOrTimeoutWithProbabilities[A](fail: Double, timeout: Double)(f: Future[A]) = + userName: UserName, + emailAddress: EmailAddress, + password: Option[Password] + ): F[Error Either User] = + (for + maybeUser <- EitherT[F, Error, Option[User]] { + userRepository.getByUserName(userName).map(Right.apply) + } + id <- EitherT[F, Error, Id](generateId().map(Right.apply)) + result <- EitherT.fromEither[F] { + if (maybeUser.nonEmpty) Left(Error.Exists: Error) + else + Right(User(id, userName, emailAddress, password, OffsetDateTime.now())) + } + _ <- EitherT(save(result)) + yield result).value + + def updateEmail(id: Id, emailAddress: EmailAddress): F[Error Either User] = + (for + user <- EitherT(get(id)) + result <- EitherT.fromEither[F] { + if (user.isDeleted) Left(Error.Deleted) + else Right(user.updateEmailAddress(emailAddress, OffsetDateTime.now())) + } + _ <- EitherT(save(result)) + yield result).value + + def updatePassword(id: Id, password: Password): F[Error Either User] = + (for + user <- EitherT(get(id)) + result <- EitherT.fromEither[F] { + if (user.isDeleted) Left(Error.Deleted) + else Right(user.updatePassword(password, OffsetDateTime.now())) + } + _ <- EitherT(save(result)) + yield result).value + + def resetPassword(id: Id): F[Error Either User] = + (for + user <- EitherT(get(id)) + result <- EitherT.fromEither[F] { + if (user.isDeleted) Left(Error.Deleted) + else Right(user.resetPassword(OffsetDateTime.now())) + } + _ <- EitherT(save(result)) + yield result).value + + def block(id: Id): F[Error Either User] = + (for + user <- EitherT(get(id)) + result <- EitherT.fromEither[F] { + if (user.isDeleted) Left(Error.Deleted) + else if (user.isBlocked) Left(Error.Blocked) + else Right(user.block(OffsetDateTime.now)) + } + _ <- EitherT(save(result)) + yield result).value + + def unblock(id: Id): F[Error Either User] = + (for + user <- EitherT(get(id)) + result <- EitherT.fromEither[F] { + if (user.isDeleted) Left(Error.Deleted) + else if (user.isActive) Left(Error.Active) + else Right(user.unblock(OffsetDateTime.now)) + } + _ <- EitherT(save(result)) + yield result).value + + def delete(id: Id): F[Error Either Done] = + (for + user <- EitherT(get(id)) + result <- EitherT.fromEither[F] { + if (user.isDeleted) Left(Error.Deleted) + else if (user.isActive) Left(Error.Active) + else Right(user.delete(OffsetDateTime.now)) + } + _ <- EitherT(save(result)) + yield Done).value + + def all(): F[Error Either List[User]] = + for result <- userRepository.all() + yield result.asRight[Error] + + private def save(user: User): F[Error Either Done] = + for result <- userRepository.insert(user) + yield result.asRight[Error] + +object UnreliableInterpreter: + + private final def nonCompletingFuture[F[_]: Async, A]: F[A] = Async[F].never[A] + + private def failWithProbability[F[_]: MonadThrow, A](probability: Double)(f: F[A]) = + if (Random.nextDouble < probability) MonadThrow[F].raiseError(new Exception) else f + + private def timeoutWithProbability[F[_]: Async, A](probability: Double)(f: F[A]) = + if (Random.nextDouble < probability) nonCompletingFuture[F, A] else f + + private def failOrTimeoutWithProbabilities[F[_]: Async, A](fail: Double, timeout: Double)(f: F[A]) = failWithProbability(fail)(timeoutWithProbability(timeout)(f)) -} -final class UnreliableInterpreter private[usermanagement] ( - underlying: Algebra[Future[?]], - config: ServicesConfig.UsersConfig -)( - implicit - ec: ExecutionContext -) extends Algebra[Future[?]] { - import UnreliableInterpreter._ - import User._ - - def generateId(): Future[Id] = +final class UnreliableInterpreter[F[_]: Async] private[usermanagement] ( + underlying: Algebra[F], + config: ServicesConfig.UsersConfig) + extends Algebra[F]: + + import UnreliableInterpreter.* + import User.* + + def generateId(): F[Id] = underlying.generateId() - def get( - id: Id - ): Future[Error Either User] = + def get(id: Id): F[Error Either User] = failOrTimeout(underlying.get(id)) def signUp( - userName: UserName, - emailAddress: EmailAddress, - password: Option[Password] - ): Future[Error Either User] = + userName: UserName, + emailAddress: EmailAddress, + password: Option[Password] + ): F[Error Either User] = failOrTimeout(underlying.signUp(userName, emailAddress, password)) - def updateEmail( - id: Id, - emailAddress: EmailAddress - ): Future[Error Either User] = + def updateEmail(id: Id, emailAddress: EmailAddress): F[Error Either User] = failOrTimeout(underlying.updateEmail(id, emailAddress)) - def updatePassword( - id: Id, - password: Password - ): Future[Error Either User] = + def updatePassword(id: Id, password: Password): F[Error Either User] = failOrTimeout(underlying.updatePassword(id, password)) - def resetPassword( - id: Id - ): Future[Error Either User] = + def resetPassword(id: Id): F[Error Either User] = failOrTimeout(underlying.resetPassword(id)) - def block( - id: Id - ): Future[Error Either User] = + def block(id: Id): F[Error Either User] = failOrTimeout(underlying.block(id)) - def unblock( - id: Id - ): Future[Error Either User] = + def unblock(id: Id): F[Error Either User] = failOrTimeout(underlying.unblock(id)) - def delete( - id: Id - ): Future[Error Either Done] = + def delete(id: Id): F[Error Either Done] = failOrTimeout(underlying.delete(id)) - def all(): Future[Error Either List[User]] = + def all(): F[Error Either List[User]] = failOrTimeout(underlying.all()) - private def failOrTimeout[A](f: Future[A]): Future[A] = + private def failOrTimeout[A](f: F[A]): F[A] = failOrTimeoutWithProbabilities(config.failureProbability, config.timeoutProbability)(f) - -} diff --git a/users/src/test/scala/users/Mock.scala b/users/src/test/scala/users/Mock.scala new file mode 100644 index 00000000..f6c94e51 --- /dev/null +++ b/users/src/test/scala/users/Mock.scala @@ -0,0 +1,108 @@ +package users + +import java.time.OffsetDateTime + +import scala.collection.concurrent.TrieMap + +import cats.effect.IO +import cats.implicits.* + +import users.domain.Done +import users.domain.EmailAddress +import users.domain.Password +import users.domain.User +import users.domain.User.Id +import users.domain.UserName +import users.services.usermanagement +import users.services.UserManagement + +trait Mock: + + trait HasAdminHelper: + def genAdmin(): IO[User] + + def userManagementMock: UserManagement[IO] with HasAdminHelper = new UserManagement[IO] with HasAdminHelper: + + private val storage: TrieMap[Id, User] = TrieMap.empty + + def genAdmin(): IO[User] = + User( + User.Id.gen, + userName = UserName("admin"), + emailAddress = EmailAddress("admin@test.com"), + None, + User.Metadata(1, OffsetDateTime.now, OffsetDateTime.now, None, None), + isAdmin = true + ).pure[IO].map { u => + storage.put(u.id, u) + u + } + + override def all(): IO[Either[usermanagement.Error, List[User]]] = + storage.values.toList.asRight[usermanagement.Error].pure[IO] + + override def block(id: Id): IO[Either[usermanagement.Error, User]] = + updateAndReturn(id)(_.block(OffsetDateTime.now)) + + override def delete(id: Id): IO[Either[usermanagement.Error, Done]] = + Either.fromOption(storage.remove(id).map(_ => Done), usermanagement.Error.NotFound).pure[IO] + + override def generateId(): IO[Id] = Id.gen.pure[IO] + + override def get(id: Id): IO[Either[usermanagement.Error, User]] = + Either.fromOption(storage.get(id), usermanagement.Error.NotFound).pure[IO] + + override def resetPassword(id: Id): IO[Either[usermanagement.Error, User]] = + updateAndReturn(id)(_.resetPassword(OffsetDateTime.now)) + + override def signUp( + userName: UserName, + emailAddress: EmailAddress, + password: Option[Password]): IO[Either[usermanagement.Error, User]] = + (storage.find(_._2.userName == userName) match + case Some(u) => usermanagement.Error.Exists.asLeft[User] + case None => + val id = Id.gen + val u = User( + id, + userName, + emailAddress, + password, + OffsetDateTime.now() + ) + storage.put(id, u) + u.asRight[usermanagement.Error] + ).pure[IO] + + override def unblock(id: Id): IO[Either[usermanagement.Error, User]] = + updateAndReturn(id)(_.unblock(OffsetDateTime.now)) + + override def updateEmail(id: Id, emailAddress: EmailAddress): IO[Either[usermanagement.Error, User]] = + (storage.get(id) match { + case Some(u) => + storage.find(_._2.emailAddress == emailAddress) match { + case Some(active) if active._1 != id => usermanagement.Error.Exists.asLeft[User] + case _ => + storage.update(id, u.updateEmailAddress(emailAddress, OffsetDateTime.now())) + Either.fromOption( + storage.get(id), + usermanagement.Error.NotFound + ) + } + case None => usermanagement.Error.NotFound.asLeft[User] + }).pure[IO] + + override def updatePassword(id: Id, password: Password): IO[Either[usermanagement.Error, User]] = + updateAndReturn(id)(_.updatePassword(password, OffsetDateTime.now())) + + private def updateAndReturn(id: Id)(updF: User => User): IO[Either[usermanagement.Error, User]] = + Either + .fromOption( + for + user <- storage.get(id) + _ = storage.update(id, updF(user)) + userUpd <- storage.get(id) + yield userUpd, + usermanagement.Error.NotFound + ) + .pure[IO] diff --git a/users/src/test/scala/users/http/AdminRoutesSpec.scala b/users/src/test/scala/users/http/AdminRoutesSpec.scala new file mode 100644 index 00000000..45f0143b --- /dev/null +++ b/users/src/test/scala/users/http/AdminRoutesSpec.scala @@ -0,0 +1,229 @@ +package users.http + +import org.http4s.* +import org.http4s.circe.* +import org.http4s.circe.CirceEntityDecoder.* +import org.http4s.circe.CirceEntityEncoder.* +import org.http4s.dsl.io.* +import org.http4s.implicits.* +import org.scalatest.compatible.Assertion +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpecLike +import org.scalatest.EitherValues +import org.scalatest.OptionValues + +import cats.effect.testing.scalatest.AsyncIOSpec +import cats.effect.IO +import cats.implicits.* + +import users.domain.* +import users.http.dto.* +import users.services.usermanagement +import users.Mock + +class AdminRoutesSpec + extends AsyncWordSpecLike + with AsyncIOSpec + with Matchers + with Mock + with EitherValues + with OptionValues + with Helpers: + + "AdminRoutes" should { + "forbid access to non-admin" in { + val storage = userManagementMock + + (for { + user <- initUser(storage) + request <- + Request(Method.GET, uri"/admin".withQueryParam("id", user.id.value)) + .withHeaders(tokenHeader(user.id)) + .pure[IO] + response <- adminRoutes(storage).run(request) + } yield response).asserting(_.status shouldBe Forbidden) + } + + "return any user" in { + val storage = userManagementMock + val routes = adminRoutes(storage) + + (for + admin <- storage.genAdmin() + user <- initUser(storage) + + adminRequest <- + Request(Method.GET, uri"/admin".withQueryParam("id", admin.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + userRequest <- + Request(Method.GET, uri"/admin".withQueryParam("id", user.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + adminResponse <- routes.run(adminRequest).flatMap(_.as[User]) + userResponse <- routes.run(userRequest).flatMap(_.as[User]) + yield (admin, user, adminResponse, userResponse)).asserting { + case (admin, user, adminUserResponse, userResponse) => + admin.copy(password = None) shouldBe adminUserResponse + user.copy(password = None) shouldBe userResponse + } + } + + "update email" in { + val storage = userManagementMock + val routes = adminRoutes(storage) + val newEmail = EmailAddress("test.new@test.com") + + (for + admin <- storage.genAdmin() + user <- initUser(storage) + + userRequest <- + Request(Method.POST, uri"/admin/update-email".withQueryParam("id", user.id.value)) + .withHeaders(tokenHeader(admin.id)) + .withEntity(UpdateEmail(newEmail)) + .pure[IO] + + userResponse <- routes.run(userRequest).flatMap(_.as[User]) + persistedUser <- storage.get(user.id) + yield (userResponse, persistedUser)).asserting { case (userResponse, persistedUser) => + userResponse.emailAddress shouldBe newEmail + persistedUser.value.emailAddress shouldBe newEmail + } + } + + "reset password" in { + val storage = userManagementMock + val routes = adminRoutes(storage) + + (for + admin <- storage.genAdmin() + user <- initUser(storage).flatMap(u => storage.updatePassword(u.id, Password("123")).map(_.value)) + + userRequest <- + Request(Method.POST, uri"/admin/reset-password".withQueryParam("id", user.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + _ <- routes.run(userRequest).flatMap(_.as[User]) + persistedUser <- storage.get(user.id) + yield persistedUser).asserting { case persistedUser => + persistedUser.value.password shouldBe Symbol("Empty") + } + } + + "fail to block itself" in { + val storage = userManagementMock + (for + admin <- storage.genAdmin() + + userRequest <- + Request(Method.POST, uri"/admin/block".withQueryParam("id", admin.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + response <- adminRoutes(storage).run(userRequest) + yield response).asserting { case response => + response.status shouldBe BadRequest + } + } + + "block user" in { + val storage = userManagementMock + val routes = adminRoutes(storage) + + (for + admin <- storage.genAdmin() + user <- initUser(storage) + + userRequest <- + Request(Method.POST, uri"/admin/block".withQueryParam("id", user.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + _ <- routes.run(userRequest).flatMap(_.as[User]) + persistedUser <- storage.get(user.id) + yield persistedUser).asserting { case persistedUser => + persistedUser.value.isBlocked shouldBe true + } + } + + "unblock user" in { + val storage = userManagementMock + val routes = adminRoutes(storage) + + (for + admin <- storage.genAdmin() + user <- initUser(storage).flatMap(u => storage.block(u.id).map(_.value)) + + userRequest <- + Request(Method.POST, uri"/admin/unblock".withQueryParam("id", user.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + _ <- routes.run(userRequest).flatMap(_.as[User]) + persistedUser <- storage.get(user.id) + yield persistedUser).asserting { case persistedUser => + persistedUser.value.isBlocked shouldBe false + } + } + + "not delete itself" in { + val storage = userManagementMock + + (for + admin <- storage.genAdmin() + userRequest <- + Request(Method.DELETE, uri"/admin/delete".withQueryParam("id", admin.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + response <- adminRoutes(storage).run(userRequest) + yield response).asserting { case response => + response.status shouldBe BadRequest + } + } + + "delete user" in { + val storage = userManagementMock + val routes = adminRoutes(storage) + + (for + admin <- storage.genAdmin() + user <- initUser(storage) + + userRequest <- + Request(Method.DELETE, uri"/admin/delete".withQueryParam("id", user.id.value)) + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + response <- routes.run(userRequest) + persistedUser <- storage.get(user.id) + yield (response, persistedUser)).asserting { case (response, persistedUser) => + response.status shouldBe Ok + persistedUser shouldBe Symbol("Left") + persistedUser.left.value shouldBe usermanagement.Error.NotFound + } + } + + "return all users" in { + val storage = userManagementMock + val routes = adminRoutes(storage) + + (for + admin <- storage.genAdmin() + user <- initUser(storage) + + userRequest <- + Request(Method.GET, uri"/admin/all") + .withHeaders(tokenHeader(admin.id)) + .pure[IO] + + users <- routes.run(userRequest).flatMap(_.as[List[User]]) + persistedUsers <- storage.all() + yield (users, persistedUsers)).asserting { case (users, persistedUsers) => + users should contain theSameElementsAs persistedUsers.value.map(_.withoutPassword) + } + } + } diff --git a/users/src/test/scala/users/http/Helpers.scala b/users/src/test/scala/users/http/Helpers.scala new file mode 100644 index 00000000..213420c0 --- /dev/null +++ b/users/src/test/scala/users/http/Helpers.scala @@ -0,0 +1,31 @@ +package users.http + +import org.http4s.* +import org.scalatest.EitherValues +import org.scalatest.OptionValues +import org.scalatest.Suite +import org.typelevel.ci.CIString +import org.typelevel.log4cats.slf4j.Slf4jLogger +import org.typelevel.log4cats.SelfAwareStructuredLogger + +import cats.effect.testing.scalatest.AsyncIOSpec +import cats.effect.IO + +import users.domain.* +import users.services.UserManagement + +trait Helpers: + self: AsyncIOSpec with EitherValues with OptionValues with Suite => + + implicit val logger: SelfAwareStructuredLogger[IO] = Slf4jLogger.getLogger[IO] + + def userRoutes(storage: UserManagement[IO]) = + UserRoutes.make[IO](storage).routes.orNotFound + + def adminRoutes(storage: UserManagement[IO]) = + AdminRoutes.make[IO](storage).routes.orNotFound + + protected def initUser(service: UserManagement[IO]): IO[User] = + service.signUp(UserName("test-user"), EmailAddress("test@test.com"), None).map(_.value) + + protected def tokenHeader(id: User.Id): Header.Raw = Header.Raw(CIString("token"), id.value) diff --git a/users/src/test/scala/users/http/UserRoutesSpec.scala b/users/src/test/scala/users/http/UserRoutesSpec.scala new file mode 100644 index 00000000..bc8556c1 --- /dev/null +++ b/users/src/test/scala/users/http/UserRoutesSpec.scala @@ -0,0 +1,132 @@ +package users.http + +import org.http4s.* +import org.http4s.circe.* +import org.http4s.circe.CirceEntityDecoder.* +import org.http4s.circe.CirceEntityEncoder.* +import org.http4s.dsl.io.* +import org.http4s.implicits.* +import org.scalatest.compatible.Assertion +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpecLike +import org.scalatest.EitherValues +import org.scalatest.OptionValues +import org.typelevel.ci.CIString + +import cats.effect.testing.scalatest.AsyncIOSpec +import cats.effect.IO +import cats.implicits.* +import io.circe.* +import io.circe.literal.* + +import users.domain.* +import users.http.dto.* +import users.Mock + +class UserRoutesSpec + extends AsyncWordSpecLike + with AsyncIOSpec + with Matchers + with Mock + with EitherValues + with OptionValues + with Helpers: + + "UserRoutes" should { + "return Forbidden for auth protected urls" in { + userRoutes(userManagementMock) + .run(Request[IO](Method.GET, uri"/me")) + .asserting(_.status shouldBe Forbidden) + } + + "return BadRequest with error for incorrect json" in { + userRoutes(userManagementMock) + .run(Request[IO](Method.POST, uri"/signup").withEntity(json"""{"test": "json"}""")) + .asserting(_.status shouldBe BadRequest) + } + + "return BadRequest with error for incorrect email" in { + val signupForm = SignupForm( + UserName("test-user"), + EmailAddress("test-test.com"), + None + ) + + userRoutes(userManagementMock) + .run(Request[IO](Method.POST, uri"/signup").withEntity[IO, SignupForm](signupForm)) + .asserting(_.status shouldBe BadRequest) + } + + "create a user" in { + val storage = userManagementMock + val signupForm = SignupForm( + UserName("test-user"), + EmailAddress("test@test.com"), + None + ) + + (for + response <- userRoutes(storage).run(Request[IO](Method.POST, uri"/signup").withEntity(signupForm)) + id = User.Id(response.headers.get(CIString("token")).value.head.value) + persistedUser <- storage.get(id) + userResponse <- response.as[UserInfo] + yield (persistedUser, userResponse)).asserting { case (persistedUser, userResponse) => + userResponse.email shouldBe signupForm.emailAddress + userResponse.username shouldBe signupForm.userName + persistedUser.value.short shouldBe userResponse + } + } + + "update email for a user" in { + val storage = userManagementMock + + val newEmail = EmailAddress("test.new@test.com") + + (for + user <- initUser(storage) + req <- Request[IO](Method.POST, uri"/update-email") + .withEntity(UpdateEmail(newEmail)) + .withHeaders(tokenHeader(user.id)) + .pure[IO] + userInfo <- userRoutes(storage).run(req).flatMap(_.as[UserInfo]) + yield userInfo).asserting { r => + r.email shouldBe newEmail + } + } + + "update password for a user" in { + val storage = userManagementMock + + val newPassword = Password("123") + + (for + user <- initUser(storage) + req <- Request[IO](Method.POST, uri"/update-password") + .withEntity(UpdatePassword(newPassword)) + .withHeaders(tokenHeader(user.id)) + .pure[IO] + response <- userRoutes(storage).run(req) + updatedUser <- storage.get(user.id).map(_.value) + yield (response, updatedUser)).asserting { case (r, updatedUser) => + r.status shouldBe Ok + updatedUser.password.value shouldBe newPassword + } + } + + "reset password for a user" in { + val storage = userManagementMock + + (for + user <- initUser(storage) + _ <- storage.updatePassword(user.id, Password("123")).map(_.value) + req <- Request[IO](Method.POST, uri"/reset-password") + .withHeaders(tokenHeader(user.id)) + .pure[IO] + response <- userRoutes(storage).run(req) + updatedUser <- storage.get(user.id).map(_.value) + yield (response, updatedUser)).asserting { case (r, updatedUser) => + r.status shouldBe Ok + updatedUser.password shouldBe Symbol("Empty") + } + } + }