├── project └── build.properties ├── src └── main │ └── scala │ ├── auth │ ├── JwtDecodingError.scala │ ├── JwtEncoder.scala │ ├── JwtDecoder.scala │ ├── JwtDecoderLive.scala │ └── JwtEncoderLive.scala │ ├── db │ ├── mongo │ │ ├── DBConfig.scala │ │ ├── DatabaseInitializer.scala │ │ ├── DataSource.scala │ │ ├── DatabaseContext.scala │ │ ├── DataSourceLive.scala │ │ └── MongoDatabaseInitializer.scala │ ├── DbSuccess.scala │ ├── DbError.scala │ ├── user │ │ ├── UserRepository.scala │ │ └── UserRepositoryLive.scala │ ├── package.scala │ ├── Repository.scala │ └── note │ │ ├── NotesRepository.scala │ │ └── NotesRepositoryLive.scala │ ├── domain │ ├── package.scala │ └── Domain.scala │ ├── sort │ ├── SortService.scala │ ├── SortOrder.scala │ └── SortNoteService.scala │ ├── server │ ├── endpoint │ │ ├── note │ │ │ ├── HasRoute.scala │ │ │ ├── NoteEndpointDefinitions.scala │ │ │ ├── UpdateNoteEndpointLive.scala │ │ │ ├── GetAllNotesEndpointLive.scala │ │ │ ├── GetNoteEndpointLive.scala │ │ │ ├── SearchNoteEndpointLive.scala │ │ │ ├── CreateNoteEndpointLive.scala │ │ │ ├── SortNoteEndpointLive.scala │ │ │ └── DeleteNoteEndpointLive.scala │ │ └── user │ │ │ ├── UserEndpointDefinitions.scala │ │ │ ├── LoginEndpointLive.scala │ │ │ └── SignupEndpointLive.scala │ ├── middleware │ │ ├── RequestContextManager.scala │ │ ├── JwtValidatorMiddleware.scala │ │ ├── RequestContextMiddleware.scala │ │ ├── RequestContextManagerLive.scala │ │ ├── RequestContext.scala │ │ └── JwtValidatorMiddlewareLive.scala │ └── NotesServer.scala │ ├── search │ ├── SearchService.scala │ ├── SearchCriteria.scala │ └── SearchNoteService.scala │ ├── hash │ ├── PasswordHashService.scala │ └── SecureHashService.scala │ ├── route │ ├── service │ │ ├── package.scala │ │ ├── GetAllNotesServiceLive.scala │ │ ├── CreateNoteServiceLive.scala │ │ ├── DeleteNoteServiceLive.scala │ │ ├── UpdateNoteServiceLive.scala │ │ ├── GetNoteServiceLive.scala │ │ ├── ServiceDefinitions.scala │ │ ├── SignupServiceLive.scala │ │ └── LoginServiceLive.scala │ └── handler │ │ ├── GetNoteHandlerLive.scala │ │ ├── package.scala │ │ ├── GetAllNotesHandlerLive.scala │ │ ├── DeleteNoteHandlerLive.scala │ │ ├── RequestHandlerDefinitions.scala │ │ ├── UpdateNoteHandlerLive.scala │ │ ├── SignupHandlerLive.scala │ │ ├── SortNoteHandlerLive.scala │ │ ├── CreateNoteHandlerLive.scala │ │ ├── LoginHandlerLive.scala │ │ └── SearchNoteHandlerLive.scala │ └── Main.scala ├── .gitignore └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.7.1 2 | -------------------------------------------------------------------------------- /src/main/scala/auth/JwtDecodingError.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | final case class JwtDecodingError(msg: String) 4 | -------------------------------------------------------------------------------- /src/main/scala/db/mongo/DBConfig.scala: -------------------------------------------------------------------------------- 1 | package db.mongo 2 | 3 | final case class DBConfig(port: String, name: String) -------------------------------------------------------------------------------- /src/main/scala/domain/package.scala: -------------------------------------------------------------------------------- 1 | package object domain: 2 | 3 | extension[A](a: A) 4 | def some: Option[A] = Some(a) 5 | -------------------------------------------------------------------------------- /src/main/scala/auth/JwtEncoder.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import route.service.ServiceDefinitions.LoginService.JWT 4 | 5 | trait JwtEncoder[A]: 6 | def encode(a: A): JWT 7 | -------------------------------------------------------------------------------- /src/main/scala/sort/SortService.scala: -------------------------------------------------------------------------------- 1 | package scala.sort 2 | 3 | import zio.Task 4 | 5 | trait SortService[A]: 6 | def sort(sortOrder: SortOrder, userId: Long): Task[List[A]] 7 | 8 | -------------------------------------------------------------------------------- /src/main/scala/auth/JwtDecoder.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import domain.Domain.JwtContent 4 | 5 | trait JwtDecoder: 6 | def decode(token: String): Either[JwtDecodingError, JwtContent] 7 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/HasRoute.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import zhttp.http.HttpApp 4 | 5 | trait HasRoute[Env]: 6 | def route: HttpApp[Env, Throwable] 7 | 8 | -------------------------------------------------------------------------------- /src/main/scala/db/mongo/DatabaseInitializer.scala: -------------------------------------------------------------------------------- 1 | package db.mongo 2 | 3 | import zio.RIO 4 | 5 | trait DatabaseInitializer: 6 | 7 | def initialize(DBConfig: DBConfig): RIO[DataSource, Unit] 8 | -------------------------------------------------------------------------------- /src/main/scala/search/SearchService.scala: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import zio.Task 4 | 5 | trait SearchService[A]: 6 | def searchByTitle(title: String, searchCriteria: SearchCriteria, userId: Long): Task[Either[String, List[A]]] 7 | 8 | -------------------------------------------------------------------------------- /src/main/scala/hash/PasswordHashService.scala: -------------------------------------------------------------------------------- 1 | package hash 2 | 3 | import zio._ 4 | 5 | trait PasswordHashService: 6 | 7 | def hash(password: String): String 8 | 9 | def validate(password: String, hashedPassword: String): Boolean 10 | -------------------------------------------------------------------------------- /src/main/scala/server/middleware/RequestContextManager.scala: -------------------------------------------------------------------------------- 1 | package server.middleware 2 | 3 | import zio.UIO 4 | 5 | trait RequestContextManager: 6 | 7 | def setCtx(ctx: RequestContext): UIO[Unit] 8 | 9 | def getCtx: UIO[RequestContext] 10 | -------------------------------------------------------------------------------- /src/main/scala/db/mongo/DataSource.scala: -------------------------------------------------------------------------------- 1 | package db.mongo 2 | 3 | import org.mongodb.scala.MongoDatabase 4 | import zio.{UIO, ZIO} 5 | 6 | trait DataSource: 7 | 8 | def setCtx(ctx: DatabaseContext): UIO[Unit] 9 | 10 | def getCtx: UIO[DatabaseContext] 11 | -------------------------------------------------------------------------------- /src/main/scala/db/DbSuccess.scala: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | enum DbSuccess(val msg: String): 4 | 5 | case Created(override val msg: String) extends DbSuccess(msg) 6 | case Updated(override val msg: String) extends DbSuccess(msg) 7 | case Deleted(override val msg: String) extends DbSuccess(msg) -------------------------------------------------------------------------------- /src/main/scala/server/middleware/JwtValidatorMiddleware.scala: -------------------------------------------------------------------------------- 1 | package server.middleware 2 | 3 | import zhttp.http.{Middleware, Request, Response} 4 | 5 | trait JwtValidatorMiddleware: 6 | 7 | def validate: Middleware[RequestContextManager, Nothing, Request, Response, Request, Response] 8 | -------------------------------------------------------------------------------- /src/main/scala/db/DbError.scala: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | enum DbError(val msg: String): 4 | 5 | case ReasonUnknown(override val msg: String) extends DbError(msg) 6 | case InvalidId(override val msg: String) extends DbError(msg) 7 | case NotFound(override val msg: String) extends DbError(msg) 8 | -------------------------------------------------------------------------------- /src/main/scala/sort/SortOrder.scala: -------------------------------------------------------------------------------- 1 | package scala.sort 2 | 3 | enum SortOrder: 4 | self => 5 | 6 | case Ascending 7 | case Descending 8 | 9 | def fold[A](ifAscending: => A)(ifDescending: => A): A = self match 10 | case SortOrder.Ascending => ifAscending 11 | case SortOrder.Descending => ifDescending -------------------------------------------------------------------------------- /src/main/scala/db/user/UserRepository.scala: -------------------------------------------------------------------------------- 1 | package db.user 2 | 3 | import db.Repository 4 | import domain.Domain.User 5 | import zio.Task 6 | 7 | trait UserRepository extends Repository[User] : 8 | 9 | def userExists(email: String): Task[Boolean] 10 | 11 | def getUserByEmail(email: String): Task[Option[User]] 12 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/user/UserEndpointDefinitions.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.user 2 | 3 | import zhttp.http.HttpApp 4 | 5 | object UserEndpointDefinitions: 6 | 7 | trait LoginEndpoint: 8 | def route: HttpApp[Any, Throwable] 9 | 10 | trait SignupEndpoint: 11 | def route: HttpApp[Any, Throwable] -------------------------------------------------------------------------------- /src/main/scala/db/mongo/DatabaseContext.scala: -------------------------------------------------------------------------------- 1 | package db.mongo 2 | 3 | import org.mongodb.scala.MongoDatabase 4 | 5 | final case class DatabaseContext(mongoDatabase: Option[MongoDatabase]) 6 | 7 | object DatabaseContext: 8 | 9 | def initial: DatabaseContext = new DatabaseContext(None) 10 | 11 | def apply(mongoDatabase: MongoDatabase): DatabaseContext = new DatabaseContext(Some(mongoDatabase)) -------------------------------------------------------------------------------- /src/main/scala/server/middleware/RequestContextMiddleware.scala: -------------------------------------------------------------------------------- 1 | package server.middleware 2 | 3 | import auth.JwtDecoderLive 4 | import zhttp.http.{Middleware, Request, Response} 5 | 6 | object RequestContextMiddleware: 7 | 8 | final lazy val jwtAuthMiddleware: Middleware[RequestContextManager, Nothing, Request, Response, Request, Response] = 9 | JwtValidatorMiddlewareLive(JwtDecoderLive()).validate 10 | -------------------------------------------------------------------------------- /src/main/scala/search/SearchCriteria.scala: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | enum SearchCriteria: 4 | self => 5 | 6 | case Exact 7 | case NonExact 8 | 9 | def fold[A](ifExact: => A)(ifNonExact: => A): A = self match 10 | case Exact => ifExact 11 | case NonExact => ifNonExact 12 | 13 | object SearchCriteria: 14 | 15 | def exact: SearchCriteria = Exact 16 | def nonExact: SearchCriteria = NonExact 17 | -------------------------------------------------------------------------------- /src/main/scala/db/package.scala: -------------------------------------------------------------------------------- 1 | import db.Repository.DBResult 2 | import org.mongodb.scala.result.{DeleteResult, InsertOneResult, UpdateResult} 3 | import zio.* 4 | 5 | package object db: 6 | 7 | extension [A <: DeleteResult | InsertOneResult | UpdateResult] (self: A) 8 | def fold(wasAcknowledged: => Boolean, onSuccess: => DbSuccess, onFailure: => DbError): UIO[DBResult] = 9 | ZIO.succeed(if wasAcknowledged then Right(onSuccess) else Left(onFailure)) 10 | 11 | -------------------------------------------------------------------------------- /src/main/scala/db/Repository.scala: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import db.DbError.InvalidId 4 | import db.Repository.* 5 | import zio.* 6 | 7 | trait Repository[A]: 8 | 9 | def add(newRecord: A): Task[DBResult] 10 | 11 | def getById(id: Long): Task[Option[A]] 12 | 13 | def update(id: Long, newRecord: A): Task[DBResult] 14 | 15 | def delete(id: Long): Task[DBResult] 16 | 17 | object Repository: 18 | 19 | type DBResult = Either[DbError, DbSuccess] 20 | 21 | -------------------------------------------------------------------------------- /src/main/scala/route/service/package.scala: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import db.{DbError, DbSuccess} 4 | 5 | import scala.util.Either 6 | 7 | package object service: 8 | 9 | extension[A] (value: A) 10 | def inQuotes: String = s"`$value`" 11 | 12 | extension (dbErrorOrSuccess: Either[DbError, DbSuccess]) 13 | def toDBResultMessage: Either[String, String] = 14 | dbErrorOrSuccess.fold( 15 | err => Left(err.msg), 16 | success => Right(success.msg) 17 | ) 18 | 19 | -------------------------------------------------------------------------------- /src/main/scala/db/note/NotesRepository.scala: -------------------------------------------------------------------------------- 1 | package db.note 2 | 3 | import db.Repository 4 | import db.Repository.DBResult 5 | import domain.Domain.Note 6 | import zio.Task 7 | 8 | trait NotesRepository extends Repository[Note] : 9 | 10 | def getAll: Task[List[Note]] 11 | 12 | def getNotesByUserId(userId: Long): Task[List[Note]] 13 | 14 | def getNoteByIdAndUserId(noteId: Long, userId: Long): Task[Option[Note]] 15 | 16 | def deleteNoteByIdAndUserId(noteId: Long, userId: Long): Task[DBResult] 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # bloop and metals 2 | .bloop 3 | .bsp 4 | .metals 5 | project/metals.sbt 6 | 7 | # vs code 8 | .vscode 9 | 10 | # scala 3 11 | .tasty 12 | 13 | # sbt 14 | project/project/ 15 | project/target/ 16 | target/ 17 | 18 | # eclipse 19 | build/ 20 | .classpath 21 | .project 22 | .settings 23 | .worksheet 24 | bin/ 25 | .cache 26 | 27 | # intellij idea 28 | *.log 29 | *.iml 30 | *.ipr 31 | *.iws 32 | .idea 33 | 34 | # mac 35 | .DS_Store 36 | 37 | # other? 38 | .history 39 | .scala_dependencies 40 | .cache-main 41 | 42 | # general 43 | *.class -------------------------------------------------------------------------------- /src/main/scala/db/mongo/DataSourceLive.scala: -------------------------------------------------------------------------------- 1 | package db.mongo 2 | 3 | import zio.{Ref, UIO, ULayer, ZLayer} 4 | 5 | final case class DataSourceLive(ref: Ref[DatabaseContext]) extends DataSource: 6 | 7 | override def setCtx(ctx: DatabaseContext): UIO[Unit] = ref.set(ctx) 8 | 9 | override def getCtx: UIO[DatabaseContext] = ref.get 10 | 11 | 12 | object DataSourceLive: 13 | 14 | def layer: ULayer[DataSource] = ZLayer.scoped { 15 | for 16 | ref <- Ref.make[DatabaseContext](DatabaseContext.initial) 17 | yield DataSourceLive(ref) 18 | } -------------------------------------------------------------------------------- /src/main/scala/hash/SecureHashService.scala: -------------------------------------------------------------------------------- 1 | package hash 2 | 3 | import io.github.nremond.legacy.SecureHash 4 | import zio._ 5 | 6 | final case class SecureHashService() extends PasswordHashService: 7 | 8 | private final val underlyingImpl: SecureHash = SecureHash() 9 | 10 | override def hash(password: String): String = underlyingImpl createHash password 11 | 12 | override def validate(password: String, hashedPassword: String): Boolean = underlyingImpl.validatePassword(password, hashedPassword) 13 | 14 | object SecureHashService: 15 | 16 | lazy val layer: ULayer[PasswordHashService] = 17 | ZLayer.succeed(SecureHashService()) 18 | 19 | -------------------------------------------------------------------------------- /src/main/scala/server/middleware/RequestContextManagerLive.scala: -------------------------------------------------------------------------------- 1 | package server.middleware 2 | 3 | import zio.{FiberRef, UIO, ULayer, ZLayer} 4 | 5 | final case class RequestContextManagerLive(ref: FiberRef[RequestContext]) extends RequestContextManager: 6 | 7 | override def setCtx(ctx: RequestContext): UIO[Unit] = ref set ctx 8 | 9 | override def getCtx: UIO[RequestContext] = ref.get 10 | 11 | 12 | object RequestContextManagerLive: 13 | 14 | def layer: ULayer[RequestContextManager] = ZLayer.scoped { 15 | for 16 | ref <- FiberRef.make[RequestContext](RequestContext.initial) 17 | yield RequestContextManagerLive(ref) 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/NoteEndpointDefinitions.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import server.middleware.RequestContextManager 4 | 5 | object NoteEndpointDefinitions: 6 | trait GetAllNotesEndpoint extends HasRoute[RequestContextManager] 7 | trait SearchNoteEndpoint extends HasRoute[RequestContextManager] 8 | trait DeleteNoteEndpoint extends HasRoute[RequestContextManager] 9 | trait CreateNoteEndpoint extends HasRoute[RequestContextManager] 10 | trait UpdateNoteEndpoint extends HasRoute[Any] 11 | trait SortNoteEndpoint extends HasRoute[RequestContextManager] 12 | trait GetNoteEndpoint extends HasRoute[RequestContextManager] 13 | 14 | -------------------------------------------------------------------------------- /src/main/scala/route/service/GetAllNotesServiceLive.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import db.* 4 | import db.note.NotesRepository 5 | import zhttp.http.Response 6 | import zio.* 7 | import zio.json.* 8 | import ServiceDefinitions.GetAllNotesService 9 | import domain.Domain.Note 10 | 11 | final case class GetAllNotesServiceLive private(private val notesRepository: NotesRepository) extends GetAllNotesService: 12 | 13 | override def getNotesByUserId(userId: Long): Task[List[Note]] = 14 | notesRepository getNotesByUserId userId 15 | 16 | 17 | object GetAllNotesServiceLive: 18 | 19 | lazy val layer: URLayer[NotesRepository, GetAllNotesService] = 20 | ZLayer.fromFunction(GetAllNotesServiceLive.apply) 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/GetNoteHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import zhttp.http.Response 4 | import zio.* 5 | import zio.json.* 6 | import zhttp.http.Status 7 | import RequestHandlerDefinitions.GetNoteHandler 8 | import route.service.GetNoteServiceLive 9 | import route.service.ServiceDefinitions.GetNoteService 10 | 11 | final case class GetNoteHandlerLive(getNoteService: GetNoteService) extends GetNoteHandler: 12 | 13 | override def handle(noteId: Long, userId: Long): Task[Response] = 14 | getNoteService.getNote(noteId, userId) 15 | .map(_.notFoundOrFound) 16 | 17 | 18 | object GetNoteHandlerLive: 19 | 20 | lazy val layer: URLayer[GetNoteService, GetNoteHandler] = ZLayer.fromFunction(GetNoteHandlerLive.apply) -------------------------------------------------------------------------------- /src/main/scala/route/service/CreateNoteServiceLive.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import db.note.{NotesRepository, NotesRepositoryLive} 4 | import zhttp.http.Response 5 | import zio.* 6 | import zio.json.* 7 | import ServiceDefinitions.CreateNoteService 8 | import domain.Domain.Note 9 | 10 | final case class CreateNoteServiceLive(private val notesRepository: NotesRepository) extends CreateNoteService: 11 | 12 | override def createNote(note: Note): Task[Either[String, String]] = 13 | notesRepository.add(note) 14 | .map(_.toDBResultMessage) 15 | 16 | 17 | object CreateNoteServiceLive: 18 | 19 | val layer: URLayer[NotesRepository, CreateNoteService] = 20 | ZLayer.fromFunction(CreateNoteServiceLive.apply) 21 | 22 | -------------------------------------------------------------------------------- /src/main/scala/server/middleware/RequestContext.scala: -------------------------------------------------------------------------------- 1 | package server.middleware 2 | 3 | import io.netty.handler.codec.http.HttpHeaders 4 | import pdi.jwt.{Jwt, JwtAlgorithm} 5 | import auth.{JwtDecoder, JwtDecoderLive} 6 | import domain.Domain.JwtContent 7 | import zio.* 8 | import zio.json.* 9 | import zhttp.http.* 10 | import zhttp.service.Server 11 | import zhttp.http.middleware.Auth 12 | 13 | case class RequestContext(jwtContent: Option[JwtContent]): 14 | 15 | def getJwtOrFail: Either[Task[Response], JwtContent] = jwtContent.fold(Left(ZIO.succeed(Response.text("Auth failed").setStatus(Status.Unauthorized))))(Right(_)) 16 | 17 | 18 | object RequestContext: 19 | 20 | def initial: RequestContext = new RequestContext(None) 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/main/scala/route/service/DeleteNoteServiceLive.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import db.Repository.* 4 | import db.note.{NotesRepository, NotesRepositoryLive} 5 | import zhttp.http.Response 6 | import zio.* 7 | import ServiceDefinitions.DeleteNoteService 8 | 9 | final case class DeleteNoteServiceLive(private val notesRepository: NotesRepository) extends DeleteNoteService: 10 | 11 | override def deleteRecord(noteId: Long, userId: Long): Task[Either[String, String]] = 12 | notesRepository.deleteNoteByIdAndUserId(noteId, userId) 13 | .map(_.toDBResultMessage) 14 | 15 | object DeleteNoteServiceLive: 16 | 17 | lazy val layer: URLayer[NotesRepository, DeleteNoteService] = 18 | ZLayer.fromFunction(DeleteNoteServiceLive.apply) 19 | 20 | -------------------------------------------------------------------------------- /src/main/scala/route/service/UpdateNoteServiceLive.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import db.note.{NotesRepository, NotesRepositoryLive} 4 | import ServiceDefinitions.UpdateNoteService 5 | import domain.Domain.Note 6 | import zhttp.http.Response 7 | import zio.* 8 | import zio.json.* 9 | 10 | final case class UpdateNoteServiceLive(private val notesRepository: NotesRepository) extends UpdateNoteService: 11 | 12 | override def updateNote(noteId: Long, note: Note): Task[Either[String, String]] = 13 | notesRepository.update(noteId, note.copy(id = Some(noteId))) 14 | .map(_.toDBResultMessage) 15 | 16 | object UpdateNoteServiceLive: 17 | 18 | lazy val layer: URLayer[NotesRepository, UpdateNoteService] = ZLayer.fromFunction(UpdateNoteServiceLive.apply) 19 | 20 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/user/LoginEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.user 2 | 3 | import route.handler.RequestHandlerDefinitions.LoginHandler 4 | import route.service.LoginServiceLive 5 | import server.NotesServer 6 | import server.endpoint.user.UserEndpointDefinitions.LoginEndpoint 7 | import zhttp.http.* 8 | import zio.* 9 | 10 | final case class LoginEndpointLive(loginHandler: LoginHandler) extends LoginEndpoint: 11 | 12 | override lazy val route: HttpApp[Any, Throwable] = Http.collectZIO[Request] { 13 | case request@Method.POST -> !! / "api" / "user" / "login" => loginHandler handle request 14 | } 15 | 16 | object LoginEndpointLive: 17 | 18 | lazy val layer: URLayer[LoginHandler, LoginEndpoint] = 19 | ZLayer.fromFunction(LoginEndpointLive.apply) 20 | 21 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/user/SignupEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.user 2 | 3 | import route.handler.* 4 | import route.handler.RequestHandlerDefinitions.SignupHandler 5 | import route.service.SignupServiceLive 6 | import server.endpoint.user.UserEndpointDefinitions.SignupEndpoint 7 | import zhttp.http.* 8 | import zio.* 9 | 10 | final case class SignupEndpointLive(signupHandler: SignupHandler) extends SignupEndpoint: 11 | 12 | override lazy val route: HttpApp[Any, Throwable] = Http.collectZIO[Request] { 13 | case request@Method.POST -> !! / "api" / "user" / "signup" => signupHandler handle request 14 | } 15 | 16 | 17 | object SignupEndpointLive: 18 | 19 | lazy val layer: URLayer[SignupHandler, SignupEndpoint] = 20 | ZLayer.fromFunction(SignupEndpointLive.apply) 21 | -------------------------------------------------------------------------------- /src/main/scala/route/service/GetNoteServiceLive.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import db.* 4 | import db.note.NotesRepository 5 | import zhttp.http.Response 6 | import zio.* 7 | import zio.json.* 8 | import ServiceDefinitions.GetNoteService 9 | import domain.Domain.Note 10 | 11 | final case class GetNoteServiceLive(private val notesRepository: NotesRepository) extends GetNoteService: 12 | 13 | override def getNote(noteId: Long, userId: Long): Task[Either[String, Note]] = 14 | notesRepository 15 | .getNoteByIdAndUserId(noteId, userId) 16 | .map(_.fold(Left(s"Could not find the note with id ${noteId.inQuotes}"))(Right(_))) 17 | 18 | object GetNoteServiceLive: 19 | 20 | lazy val layer: URLayer[NotesRepository, GetNoteService] = 21 | ZLayer.fromFunction(GetNoteServiceLive.apply) -------------------------------------------------------------------------------- /src/main/scala/auth/JwtDecoderLive.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import domain.Domain.JwtContent 4 | import pdi.jwt.{Jwt, JwtAlgorithm} 5 | import zio.{ULayer, ZLayer} 6 | import zio.json.* 7 | 8 | final case class JwtDecoderLive() extends JwtDecoder: 9 | 10 | override def decode(token: String): Either[JwtDecodingError, JwtContent] = 11 | Jwt.decode(token, scala.util.Properties.envOrElse("JWT_PRIVATE_KEY", "default private key"), Seq(JwtAlgorithm.HS256)) 12 | .fold(err => Left(JwtDecodingError(err.getMessage)), claim => { 13 | val jwtContentEither = claim.content.fromJson[JwtContent] 14 | jwtContentEither.fold(err => Left(JwtDecodingError(err)), Right(_)) 15 | }) 16 | 17 | object JwtDecoderLive: 18 | lazy val layer: ULayer[JwtDecoder] = ZLayer.succeed(JwtDecoderLive()) 19 | 20 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/package.scala: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import db.DbError.* 4 | import zhttp.http.Response 5 | import zhttp.http.Status 6 | import zio.json.* 7 | 8 | package object handler: 9 | 10 | extension [A](elems: List[A]) 11 | def toJsonResponse(using jsonEncoder: JsonEncoder[A]): Response = Response.text(elems.toJson) 12 | 13 | extension [A](eitherNotFoundOrFound: Either[String, A]) 14 | def notFoundOrFound(using jsonEncoder: JsonEncoder[A]): Response = eitherNotFoundOrFound.fold( 15 | Response.text(_).setStatus(Status.NotFound), 16 | record => Response.text(record.toJson).setStatus(Status.Ok) 17 | ) 18 | 19 | extension[A] (any: A)(using jsonEncoder: JsonEncoder[A]) 20 | def toJsonResponse: Response = Response.text(any.toJsonPretty) 21 | 22 | -------------------------------------------------------------------------------- /src/main/scala/sort/SortNoteService.scala: -------------------------------------------------------------------------------- 1 | package scala.sort 2 | 3 | import db.* 4 | import db.note.NotesRepository 5 | import domain.Domain.Note 6 | import zio.* 7 | 8 | import scala.sort.SortNoteService.{ascending, descending} 9 | 10 | final case class SortNoteService(notesRepository: NotesRepository) extends SortService[Note]: 11 | 12 | def sort(sortOrder: SortOrder, userId: Long): Task[List[Note]] = 13 | for 14 | notes <- notesRepository getNotesByUserId userId 15 | sorted <- ZIO.succeed(sortOrder.fold(notes.sortWith(ascending))(notes.sortWith(descending))) 16 | yield sorted 17 | 18 | 19 | object SortNoteService: 20 | 21 | val ascending: (Note, Note) => Boolean = _.title < _.title 22 | val descending: (Note, Note) => Boolean = _.title > _.title 23 | 24 | lazy val layer: URLayer[NotesRepository, SortNoteService] = ZLayer.fromFunction(SortNoteService.apply) 25 | 26 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/UpdateNoteEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import domain.* 4 | import route.handler.* 5 | import route.handler.RequestHandlerDefinitions.UpdateNoteHandler 6 | import route.service.UpdateNoteServiceLive 7 | import server.NotesServer 8 | import server.endpoint.note.NoteEndpointDefinitions.UpdateNoteEndpoint 9 | import zhttp.http.* 10 | import zio.* 11 | 12 | final case class UpdateNoteEndpointLive(updateNoteHandler: UpdateNoteHandler) extends UpdateNoteEndpoint: 13 | 14 | override lazy val route: HttpApp[Any, Throwable] = Http.collectZIO[Request] { 15 | case request@Method.PUT -> !! / "api" / "notes" / long(noteId) => updateNoteHandler.handle(request, noteId) 16 | } 17 | 18 | 19 | object UpdateNoteEndpointLive: 20 | 21 | lazy val layer: URLayer[UpdateNoteHandler, UpdateNoteEndpoint] = 22 | ZLayer.fromFunction(UpdateNoteEndpointLive.apply) 23 | 24 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/GetAllNotesHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import db.note.NotesRepositoryLive 4 | import pdi.jwt.{Jwt, JwtCirce} 5 | import server.NotesServer 6 | import zhttp.http.Response 7 | import zio.* 8 | import zio.json.* 9 | import zhttp.http.* 10 | import RequestHandlerDefinitions.GetAllNotesHandler 11 | import domain.Domain.JwtContent 12 | import route.service.GetAllNotesServiceLive 13 | import route.service.ServiceDefinitions.GetAllNotesService 14 | 15 | final case class GetAllNotesHandlerLive(getAllNotesService: GetAllNotesService) extends GetAllNotesHandler: 16 | 17 | override def handle(jwtContent: JwtContent): Task[Response] = 18 | getAllNotesService.getNotesByUserId(jwtContent.userId) 19 | .map(_.toJsonResponse) 20 | 21 | 22 | object GetAllNotesHandlerLive: 23 | 24 | lazy val layer: URLayer[GetAllNotesService, GetAllNotesHandler] = 25 | ZLayer.fromFunction(GetAllNotesHandlerLive.apply) 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/DeleteNoteHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import db.note.NotesRepositoryLive 4 | import zhttp.http.Response 5 | import zio.* 6 | import zhttp.http.* 7 | import RequestHandlerDefinitions.DeleteNoteHandler 8 | import route.service 9 | import route.service.DeleteNoteServiceLive 10 | import route.service.ServiceDefinitions.DeleteNoteService 11 | 12 | final case class DeleteNoteHandlerLive(deleteNoteService: DeleteNoteService) extends DeleteNoteHandler: 13 | 14 | override def handle(noteId: Long, userId: Long): Task[Response] = 15 | deleteNoteService.deleteRecord(noteId, userId) 16 | .map(_.fold( 17 | err => Response.text(err).setStatus(Status.BadRequest), 18 | success => Response.text(success).setStatus(Status.Ok) 19 | )) 20 | 21 | 22 | object DeleteNoteHandlerLive: 23 | 24 | lazy val layer: URLayer[DeleteNoteService, DeleteNoteHandler] = ZLayer.fromFunction(DeleteNoteHandlerLive.apply) 25 | -------------------------------------------------------------------------------- /src/main/scala/server/middleware/JwtValidatorMiddlewareLive.scala: -------------------------------------------------------------------------------- 1 | package server.middleware 2 | 3 | import auth.{JwtDecoder, JwtDecoderLive} 4 | import zhttp.http.{Http, Middleware, Request, Response} 5 | import zio.ZIO 6 | 7 | final case class JwtValidatorMiddlewareLive(jwtDecoder: JwtDecoder) extends JwtValidatorMiddleware: 8 | 9 | override lazy val validate: Middleware[RequestContextManager, Nothing, Request, Response, Request, Response] = new Middleware: 10 | override def apply[R1 <: RequestContextManager, E1 >: Nothing](http: Http[R1, E1, Request, Response]): Http[R1, E1, Request, Response] = 11 | http.contramapZIO { request => 12 | for 13 | ctxManager <- ZIO.service[RequestContextManager] 14 | ctx <- ctxManager.getCtx 15 | jwtContent = jwtDecoder.decode(request.bearerToken.fold("")(identity)) 16 | _ <- jwtContent.fold( 17 | _ => ctxManager.setCtx(ctx.copy(jwtContent = None)), 18 | content => ctxManager.setCtx(ctx.copy(jwtContent = Some(content))) 19 | ) 20 | yield request 21 | } 22 | 23 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/GetAllNotesEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import route.handler.RequestHandlerDefinitions.GetAllNotesHandler 4 | import server.* 5 | import server.endpoint.note.NoteEndpointDefinitions.GetAllNotesEndpoint 6 | import server.middleware.{RequestContext, RequestContextManager, RequestContextMiddleware} 7 | import zhttp.http.* 8 | import zio.* 9 | 10 | final case class GetAllNotesEndpointLive(getAllNotesHandler: GetAllNotesHandler) extends GetAllNotesEndpoint: 11 | 12 | override lazy val route: HttpApp[RequestContextManager, Throwable] = Http.collectZIO[Request] { 13 | case Method.GET -> !! / "api" / "notes" => 14 | for 15 | requestContext <- ZIO.service[RequestContextManager].flatMap(_.getCtx) 16 | response <- requestContext.getJwtOrFail.fold(identity, getAllNotesHandler.handle) 17 | yield response 18 | } @@ RequestContextMiddleware.jwtAuthMiddleware 19 | 20 | 21 | object GetAllNotesEndpointLive: 22 | 23 | def layer: URLayer[GetAllNotesHandler, GetAllNotesEndpoint] = 24 | ZLayer.fromFunction(GetAllNotesEndpointLive.apply) 25 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/RequestHandlerDefinitions.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import domain.Domain.JwtContent 4 | import zhttp.http.{Request, Response} 5 | import zio.Task 6 | 7 | object RequestHandlerDefinitions: 8 | 9 | trait CreateNoteHandler: 10 | def handle(request: Request, jwtContent: JwtContent): Task[Response] 11 | 12 | trait DeleteNoteHandler: 13 | def handle(noteId: Long, userId: Long): Task[Response] 14 | 15 | trait GetAllNotesHandler: 16 | def handle(jwtContent: JwtContent): Task[Response] 17 | 18 | trait GetNoteHandler: 19 | def handle(noteId: Long, userId: Long): Task[Response] 20 | 21 | trait LoginHandler: 22 | def handle(request: Request): Task[Response] 23 | 24 | trait SearchNoteHandler: 25 | def handle(request: Request, jwtContent: JwtContent): Task[Response] 26 | 27 | trait SignupHandler: 28 | def handle(request: Request): Task[Response] 29 | 30 | trait SortNoteHandler: 31 | def handle(request: Request, jwtContent: JwtContent): Task[Response] 32 | 33 | trait UpdateNoteHandler: 34 | def handle(request: Request, noteId: Long): Task[Response] 35 | -------------------------------------------------------------------------------- /src/main/scala/route/service/ServiceDefinitions.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import domain.Domain.{LoginPayload, Note, User} 4 | import route.service.ServiceDefinitions.LoginService.{JWT, LoginError} 5 | import zio.Task 6 | 7 | object ServiceDefinitions: 8 | 9 | trait CreateNoteService: 10 | def createNote(note: Note): Task[Either[String, String]] 11 | 12 | trait DeleteNoteService: 13 | def deleteRecord(noteId: Long, userId: Long): Task[Either[String, String]] 14 | 15 | trait GetAllNotesService: 16 | def getNotesByUserId(userId: Long): Task[List[Note]] 17 | 18 | trait GetNoteService: 19 | def getNote(noteId: Long, userId: Long): Task[Either[String, Note]] 20 | 21 | trait LoginService: 22 | def login(loginPayload: LoginPayload): Task[Either[LoginError, JWT]] 23 | 24 | object LoginService: 25 | case class JWT(value: String) 26 | case class LoginError(value: String) 27 | 28 | trait SignupService: 29 | def signUp(user: User): Task[Either[String, String]] 30 | 31 | trait UpdateNoteService: 32 | def updateNote(noteId: Long, newNote: Note): Task[Either[String, String]] 33 | 34 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/GetNoteEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import domain.* 4 | import route.handler.RequestHandlerDefinitions.GetNoteHandler 5 | import server.NotesServer 6 | import server.endpoint.note.NoteEndpointDefinitions.GetNoteEndpoint 7 | import server.middleware.{RequestContextManager, RequestContextMiddleware} 8 | import zhttp.http.* 9 | import zio.* 10 | 11 | final case class GetNoteEndpointLive(getNoteHandler: GetNoteHandler) extends GetNoteEndpoint: 12 | 13 | override lazy val route: HttpApp[RequestContextManager, Throwable] = Http.collectZIO[Request] { 14 | case Method.GET -> !! / "api" / "notes" / long(noteId) => 15 | for 16 | requestContext <- ZIO.service[RequestContextManager].flatMap(_.getCtx) 17 | response <- requestContext.getJwtOrFail.fold(identity, jwtContent => getNoteHandler.handle(noteId, jwtContent.userId)) 18 | yield response 19 | } @@ RequestContextMiddleware.jwtAuthMiddleware 20 | 21 | 22 | object GetNoteEndpointLive: 23 | 24 | lazy val layer: URLayer[GetNoteHandler, GetNoteEndpoint] = 25 | ZLayer.fromFunction(GetNoteEndpointLive.apply) 26 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/SearchNoteEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import route.handler.* 4 | import route.handler.RequestHandlerDefinitions.SearchNoteHandler 5 | import server.NotesServer 6 | import server.endpoint.note.NoteEndpointDefinitions.SearchNoteEndpoint 7 | import server.middleware.{RequestContextManager, RequestContextMiddleware} 8 | import zhttp.http.* 9 | import zio.* 10 | 11 | final case class SearchNoteEndpointLive(searchNoteHandler: SearchNoteHandler) extends SearchNoteEndpoint: 12 | 13 | override lazy val route: HttpApp[RequestContextManager, Throwable] = Http.collectZIO[Request] { 14 | case request@Method.GET -> !! / "api" / "notes" / "search" => 15 | for 16 | requestContext <- ZIO.service[RequestContextManager].flatMap(_.getCtx) 17 | response <- requestContext.getJwtOrFail.fold(identity, searchNoteHandler.handle(request, _)) 18 | yield response 19 | } @@ RequestContextMiddleware.jwtAuthMiddleware 20 | 21 | 22 | object SearchNoteEndpointLive: 23 | 24 | lazy val layer: URLayer[SearchNoteHandler, SearchNoteEndpoint] = 25 | ZLayer.fromFunction(SearchNoteEndpointLive.apply) 26 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/UpdateNoteHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import zhttp.http.* 4 | import zio.* 5 | import zio.json.* 6 | import RequestHandlerDefinitions.UpdateNoteHandler 7 | import domain.Domain.Note 8 | import route.service.ServiceDefinitions.UpdateNoteService 9 | import route.service.UpdateNoteServiceLive 10 | 11 | final case class UpdateNoteHandlerLive(updateNoteService: UpdateNoteService) extends UpdateNoteHandler: 12 | 13 | override def handle(request: Request, noteId: Long): Task[Response] = 14 | for 15 | noteEither <- request.bodyAsString.map(_.fromJson[Note]) 16 | response <- noteEither.fold ( 17 | _ => ZIO.succeed(Response.text("Invalid Json").setStatus(Status.BadRequest)), 18 | note => updateNoteService.updateNote(noteId, note).map(_.fold( 19 | failure => Response.text(failure).setStatus(Status.BadRequest), 20 | success => Response.text(success).setStatus(Status.NoContent) 21 | )) 22 | ) 23 | yield response 24 | 25 | 26 | object UpdateNoteHandlerLive: 27 | 28 | lazy val layer: URLayer[UpdateNoteService, UpdateNoteHandler] = ZLayer.fromFunction(UpdateNoteHandlerLive.apply) 29 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/CreateNoteEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import route.handler.RequestHandlerDefinitions.* 4 | import route.service.CreateNoteServiceLive 5 | import server.NotesServer 6 | import server.endpoint.note.NoteEndpointDefinitions.CreateNoteEndpoint 7 | import server.middleware.{RequestContextManager, RequestContextMiddleware} 8 | import zhttp.* 9 | import zhttp.http.* 10 | import zio.* 11 | 12 | final case class CreateNoteEndpointLive(createNoteHandler: CreateNoteHandler) extends CreateNoteEndpoint: 13 | 14 | override lazy val route: HttpApp[RequestContextManager, Throwable] = Http.collectZIO[Request] { 15 | case request @ Method.POST -> !! / "api" / "notes" => 16 | for 17 | requestContext <- ZIO.service[RequestContextManager].flatMap(_.getCtx) 18 | response <- requestContext.getJwtOrFail.fold(identity, createNoteHandler.handle(request, _)) 19 | yield response 20 | } @@ RequestContextMiddleware.jwtAuthMiddleware 21 | 22 | 23 | object CreateNoteEndpointLive: 24 | 25 | lazy val layer: URLayer[CreateNoteHandler, CreateNoteEndpoint] = 26 | ZLayer.fromFunction(CreateNoteEndpointLive.apply) 27 | 28 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/SortNoteEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import domain.* 4 | import route.handler.* 5 | import route.handler.RequestHandlerDefinitions.SortNoteHandler 6 | import server.NotesServer 7 | import server.endpoint.note.NoteEndpointDefinitions.SortNoteEndpoint 8 | import server.middleware.{RequestContextManager, RequestContextMiddleware} 9 | import zhttp.http.* 10 | import zio.* 11 | 12 | import scala.sort.SortNoteService 13 | 14 | final case class SortNoteEndpointLive(sortNoteHandler: SortNoteHandler) extends SortNoteEndpoint: 15 | 16 | override lazy val route: HttpApp[RequestContextManager, Throwable] = Http.collectZIO[Request] { 17 | case request@Method.GET -> !! / "api" / "notes" / "sort" => 18 | for 19 | requestContext <- ZIO.service[RequestContextManager].flatMap(_.getCtx) 20 | response <- requestContext.getJwtOrFail.fold(identity, sortNoteHandler.handle(request, _)) 21 | yield response 22 | } @@ RequestContextMiddleware.jwtAuthMiddleware 23 | 24 | 25 | object SortNoteEndpointLive: 26 | 27 | lazy val layer: URLayer[SortNoteHandler, SortNoteEndpoint] = 28 | ZLayer.fromFunction(SortNoteEndpointLive.apply) -------------------------------------------------------------------------------- /src/main/scala/route/handler/SignupHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import zio.* 4 | import zhttp.http.{Request, Response, Status} 5 | import domain.* 6 | import zio.json.* 7 | import RequestHandlerDefinitions.SignupHandler 8 | import domain.Domain.User 9 | import route.service.ServiceDefinitions.SignupService 10 | import route.service.SignupServiceLive 11 | 12 | final case class SignupHandlerLive(signupService: SignupService) extends SignupHandler: 13 | 14 | override def handle(request: Request): Task[Response] = 15 | for 16 | userEither <- request.bodyAsString.map(_.fromJson[User]) 17 | response <- userEither.fold(_ => ZIO.succeed(Response.text("Invalid Json")), mapSignupServiceResultToResponse) 18 | yield response 19 | 20 | 21 | private def mapSignupServiceResultToResponse(user: User): Task[Response] = 22 | signupService 23 | .signUp(user) 24 | .map(_.fold( 25 | Response.text(_).setStatus(Status.Conflict), 26 | token => Response.text(token).setStatus(Status.Created) 27 | )) 28 | 29 | 30 | object SignupHandlerLive: 31 | 32 | lazy val layer: URLayer[SignupService, SignupHandler] = 33 | ZLayer.fromFunction(SignupHandlerLive.apply) 34 | 35 | -------------------------------------------------------------------------------- /src/main/scala/server/endpoint/note/DeleteNoteEndpointLive.scala: -------------------------------------------------------------------------------- 1 | package server.endpoint.note 2 | 3 | import route.handler.* 4 | import route.handler.RequestHandlerDefinitions.DeleteNoteHandler 5 | import route.service.DeleteNoteServiceLive 6 | import server.NotesServer 7 | import server.endpoint.note.NoteEndpointDefinitions.DeleteNoteEndpoint 8 | import server.middleware.{RequestContext, RequestContextManager, RequestContextMiddleware} 9 | import zhttp.http.* 10 | import zio.* 11 | 12 | final case class DeleteNoteEndpointLive(deleteNoteHandler: DeleteNoteHandler) extends DeleteNoteEndpoint: 13 | 14 | override lazy val route: HttpApp[RequestContextManager, Throwable] = Http.collectZIO[Request] { 15 | case Method.DELETE -> !! / "api" / "notes" / long(noteId) => 16 | for 17 | requestContext <- ZIO.service[RequestContextManager].flatMap(_.getCtx) 18 | response <- requestContext.getJwtOrFail.fold(identity, jwtContent => deleteNoteHandler.handle(noteId, jwtContent.userId)) 19 | yield response 20 | } @@ RequestContextMiddleware.jwtAuthMiddleware 21 | 22 | 23 | object DeleteNoteEndpointLive: 24 | 25 | lazy val layer: URLayer[DeleteNoteHandler, DeleteNoteEndpoint] = 26 | ZLayer.fromFunction(DeleteNoteEndpointLive.apply) 27 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/SortNoteHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import zhttp.http.Response 4 | import zhttp.http.Request 5 | 6 | import sort.{SortNoteService, SortOrder, SortService} 7 | import zio.json.* 8 | import zio.* 9 | import RequestHandlerDefinitions.SortNoteHandler 10 | import domain.Domain.{JwtContent, Note} 11 | 12 | final case class SortNoteHandlerLive(sortNoteService: SortService[Note]) extends SortNoteHandler: 13 | 14 | override def handle(request: Request, jwtContent: JwtContent): Task[Response] = 15 | for 16 | queryParams <- ZIO.succeed(request.url.queryParams) 17 | sortOrder <- getSortOrderFromQueryParams(queryParams) 18 | ordered <- sortNoteService.sort(sortOrder, jwtContent.userId) 19 | response <- ZIO.succeed(Response.text(ordered.toJsonPretty)) 20 | yield response 21 | 22 | private def getSortOrderFromQueryParams(queryParams: Map[String, List[String]]) = ZIO.succeed { 23 | queryParams 24 | .get("order") 25 | .fold(SortOrder.Ascending)(ord =>if ord.head == "asc" then SortOrder.Ascending else SortOrder.Descending) 26 | } 27 | 28 | 29 | object SortNoteHandlerLive: 30 | 31 | lazy val layer: URLayer[SortService[Note], SortNoteHandler] = ZLayer.fromFunction(SortNoteHandlerLive.apply) 32 | 33 | -------------------------------------------------------------------------------- /src/main/scala/auth/JwtEncoderLive.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import pdi.jwt.JwtAlgorithm 4 | import zio.{RIO, Task} 5 | import db.* 6 | import route.service.LoginServiceLive.layer 7 | import hash.{PasswordHashService, SecureHashService} 8 | import zio.* 9 | 10 | import java.time.Instant 11 | import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim} 12 | import io.circe.* 13 | import jawn.parse as jawnParse 14 | import zio.json.* 15 | import JwtEncoderLive.DAY_IN_SECONDS 16 | import domain.Domain.{JwtContent, LoginPayload, User} 17 | import route.service.ServiceDefinitions.LoginService.JWT 18 | 19 | final case class JwtEncoderLive() extends JwtEncoder[User]: 20 | 21 | override def encode(user: User): JWT = 22 | val key = scala.util.Properties.envOrElse("JWT_PRIVATE_KEY", "default private key") 23 | val algo = JwtAlgorithm.HS256 24 | val claim = JwtClaim( 25 | expiration = Some(Instant.now.plusSeconds(DAY_IN_SECONDS).getEpochSecond), 26 | issuedAt = Some(Instant.now.getEpochSecond), 27 | content = JwtContent(user.id.get, user.name, user.email).toJsonPretty 28 | ) 29 | 30 | JWT(JwtCirce.encode(claim, key, algo)) 31 | 32 | 33 | object JwtEncoderLive: 34 | 35 | val DAY_IN_SECONDS: Long = 60 * 60 * 24 36 | 37 | lazy val layer: ULayer[JwtEncoder[User]] = ZLayer.succeed(JwtEncoderLive()) 38 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/CreateNoteHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import zhttp.http.HttpError.BadRequest 4 | import zhttp.http.{Request, Response, Status} 5 | import zio.* 6 | import zio.json.* 7 | import RequestHandlerDefinitions.CreateNoteHandler 8 | import domain.Domain.{JwtContent, Note} 9 | import route.service.ServiceDefinitions.CreateNoteService 10 | 11 | final case class CreateNoteHandlerLive(createNoteService: CreateNoteService) extends CreateNoteHandler: 12 | 13 | override def handle(request: Request, jwtContent: JwtContent): Task[Response] = 14 | for 15 | noteEither <- request.bodyAsString.map(_.fromJson[Note]) 16 | response <- noteEither.fold(_ => ZIO.succeed(Response.text("Invalid Json").setStatus(Status.BadRequest)), creationStatusToResponse(jwtContent.userId, _)) 17 | yield response 18 | 19 | private def creationStatusToResponse(userId: Long, note: Note): Task[Response] = 20 | val noteWithUserId = note.copy(userId = Some(userId)) 21 | 22 | createNoteService.createNote(noteWithUserId) 23 | .map(_.fold( 24 | err => Response.text(err).setStatus(Status.BadRequest), 25 | success => Response.text(success).setStatus(Status.Created)) 26 | ) 27 | 28 | 29 | object CreateNoteHandlerLive: 30 | 31 | lazy val layer: URLayer[CreateNoteService, CreateNoteHandler] = 32 | ZLayer.fromFunction(CreateNoteHandlerLive.apply) 33 | 34 | -------------------------------------------------------------------------------- /src/main/scala/route/service/SignupServiceLive.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import db.* 4 | import db.user.UserRepository 5 | import hash.{PasswordHashService, SecureHashService} 6 | import domain.* 7 | import zio.* 8 | import zio.json.* 9 | import ServiceDefinitions.SignupService 10 | import domain.Domain.User 11 | 12 | final case class SignupServiceLive( 13 | private val passwordHashService: PasswordHashService, 14 | private val userRepository: UserRepository 15 | ) extends SignupService: 16 | 17 | override def signUp(user: User): Task[Either[String, String]] = 18 | for 19 | userExists <- userRepository userExists user.email 20 | response <- 21 | if userExists then ZIO.succeed(Left("User already exists")) 22 | else 23 | for 24 | userWithHashedPass <- ZIO.succeed(user.copy(password = passwordHashService.hash(user.password))) 25 | userWithId <- ZIO.succeed(userWithHashedPass.copy(id = Some(scala.util.Random.nextLong(Long.MaxValue)))) 26 | signupStatus <- userRepository.add(userWithId).map(_.toDBResultMessage) 27 | yield signupStatus 28 | yield response 29 | 30 | object SignupServiceLive: 31 | 32 | lazy val layer: URLayer[PasswordHashService & UserRepository, SignupService] = 33 | ZLayer.fromFunction(SignupServiceLive.apply) 34 | 35 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/LoginHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import zhttp.http.* 4 | import zio.* 5 | import zio.json.* 6 | import db.* 7 | import hash.{PasswordHashService, SecureHashService} 8 | 9 | import java.time.Instant 10 | import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim} 11 | 12 | import java.time.Instant 13 | import io.circe.* 14 | import jawn.parse as jawnParse 15 | import RequestHandlerDefinitions.LoginHandler 16 | import domain.Domain.LoginPayload 17 | import route.service.ServiceDefinitions.LoginService 18 | 19 | final case class LoginHandlerLive(loginService: LoginService) extends LoginHandler: 20 | 21 | override def handle(request: Request): Task[Response] = 22 | for 23 | loginPayloadEither <- request.bodyAsString.map(_.fromJson[LoginPayload]) 24 | response <- loginPayloadEither.fold(_ => ZIO.succeed(Response.text("Invalid Json").setStatus(Status.BadRequest)), processLoginPayload) 25 | yield response 26 | 27 | private def processLoginPayload(loginPayload: LoginPayload): Task[Response] = 28 | loginService 29 | .login(loginPayload) 30 | .map(_.fold( 31 | err => Response.text(err.value).setStatus(Status.Unauthorized), 32 | jwt => Response.text(s"""{"token": ${jwt.value}""").setStatus(Status.Ok)) 33 | ) 34 | 35 | object LoginHandlerLive: 36 | 37 | lazy val layer: URLayer[LoginService, LoginHandler] = 38 | ZLayer.fromFunction(LoginHandlerLive.apply) 39 | -------------------------------------------------------------------------------- /src/main/scala/search/SearchNoteService.scala: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import db.note.{NotesRepository, NotesRepositoryLive} 4 | import domain.Domain.Note 5 | import zio.{Task, UIO, ZIO, ZLayer} 6 | 7 | import java.time.Instant 8 | import java.util.Date 9 | 10 | final case class SearchNoteService(notesRepository: NotesRepository) extends SearchService[Note]: 11 | 12 | override def searchByTitle(title: String, searchCriteria: SearchCriteria, userId: Long): Task[Either[String, List[Note]]] = 13 | for 14 | notes <- notesRepository getNotesByUserId userId 15 | response <- searchCriteria.fold(getExactMatches(title, notes))(getNonExactMatches(title, notes)) 16 | yield response 17 | 18 | private def getNonExactMatches(title: String, notes: List[Note]): UIO[Either[String, List[Note]]] = ZIO.succeed { 19 | val maybeNotes = notes.filter(note => note.title.replace(" ", "").toLowerCase.contains(title.replace(" ", "").toLowerCase)) 20 | if maybeNotes.nonEmpty then Right(maybeNotes) else Left(s"No matches with title $title") 21 | } 22 | 23 | private def getExactMatches(title: String, notes: List[Note]): UIO[Either[String, List[Note]]] = 24 | ZIO.succeed(notes.find(_.title == title).fold(Left(s"No matches with title $title"))(note => Right(note :: Nil))) 25 | 26 | 27 | 28 | object SearchNoteService: 29 | 30 | lazy val layer: ZLayer[NotesRepository, Nothing, SearchService[Note]] = ZLayer.fromFunction(SearchNoteService.apply) 31 | 32 | -------------------------------------------------------------------------------- /src/main/scala/db/mongo/MongoDatabaseInitializer.scala: -------------------------------------------------------------------------------- 1 | package db.mongo 2 | 3 | import com.mongodb.event.CommandListener 4 | import zio.* 5 | import org.mongodb.scala.* 6 | import org.mongodb.scala.connection.ConnectionPoolSettings 7 | 8 | import java.net.SocketTimeoutException 9 | import java.util.concurrent.TimeUnit 10 | 11 | final case class MongoDatabaseInitializer(dataSource: DataSource) extends DatabaseInitializer: 12 | 13 | private val mongoServerSettings = ZIO.succeed { 14 | MongoClientSettings.builder() 15 | .applyToClusterSettings(_.serverSelectionTimeout(5, TimeUnit.SECONDS)) 16 | .build() 17 | } 18 | 19 | override def initialize(dbConfig: DBConfig): UIO[Unit] = 20 | (for 21 | settings <- Console.printLine(s"Attempting to establish the connection with MongoDB on port: ${dbConfig.port} with db ${dbConfig.name}") *> mongoServerSettings 22 | client <- ZIO.attempt(MongoClient(settings)) 23 | db <- ZIO.attempt(client.getDatabase(dbConfig.name)) 24 | _ <- ZIO.fromFuture(implicit ec => db.listCollectionNames.toFuture).catchSome { 25 | case mte: MongoTimeoutException => ZIO.fail(RuntimeException(s"Connecting to MongoDB failed, reason: ${mte.getMessage}")) 26 | case _ => ZIO.fail(RuntimeException("Connecting to MongoDB failed, reason unknown")) 27 | } *> dataSource.setCtx(DatabaseContext(db)) 28 | yield ()).orDie 29 | 30 | 31 | object MongoDatabaseInitializer: 32 | 33 | lazy val layer: URLayer[DataSource, DatabaseInitializer] = ZLayer.fromFunction(MongoDatabaseInitializer.apply) -------------------------------------------------------------------------------- /src/main/scala/route/service/LoginServiceLive.scala: -------------------------------------------------------------------------------- 1 | package route.service 2 | 3 | import auth.JwtEncoder 4 | import db.* 5 | import db.user.UserRepository 6 | import hash.{PasswordHashService, SecureHashService} 7 | import io.circe.* 8 | import io.circe.jawn.parse as jawnParse 9 | import domain.* 10 | import domain.Domain.{LoginPayload, User} 11 | import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim} 12 | import route.service.LoginServiceLive.layer 13 | import zio.* 14 | import zio.json.* 15 | import zio.ZLayer 16 | 17 | import java.time.Instant 18 | import route.service.ServiceDefinitions.LoginService 19 | import route.service.ServiceDefinitions.LoginService.{JWT, LoginError} 20 | 21 | final case class LoginServiceLive( 22 | private val userRepository: UserRepository, 23 | private val passwordHashService: PasswordHashService, 24 | private val jwtEncoder: JwtEncoder[User] 25 | ) extends LoginService: 26 | override def login(loginPayload: LoginPayload): Task[Either[LoginError, JWT]] = 27 | userRepository 28 | .getUserByEmail(loginPayload.email) 29 | .map(_.fold(Left(LoginError("User does not exist")))(user => getJwtOrAuthFailure(loginPayload.password, user))) 30 | 31 | private def getJwtOrAuthFailure(loginPassword: String, user: User): Either[LoginError, JWT] = 32 | val passwordMatch = passwordHashService.validate(loginPassword, user.password) 33 | if passwordMatch then Right(jwtEncoder.encode(user)) else Left(LoginError("Auth failed")) 34 | 35 | 36 | object LoginServiceLive: 37 | 38 | lazy val layer: URLayer[UserRepository & PasswordHashService & JwtEncoder[User], LoginService] = 39 | ZLayer.fromFunction(LoginServiceLive.apply) 40 | -------------------------------------------------------------------------------- /src/main/scala/route/handler/SearchNoteHandlerLive.scala: -------------------------------------------------------------------------------- 1 | package route.handler 2 | 3 | import zhttp.http.Request 4 | import domain.* 5 | 6 | import util.* 7 | import search.{SearchCriteria, SearchNoteService, SearchService} 8 | import zhttp.http.* 9 | import zio.* 10 | import zio.json.* 11 | import RequestHandlerDefinitions.SearchNoteHandler 12 | import domain.Domain.{JwtContent, Note} 13 | 14 | import java.time.Instant 15 | import java.util.Date 16 | 17 | final case class SearchNoteHandlerLive(searchNoteService: SearchService[Note]) extends SearchNoteHandler: 18 | 19 | override def handle(request: Request, jwtContent: JwtContent): Task[Response] = 20 | for 21 | queryParams <- ZIO.succeed(request.url.queryParams) 22 | title <- getTitleFromQueryParams(queryParams) 23 | searchCriteria <- getSearchCriteriaFromQueryParams(queryParams) 24 | searchResult <- searchNoteService.searchByTitle(title, searchCriteria, jwtContent.userId) 25 | response <- ZIO.succeed(searchResult.fold(Response.text, note => Response.text(note.toJsonPretty))) 26 | yield response 27 | 28 | private def getSearchCriteriaFromQueryParams(queryParams: Map[String, List[String]]) = 29 | ZIO.succeed { 30 | queryParams 31 | .get("exact") 32 | .fold(SearchCriteria.nonExact)(criteria => if criteria.head == "true" then SearchCriteria.exact else SearchCriteria.nonExact) 33 | } 34 | 35 | private def getTitleFromQueryParams(queryParams: Map[String, List[String]]): UIO[String] = 36 | ZIO.succeed { 37 | queryParams 38 | .get("title") 39 | .fold("")(params => if params.isDefinedAt(0) then params.head else "") 40 | } 41 | 42 | 43 | object SearchNoteHandlerLive: 44 | 45 | lazy val layer: URLayer[SearchService[Note], SearchNoteHandler] = ZLayer.fromFunction(SearchNoteHandlerLive.apply) 46 | 47 | -------------------------------------------------------------------------------- /src/main/scala/domain/Domain.scala: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} 4 | 5 | object Domain { 6 | 7 | final case class JwtContent(userId: Long, name: String, email: String) 8 | 9 | object JwtContent: 10 | given decoder: JsonDecoder[JwtContent] = DeriveJsonDecoder.gen[JwtContent] 11 | given encoder: JsonEncoder[JwtContent] = DeriveJsonEncoder.gen[JwtContent] 12 | 13 | final case class LoginPayload(email: String, password: String) 14 | 15 | object LoginPayload: 16 | given decoder: JsonDecoder[LoginPayload] = DeriveJsonDecoder.gen[LoginPayload] 17 | given encoder: JsonEncoder[LoginPayload] = DeriveJsonEncoder.gen[LoginPayload] 18 | 19 | final case class Note(id: Option[Long], title: String, body: String, createdAt: String, userId: Option[Long]) 20 | 21 | object Note: 22 | given decoder: JsonDecoder[Note] = DeriveJsonDecoder.gen[Note] 23 | given encoder: JsonEncoder[Note] = DeriveJsonEncoder.gen[Note] 24 | 25 | def apply(id: Long, title: String, body: String, createdAt: String, userId: Long): Note = new Note(id.some, title, body, createdAt, userId.some) 26 | def apply(title: String, body: String, createdAt: String, userId: Long): Note = new Note(None, title, body, createdAt, userId.some) 27 | def apply(id: Long, title: String, body: String, createdAt: String): Note = new Note(id.some, title, body, createdAt, None) 28 | 29 | final case class User(id: Option[Long], name: String, email: String, password: String) 30 | 31 | object User: 32 | given decoder: JsonDecoder[User] = DeriveJsonDecoder.gen[User] 33 | given encoder: JsonEncoder[User] = DeriveJsonEncoder.gen[User] 34 | 35 | def apply(id: Long, name: String, email: String, password: String): User = new User(id.some, name, email, password) 36 | def apply(name: String, email: String, password: String): User = new User(None, name, email, password) 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/server/NotesServer.scala: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import db.mongo.{DBConfig, DataSource, DatabaseInitializer, MongoDatabaseInitializer} 4 | import zhttp.http.* 5 | import zhttp.service.Server 6 | import zio.* 7 | import endpoint.* 8 | import io.netty.handler.codec.http.HttpHeaders 9 | import org.mongodb.scala.MongoDatabase 10 | import pdi.jwt.algorithms.JwtUnknownAlgorithm 11 | import pdi.jwt.{Jwt, JwtAlgorithm, JwtClaim} 12 | import server.endpoint.note.NoteEndpointDefinitions.* 13 | import server.endpoint.user.UserEndpointDefinitions.* 14 | import server.middleware.RequestContextManager 15 | import zhttp.http.middleware.HttpMiddleware 16 | 17 | import java.io.IOException 18 | 19 | final case class NotesServer( 20 | signupEndpoint: SignupEndpoint, 21 | loginEndpoint: LoginEndpoint, 22 | getAllNotesEndpoint: GetAllNotesEndpoint, 23 | getNoteEndpoint: GetNoteEndpoint, 24 | createNoteEndpoint: CreateNoteEndpoint, 25 | updateNoteEndpoint: UpdateNoteEndpoint, 26 | deleteNoteEndpoint: DeleteNoteEndpoint, 27 | searchNoteEndpoint: SearchNoteEndpoint, 28 | sortNoteEndpoint: SortNoteEndpoint 29 | ): 30 | 31 | val allRoutes: Http[RequestContextManager, Throwable, Request, Response] = 32 | signupEndpoint.route ++ 33 | loginEndpoint.route ++ 34 | sortNoteEndpoint.route ++ 35 | searchNoteEndpoint.route ++ 36 | getAllNotesEndpoint.route ++ 37 | getNoteEndpoint.route ++ 38 | createNoteEndpoint.route ++ 39 | updateNoteEndpoint.route ++ 40 | deleteNoteEndpoint.route 41 | 42 | 43 | def start: ZIO[RequestContextManager & DataSource & DatabaseInitializer, Throwable, Unit] = 44 | for 45 | _ <- ZIO.succeed(println("Starting HTTP Server")) 46 | port <- System.envOrElse("PORT", "8080").map(_.toInt) 47 | dbPort <- System.envOrElse("MONGO_PORT", "mongodb://localhost:27018") 48 | dbName <- System.envOrElse("MONGO_DB_NAME", "notesdb") <* ZIO.succeed(println(s"Attempting to connect to DB")) 49 | _ <- ZIO.service[DatabaseInitializer].flatMap(_.initialize(DBConfig(dbPort, dbName))) <* ZIO.succeed(println(s"Connected to DB on port: $dbPort")) 50 | _ <- Server.start(port, allRoutes) <* ZIO.succeed(println(s"HTTP Server listening on port $port")) 51 | yield () 52 | 53 | object NotesServer: 54 | 55 | lazy val layer: URLayer[SignupEndpoint & LoginEndpoint & GetAllNotesEndpoint & GetNoteEndpoint & CreateNoteEndpoint & UpdateNoteEndpoint & DeleteNoteEndpoint & SearchNoteEndpoint & SortNoteEndpoint, NotesServer] = 56 | ZLayer.fromFunction(NotesServer.apply) 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | import route.handler.* 2 | import search.* 3 | import zio.* 4 | import db.* 5 | import db.mongo.{DataSourceLive, MongoDatabaseInitializer} 6 | import hash.* 7 | import server.NotesServer 8 | import server.endpoint.* 9 | import server.middleware.RequestContextManagerLive 10 | import auth.{JwtDecoderLive, JwtEncoderLive} 11 | import db.note.NotesRepositoryLive 12 | import db.user.UserRepositoryLive 13 | import route.service.{CreateNoteServiceLive, DeleteNoteServiceLive, GetAllNotesServiceLive, GetNoteServiceLive, LoginServiceLive, SignupServiceLive, UpdateNoteServiceLive} 14 | import server.endpoint.note.{CreateNoteEndpointLive, DeleteNoteEndpointLive, GetAllNotesEndpointLive, GetNoteEndpointLive, SearchNoteEndpointLive, SortNoteEndpointLive, UpdateNoteEndpointLive} 15 | import server.endpoint.user.{LoginEndpointLive, SignupEndpointLive} 16 | 17 | import sort.SortNoteService 18 | 19 | object Main extends ZIOAppDefault: 20 | 21 | private lazy val endpointLayers = SignupEndpointLive.layer ++ 22 | CreateNoteEndpointLive.layer ++ 23 | DeleteNoteEndpointLive.layer ++ 24 | GetAllNotesEndpointLive.layer ++ 25 | GetNoteEndpointLive.layer ++ 26 | LoginEndpointLive.layer ++ 27 | SearchNoteEndpointLive.layer ++ 28 | SortNoteEndpointLive.layer ++ 29 | UpdateNoteEndpointLive.layer 30 | 31 | private lazy val serverLayer = NotesServer.layer 32 | 33 | private lazy val handlerLayers = SearchNoteHandlerLive.layer ++ 34 | SortNoteHandlerLive.layer ++ 35 | UpdateNoteHandlerLive.layer ++ 36 | CreateNoteHandlerLive.layer ++ 37 | GetAllNotesHandlerLive.layer ++ 38 | GetNoteHandlerLive.layer ++ 39 | DeleteNoteHandlerLive.layer ++ 40 | LoginHandlerLive.layer ++ 41 | SignupHandlerLive.layer 42 | 43 | private lazy val serviceLayers = SearchNoteService.layer ++ 44 | SortNoteService.layer ++ 45 | UpdateNoteServiceLive.layer ++ 46 | CreateNoteServiceLive.layer ++ 47 | GetAllNotesServiceLive.layer ++ 48 | GetNoteServiceLive.layer ++ 49 | LoginServiceLive.layer ++ 50 | SignupServiceLive.layer ++ 51 | DeleteNoteServiceLive.layer 52 | 53 | private lazy val repoLayers = NotesRepositoryLive.layer ++ UserRepositoryLive.layer ++ MongoDatabaseInitializer.layer 54 | 55 | private lazy val dataSourceLayer = DataSourceLive.layer 56 | 57 | private lazy val otherLayers = SecureHashService.layer ++ RequestContextManagerLive.layer ++ JwtEncoderLive.layer ++ JwtDecoderLive.layer 58 | 59 | override def run: Task[Unit] = 60 | ZIO.serviceWithZIO[NotesServer](_.start) 61 | .provide( 62 | serverLayer, 63 | endpointLayers, 64 | handlerLayers, 65 | serviceLayers, 66 | repoLayers, 67 | dataSourceLayer, 68 | otherLayers 69 | ) 70 | 71 | -------------------------------------------------------------------------------- /src/main/scala/db/user/UserRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package db.user 2 | 3 | import db.* 4 | import db.DbError.InvalidId 5 | import db.Repository.* 6 | import db.mongo.{DataSource, DatabaseContext} 7 | import domain.Domain.User 8 | import org.mongodb.scala.* 9 | import org.mongodb.scala.model.Filters.* 10 | import org.mongodb.scala.model.Sorts.* 11 | import zio.* 12 | import zio.json.* 13 | 14 | final case class UserRepositoryLive(dataSource: DataSource) extends UserRepository: 15 | 16 | private val mongo: UIO[MongoDatabase] = dataSource.getCtx.map(_.mongoDatabase.get) 17 | 18 | override def getById(id: Long): Task[Option[User]] = 19 | for 20 | db <- mongo 21 | maybeDoc <- ZIO.fromFuture { implicit ec => 22 | db.getCollection("user") 23 | .find(equal("id", id)) 24 | .first() 25 | .toFuture() 26 | } 27 | maybeUser <- ZIO.attempt(parseDocumentToUser(maybeDoc)) 28 | yield maybeUser 29 | 30 | override def update(id: Long, newUser: User): Task[DBResult] = 31 | for 32 | db <- mongo 33 | updateResult <- ZIO.fromFuture { implicit ec => 34 | db.getCollection("user") 35 | .replaceOne(equal("id", id), Document(newUser.toJson)) 36 | .toFuture() 37 | } 38 | updateStatus <- updateResult.fold(updateResult.getModifiedCount == 1, DbSuccess.Updated(s"User with id '$id' has been updated"), DbError.InvalidId(s"User with id '$id' has not been updated")) 39 | yield updateStatus 40 | 41 | override def delete(id: Long): Task[DBResult] = 42 | for 43 | db <- mongo 44 | queryResult <- ZIO.fromFuture { implicit ec => 45 | db.getCollection("user") 46 | .deleteOne(equal("id", id)) 47 | .toFuture() 48 | } 49 | deletionStatus <- queryResult.fold(queryResult.getDeletedCount == 1, DbSuccess.Deleted(s"User with id $id hsa been deleted"), DbError.InvalidId(s"Could not delete User. User with id: $id does not exist")) 50 | yield deletionStatus 51 | 52 | override def add(user: User): Task[DBResult] = 53 | for 54 | db <- mongo 55 | insertionResult <- ZIO.fromFuture { implicit ec => 56 | db.getCollection("user") 57 | .insertOne(Document(user.toJson)) 58 | .toFuture() 59 | } 60 | creationStatus <- insertionResult.fold(insertionResult.wasAcknowledged, DbSuccess.Created("User has been created"), DbError.ReasonUnknown("User has not been created")) 61 | yield creationStatus 62 | 63 | override def userExists(email: String): Task[Boolean] = 64 | for 65 | db <- mongo 66 | resultSequence <- ZIO.fromFuture { implicit ec => 67 | db.getCollection("user") 68 | .find(equal("email", email)) 69 | .toFuture() 70 | } 71 | userExists <- ZIO.succeed(resultSequence.nonEmpty) 72 | yield userExists 73 | 74 | override def getUserByEmail(email: String): Task[Option[User]] = 75 | for 76 | db <- mongo 77 | document <- ZIO.fromFuture { implicit ec => 78 | db.getCollection("user") 79 | .find(equal("email", email)) 80 | .first() 81 | .toFuture() 82 | } 83 | maybeUser <- ZIO.succeed(parseDocumentToUser(document)) 84 | yield maybeUser 85 | 86 | private def parseDocumentToUser(doc: Document) = 87 | Option(doc).fold(None) { doc => 88 | Some( 89 | User( 90 | id = doc("id").asInt64.getValue, 91 | name = doc("name").asString.getValue, 92 | email = doc("email").asString.getValue, 93 | password = doc("password").asString.getValue 94 | ) 95 | ) 96 | } 97 | 98 | 99 | object UserRepositoryLive: 100 | 101 | lazy val layer: URLayer[DataSource, UserRepository] = ZLayer.fromFunction(UserRepositoryLive.apply) 102 | -------------------------------------------------------------------------------- /src/main/scala/db/note/NotesRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package db.note 2 | 3 | import db.* 4 | import db.DbError.InvalidId 5 | import db.Repository.* 6 | import db.mongo.{DataSource, DatabaseContext, MongoDatabaseInitializer} 7 | import domain.Domain.Note 8 | import org.mongodb.scala.* 9 | import org.mongodb.scala.model.Filters.* 10 | import org.mongodb.scala.result.{DeleteResult, InsertOneResult} 11 | import zio.* 12 | import zio.json.* 13 | 14 | import java.time.Instant 15 | import java.util.Date 16 | import scala.collection.mutable.ListBuffer 17 | import scala.util.Random 18 | 19 | final case class NotesRepositoryLive(dataSource: DataSource) extends NotesRepository: 20 | 21 | private val mongo: UIO[MongoDatabase] = dataSource.getCtx.map(_.mongoDatabase.get) 22 | 23 | override def getById(id: Long): Task[Option[Note]] = 24 | for 25 | db <- mongo 26 | document <- ZIO.fromFuture { implicit ec => 27 | db.getCollection("notes") 28 | .find(equal("id", id)) 29 | .first() 30 | .toFuture() 31 | } 32 | maybeNote <- ZIO.attempt(parseDocumentToNote(document)) 33 | yield maybeNote 34 | 35 | override def getAll: Task[List[Note]] = 36 | for 37 | db <- mongo 38 | documents <- ZIO.fromFuture { implicit ec => 39 | db.getCollection("notes") 40 | .find() 41 | .toFuture() 42 | } 43 | notes <- ZIO.attempt(parseDocumentsToNoteList(documents)) 44 | yield notes 45 | 46 | override def update(id: Long, newNote: Note): Task[DBResult] = 47 | for 48 | db <- mongo 49 | updateResult <- ZIO.fromFuture { implicit ec => 50 | db.getCollection("notes") 51 | .replaceOne(equal("id", id), Document(newNote.toJson)) 52 | .toFuture() 53 | } 54 | updateStatus <- updateResult.fold(updateResult.getModifiedCount == 1, DbSuccess.Updated(s"Note with id $id has been updated"), DbError.InvalidId(s"Note with id $id has not been updated")) 55 | yield updateStatus 56 | 57 | override def delete(noteId: Long): Task[DBResult] = 58 | for 59 | db <- mongo 60 | deleteResult <- ZIO.fromFuture { implicit ec => 61 | db.getCollection("notes") 62 | .deleteOne(equal("id", noteId)) 63 | .toFuture() 64 | } 65 | deletionStatus <- deleteResult.fold(deleteResult.getDeletedCount == 1, DbSuccess.Deleted(s"Note with id $noteId has been deleted"), DbError.InvalidId(s"Could not delete Note. Note with id: $noteId does not exist")) 66 | yield deletionStatus 67 | 68 | override def add(note: Note): Task[DBResult] = 69 | for 70 | db <- mongo 71 | noteWithId <- ZIO.succeed(note.copy(id = Some(scala.util.Random.nextLong(Long.MaxValue)))) 72 | insertResult <- ZIO.fromFuture { implicit ec => 73 | db.getCollection("notes") 74 | .insertOne(Document(noteWithId.toJson)) 75 | .toFuture() 76 | } 77 | insertionStatus <- insertResult.fold(insertResult.wasAcknowledged, DbSuccess.Created("Note has been created"), DbError.ReasonUnknown("Note has not been added")) 78 | yield insertionStatus 79 | 80 | override def getNotesByUserId(userId: Long): Task[List[Note]] = 81 | for 82 | db <- mongo 83 | documents <- ZIO.fromFuture { implicit ec => 84 | db.getCollection("notes") 85 | .find(equal("userId", userId)) 86 | .toFuture() 87 | } 88 | notes <- ZIO.succeed(parseDocumentsToNoteList(documents)) 89 | yield notes 90 | 91 | override def getNoteByIdAndUserId(id: Long, userId: Long): Task[Option[Note]] = 92 | for 93 | db <- mongo 94 | document <- ZIO.fromFuture { implicit ec => 95 | db.getCollection("notes") 96 | .find(and(equal("id", id), equal("userId", userId))) 97 | .first() 98 | .toFuture() 99 | } 100 | note <- ZIO.attempt(parseDocumentToNote(document)) 101 | yield note 102 | 103 | override def deleteNoteByIdAndUserId(noteId: Long, userId: Long): Task[DBResult] = 104 | for 105 | db <- mongo 106 | deleteResult <- ZIO.fromFuture { implicit ec => 107 | db.getCollection("notes") 108 | .deleteOne(and(equal("id", noteId), equal("userId", userId))) 109 | .toFuture() 110 | } 111 | deletionStatus <- deleteResult.fold(deleteResult.getDeletedCount == 1, DbSuccess.Deleted(s"Note with id '$noteId' has been deleted"), DbError.InvalidId(s"Note with `$noteId` does not exist")) 112 | yield deletionStatus 113 | 114 | private def parseDocumentToNote(document: Document): Option[Note] = 115 | Option(document).fold(None)(doc => Some(buildNoteWithoutUserId(doc))) 116 | 117 | private def buildNoteWithoutUserId(doc: Document): Note = 118 | Note( 119 | id = doc("id").asInt64.getValue, 120 | title = doc("title").asString.getValue, 121 | body = doc("body").asString.getValue, 122 | createdAt = doc("createdAt").asString.getValue 123 | ) 124 | 125 | private def parseDocumentsToNoteList(documents: Seq[Document]): List[Note] = documents.map(parseDocumentToNote).toList.flatten 126 | 127 | object NotesRepositoryLive: 128 | 129 | lazy val layer: URLayer[DataSource, NotesRepository] = ZLayer.fromFunction(NotesRepositoryLive.apply) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### This is the backend application which enables users to perform CRUD operations on the notes. A note is a simple data structure which consists of a title, body and a few other fields. The app is built in the style of a RESTful API that enables devs to plug in multiple clients in their favorite language/framework/stack (browser, mobile, desktop etc..) 2 | 3 | ### The app has no integration tests because I have a full time job.. LOL (might add them later though) 4 | --- 5 | #### Below you will see the typical flow of the app usage: 6 | 1) User registration 7 | - User registers with email and password 8 | - User email must be unique 9 | 2) User login 10 | - User must log in to get back a JSON Web Token (JWT) 11 | - JWT will be used for accessing protected routes 12 | 3) Display User notes 13 | - User can fetch all notes which he/she has ever created/updated 14 | 4) Create new User note 15 | - User can create a new note with specified title and body 16 | 5) Modify/Delete existing User notes 17 | - User can change the fields of the specific note, or delete it at all 18 | 6) Search/Sort User notes 19 | - User can apply exact/non-exact searching and ascending/desending sorting to his/her notes 20 | 21 | #### Let's discuss step by step what each step does and how the HTTP Request/Response, API Endpoint and related things look like: 22 | --- 23 | 1) ### User registration 24 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 25 | | ------------------------------------- | ----------- | ---------------- | --------------------------- | --------------------------------- | 26 | | http://localhost:8080/api/user/signup | POST | application/json | User has been created (200) | User already exists (409) | 27 | 28 | #### Request Body: 29 | ```json 30 | { 31 | "name": "Nika", 32 | "email": "nika@gmail.com", 33 | "password": "my-strong-pass" 34 | } 35 | ``` 36 | --- 37 | 2) ### User login 38 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 39 | | ------------------------------------- | ----------- | ---------------- | ---------------------------- | --------------------------------- | 40 | | http://localhost:8080/api/user/login | POST | application/json | ```{"token": "JWT"}``` (200) | Auth failed (401) | 41 | 42 | #### Request Body: 43 | ```json 44 | { 45 | "email": "nika@gmail.com", 46 | "password": "my-strong-pass" 47 | } 48 | ``` 49 | --- 50 | 3) ### Display User notes 51 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 52 | | ------------------------------- | ----------- | ---------------- | ---------------------------- | --------------------------------- | 53 | | http://localhost:8080/api/notes | GET | application/json | Notes in JSON format (200) | Auth failed (401) | 54 | 55 | ##### hint: use the real JWT returend upon the successful login 56 | #### Headers: 57 | ``` 58 | Authorization: Bearer JWT 59 | ``` 60 | #### Typical response: 61 | ```json 62 | [ 63 | { 64 | "id": 7159997665991673534, 65 | "title": "I love Scala", 66 | "body": "Scala rocks (most definitely)", 67 | "createdAt": "09-16-2022" 68 | }, 69 | { 70 | "id": 5746959445480553359, 71 | "title": "I kinda like Java", 72 | "body": "Java rocks (kinda)", 73 | "createdAt": "09-16-2022" 74 | }, 75 | { 76 | "id": 3672367746746389626, 77 | "title": "I completely hate Python", 78 | "body": "Python sucks (yep yep)", 79 | "createdAt": "09-16-2022" 80 | } 81 | ] 82 | ``` 83 | --- 84 | 4) ### Create new Note 85 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 86 | | ------------------------------- | ----------- | ---------------- | ---------------------------- | --------------------------------- | 87 | | http://localhost:8080/api/notes | POST | application/json | Note has been created (200) | Auth failed (401) | 88 | 89 | ##### hint: use the real JWT returend upon the successful login 90 | #### Headers: 91 | ``` 92 | Authorization: Bearer JWT 93 | ``` 94 | #### Request Body: 95 | ```json 96 | { 97 | "title": "why should I learn ZIO?", 98 | "body": "cuz [insert 5 million intelligent words here]", 99 | "createdAt": "17-09-2022" 100 | } 101 | ``` 102 | --- 103 | 5) ### Get Note by ID 104 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 105 | | ----------------------------------- | ----------- | ---------------- | ---------------------------- | --------------------------------------- | 106 | | http://localhost:8080/api/notes/:id | GET | application/json | Note in JSON format (200) | Auth failed (401) / Note does not exist | 107 | 108 | ##### hint: use the real JWT returend upon the successful login 109 | #### Headers: 110 | ``` 111 | Authorization: Bearer JWT 112 | ``` 113 | #### Typical Response: 114 | ```json 115 | { 116 | "id": 5321604607032827422, 117 | "title": "why should I learn ZIO?", 118 | "body": "cuz [insert 5 million intelligent words here]", 119 | "createdAt": "17-09-2022" 120 | } 121 | ``` 122 | --- 123 | 6) ### Delete Note by ID 124 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 125 | | ----------------------------------- | ----------- | ---------------- | ---------------------------- | --------------------------------------- | 126 | | http://localhost:8080/api/notes/:id | DELETE | application/json | Note has been deleted (200) | Auth failed (401) / Note does not exist | 127 | 128 | ##### hint: use the real JWT returend upon the successful login 129 | #### Headers: 130 | ``` 131 | Authorization: Bearer JWT 132 | ``` 133 | --- 134 | 7) ### Update Note fully 135 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 136 | | ----------------------------------- | ----------- | ---------------- | ---------------------------- | --------------------------------------- | 137 | | http://localhost:8080/api/notes/:id | PUT | application/json | Note has been updated (200) | Auth failed (401) / Note does not exist | 138 | 139 | ##### hint: use the real JWT returend upon the successful login 140 | #### Headers: 141 | ``` 142 | Authorization: Bearer JWT 143 | ``` 144 | #### Request Body: 145 | ```json 146 | { 147 | "id": 7043231874327471104, 148 | "title": "why should I learn ZIO?!?!?!?!?!", 149 | "body": "cuz [insert 5 million intelligent words here]", 150 | "createdAt": "17-09-2022" 151 | } 152 | ``` 153 | --- 154 | 8) ### Search for a specific Note 155 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 156 | | ---------------------------------------------------- | ----------- | ---------------- | -------------------------------- | ------------------------- | 157 | | http://localhost:8080/api/notes/search?title={title} | GET | application/json | Note array in JSON format (200) | Auth failed (401) | 158 | 159 | ##### hint: use the real JWT returend upon the successful login 160 | #### Headers: 161 | ``` 162 | Authorization: Bearer JWT 163 | ``` 164 | #### Typical response: 165 | ```json 166 | [ 167 | { 168 | "id": 7159997665991673534, 169 | "title": "I love Scala", 170 | "body": "Scala rocks (most definitely)", 171 | "createdAt": "09-16-2022" 172 | }, 173 | { 174 | "id": 5746959445480553359, 175 | "title": "I kinda like Java", 176 | "body": "Java rocks (kinda)", 177 | "createdAt": "09-16-2022" 178 | }, 179 | { 180 | "id": 3672367746746389626, 181 | "title": "I completely hate Python", 182 | "body": "Python sucks (yep yep)", 183 | "createdAt": "09-16-2022" 184 | } 185 | ] 186 | ``` 187 | --- 188 | 9) ### Sort notes by title 189 | | Endpoint | HTTP Method | Content Type | HTTP Success (Statuscode) | HTTP Failure (Statuscode) | 190 | | ---------------------------------------------- | ----------- | ---------------- | -------------------------------- | ------------------------- | 191 | | http://localhost:8080/api/notes/sort?order=asc | GET | application/json | Note array in JSON format (200) | Auth failed (401) | 192 | 193 | ##### hint: use the real JWT returend upon the successful login 194 | #### Headers: 195 | ``` 196 | Authorization: Bearer JWT 197 | ``` 198 | #### Typical response: 199 | ```json 200 | [ 201 | { 202 | "id" : 3672367746746389626, 203 | "title" : "I completely hate Python", 204 | "body" : "Python sucks (yep yep)", 205 | "createdAt" : "09-16-2022" 206 | }, 207 | { 208 | "id" : 5746959445480553359, 209 | "title" : "I kinda like Java", 210 | "body" : "Java rocks (kinda)", 211 | "createdAt" : "09-16-2022" 212 | }, 213 | { 214 | "id" : 7159997665991673534, 215 | "title" : "I love Scala", 216 | "body" : "Scala rocks", 217 | "createdAt" : "09-16-2022" 218 | }, 219 | { 220 | "id" : 7043231874327471104, 221 | "title" : "why should I learn ZIO?", 222 | "body" : "cuz [insert 5 million intelligent words here]", 223 | "createdAt" : "17-09-2022" 224 | } 225 | ] 226 | ``` 227 | --- 228 | 229 | 230 | 231 | --------------------------------------------------------------------------------