Skip to content

Commit

Permalink
Todo app example (#16)
Browse files Browse the repository at this point in the history
* WIP: add new project example + add QueryT combinator

* Replace h2 by postgres

* Example app: refactor + postman collection
- Add more checks for POST actions
- Better business case error handling
- Add route to list todos
- Add postman collection for api usage

* WIP: Add auth + add dependent actions

* Update postman collection + Add password hash + fix some actions

* Update postman collection

* Remove adminer + refactor authentication trait
  • Loading branch information
stankoua authored Nov 29, 2018
1 parent 2048270 commit 9fc2b2a
Show file tree
Hide file tree
Showing 22 changed files with 1,011 additions and 1 deletion.
19 changes: 18 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,25 @@ lazy val sampleAppExample = (project in file("examples/sample-app"))
)
.dependsOn(core, playSqlModule)

lazy val todoAppExample = (project in file("examples/todo-app"))
.enablePlugins(PlayScala)
.settings(commonSettings)
.settings(
name := "todo-app-example",
libraryDependencies ++= Seq(
evolutions,
Dependencies.anorm,
Dependencies.postgres,
Dependencies.jbcrypt
),
play.sbt.routes.RoutesKeys.routesImport := Seq(
"java.util.UUID"
)
)
.dependsOn(core, playSqlModule)

// Aggregate all projects

lazy val root: Project = project
.in(file("."))
.aggregate(core, sampleAppExample, playSqlModule)
.aggregate(core, playSqlModule, sampleAppExample, todoAppExample)
7 changes: 7 additions & 0 deletions core/src/main/scala/core/database/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ package object database {
query: Query[Resource, M[A]]
): QueryT[M, Resource, A] =
QueryT[M, Resource, A](query.run)

def liftQuery[M[_]: Applicative, Resource, A](
query: Query[Resource, A]
): QueryT[M, Resource, A] =
QueryT.fromQuery[M, Resource, A](
query.map(implicitly[Applicative[M]].pure)
)
}

type QueryO[Resource, A] = QueryT[Option, Resource, A]
Expand Down
3 changes: 3 additions & 0 deletions core/src/main/scala/module/sql/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ package object sql {

def fromQuery[M[_], A](query: SqlQuery[M[A]]) =
QueryT.fromQuery[M, Connection, A](query)

def liftQuery[M[_]: Applicative, A](query: SqlQuery[A]) =
QueryT.liftQuery[M, Connection, A](query)
}

type SqlQueryO[A] = QueryO[Connection, A]
Expand Down
44 changes: 44 additions & 0 deletions examples/todo-app/app/controller/Authentication.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.zengularity.querymonad.examples.todoapp.controller

import java.util.UUID

import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try

import play.api.mvc._

trait Authentication { self: BaseController =>

implicit def ec: ExecutionContext

case class ConnectedUserInfo(
id: UUID,
login: String
)

case class ConnectedUserRequest[A](
userInfo: ConnectedUserInfo,
request: Request[A]
) extends WrappedRequest[A](request)

def ConnectedAction = Action andThen ConnectionRefiner

private def ConnectionRefiner =
new ActionRefiner[Request, ConnectedUserRequest] {
def executionContext = ec
def refine[A](request: Request[A]) =
Future.successful(
request.session
.get("id")
.flatMap(str => Try(UUID.fromString(str)).toOption)
.zip(request.session.get("login"))
.headOption
.map {
case (id, login) =>
ConnectedUserRequest(ConnectedUserInfo(id, login), request)
}
.toRight(Unauthorized("Missing credentials"))
)
}

}
121 changes: 121 additions & 0 deletions examples/todo-app/app/controller/TodoController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.zengularity.querymonad.examples.todoapp.controller

import java.util.UUID

import scala.concurrent.{ExecutionContext, Future}

import cats.instances.either._
import play.api.mvc._
import play.api.libs.json.Json

import com.zengularity.querymonad.examples.todoapp.controller.model.AddTodoPayload
import com.zengularity.querymonad.examples.todoapp.model.{Todo, User}
import com.zengularity.querymonad.examples.todoapp.store.{TodoStore, UserStore}
import com.zengularity.querymonad.module.sql.{SqlQueryRunner, SqlQueryT}

class TodoController(
runner: SqlQueryRunner,
todoStore: TodoStore,
userStore: UserStore,
cc: ControllerComponents
)(implicit val ec: ExecutionContext)
extends AbstractController(cc)
with Authentication {

type ErrorOrResult[A] = Either[String, A]

private def check(
login: String
)(block: => Future[Result])(implicit request: ConnectedUserRequest[_]) = {
if (request.userInfo.login == login)
block
else
Future.successful(BadRequest("Not authorized action"))
}

def addTodo(login: String): Action[AddTodoPayload] =
ConnectedAction.async(parse.json[AddTodoPayload]) { implicit request =>
check(login) {
val payload = request.body
val todo = AddTodoPayload.toModel(payload)(UUID.randomUUID(),
request.userInfo.id)
val query = for {
_ <- SqlQueryT.fromQuery[ErrorOrResult, Unit](
todoStore.getByNumber(todo.todoNumber).map {
case Some(_) => Left("Todo already exists")
case None => Right(())
}
)
_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](
todoStore.addTodo(todo)
)
} yield ()

runner(query).map {
case Right(_) => NoContent
case Left(description) => BadRequest(description)
}
}
}

def getTodo(login: String, todoId: UUID): Action[AnyContent] =
ConnectedAction.async { implicit request =>
check(login) {
runner(todoStore.getTodo(todoId)).map {
case Some(todo) => Ok(Json.toJson(todo))
case None => NotFound
}
}
}

def listTodo(login: String): Action[AnyContent] =
ConnectedAction.async { implicit request =>
check(login) {
val query = for {
user <- SqlQueryT.fromQuery[ErrorOrResult, User](
userStore.getByLogin(login).map(_.toRight("User doesn't exist"))
)
todo <- SqlQueryT.liftQuery[ErrorOrResult, List[Todo]](
todoStore.listTodo(user.id)
)
} yield todo

runner(query).map {
case Right(todos) => Ok(Json.toJson(todos))
case Left(description) => BadRequest(description)
}
}
}

def removeTodo(login: String, todoId: UUID): Action[AnyContent] =
ConnectedAction.async { implicit request =>
check(login) {
val query = for {
- <- SqlQueryT.fromQuery[ErrorOrResult, Todo](
todoStore
.getTodo(todoId)
.map(_.toRight("Todo doesn't exist"))
)
_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](
todoStore.removeTodo(todoId)
)
} yield ()

runner(query).map {
case Right(_) => NoContent
case Left(description) => BadRequest(description)
}
}
}

def completeTodo(login: String, todoId: UUID): Action[AnyContent] =
ConnectedAction.async { implicit request =>
check(login) {
runner(todoStore.completeTodo(todoId)).map {
case Some(todo) => Ok(Json.toJson(todo))
case None => NotFound("The todo doesn't exist")
}
}
}

}
126 changes: 126 additions & 0 deletions examples/todo-app/app/controller/UserController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.zengularity.querymonad.examples.todoapp.controller

import java.nio.charset.Charset
import java.util.{Base64, UUID}

import scala.concurrent.{ExecutionContext, Future}

import cats.instances.either._
import play.api.mvc._
import play.api.libs.json.Json

import com.zengularity.querymonad.examples.todoapp.controller.model.AddUserPayload
import com.zengularity.querymonad.examples.todoapp.model.{Credential, User}
import com.zengularity.querymonad.examples.todoapp.store.{
CredentialStore,
UserStore
}
import com.zengularity.querymonad.module.sql.{SqlQueryRunner, SqlQueryT}

class UserController(
runner: SqlQueryRunner,
store: UserStore,
credentialStore: CredentialStore,
cc: ControllerComponents
)(implicit val ec: ExecutionContext)
extends AbstractController(cc)
with Authentication {

type ErrorOrResult[A] = Either[String, A]

def createUser: Action[AddUserPayload] =
Action(parse.json[AddUserPayload]).async { implicit request =>
val payload = request.body
val query = for {
_ <- SqlQueryT.fromQuery[ErrorOrResult, Unit](
store.getByLogin(payload.login).map {
case Some(_) => Left("User already exists")
case None => Right(())
}
)

user = AddUserPayload.toModel(payload)(UUID.randomUUID())
credential = AddUserPayload.toCredential(payload)

_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](
credentialStore.saveCredential(credential)
)
_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](store.createUser(user))
} yield ()

runner(query).map {
case Right(_) => NoContent
case Left(description) => BadRequest(description)
}
}

def getUser(userId: UUID): Action[AnyContent] = ConnectedAction.async {
request =>
if (request.userInfo.id == userId)
runner(store.getUser(userId)).map {
case Some(user) => Ok(Json.toJson(user))
case None => NotFound("The user doesn't exist")
} else
Future.successful(NotFound("Cannot operate this action"))
}

def deleteUser(userId: UUID): Action[AnyContent] = ConnectedAction.async {
request =>
val userInfo = request.userInfo
if (userInfo.id == userId) {
val query = for {
_ <- credentialStore.deleteCredentials(userInfo.login)
_ <- store.deleteUser(userId)
} yield ()
runner(query).map(_ => NoContent.withNewSession)
} else
Future.successful(BadRequest("Cannot operate this action"))
}

def login: Action[AnyContent] = Action.async { implicit request =>
val authHeaderOpt = request.headers
.get("Authorization")
.map(_.substring("Basic".length()).trim())

val query = for {
credential <- SqlQueryT.liftF[ErrorOrResult, Credential](
authHeaderOpt
.map { encoded =>
val decoded = Base64.getDecoder().decode(encoded)
val authStr = new String(decoded, Charset.forName("UTF-8"))
authStr.split(':').toList
}
.collect {
case login :: password :: _ => Credential(login, password)
}
.toRight("Missing credentials")
)

exists <- SqlQueryT.liftQuery[ErrorOrResult, Boolean](
credentialStore.check(credential)
)

user <- {
if (exists)
SqlQueryT.fromQuery[ErrorOrResult, User](
store
.getByLogin(credential.login)
.map(_.toRight("The user doesn't exist"))
)
else
SqlQueryT.liftF[ErrorOrResult, User](Left("Wrong credentials"))
}
} yield user

runner(query).map {
case Right(user) =>
NoContent.withSession("id" -> user.id.toString, "login" -> user.login)
case Left(description) => BadRequest(description).withNewSession
}
}

def logout: Action[AnyContent] = ConnectedAction {
NoContent.withNewSession
}

}
26 changes: 26 additions & 0 deletions examples/todo-app/app/controller/model/AddTodoPayload.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.zengularity.querymonad.examples.todoapp.controller.model

import java.util.UUID

import play.api.libs.json.{Json, Reads}

import com.zengularity.querymonad.examples.todoapp.model.Todo

case class AddTodoPayload(
todoNumber: Int,
content: String,
done: Boolean
)

object AddTodoPayload {

def toModel(payload: AddTodoPayload)(id: UUID, userId: UUID): Todo = Todo(
id,
payload.todoNumber,
payload.content,
userId,
payload.done
)

implicit val format: Reads[AddTodoPayload] = Json.reads
}
Loading

0 comments on commit 9fc2b2a

Please sign in to comment.