Skip to content

Commit 9fc2b2a

Browse files
authored
Todo app example (#16)
* 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
1 parent 2048270 commit 9fc2b2a

File tree

22 files changed

+1011
-1
lines changed

22 files changed

+1011
-1
lines changed

build.sbt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,25 @@ lazy val sampleAppExample = (project in file("examples/sample-app"))
111111
)
112112
.dependsOn(core, playSqlModule)
113113

114+
lazy val todoAppExample = (project in file("examples/todo-app"))
115+
.enablePlugins(PlayScala)
116+
.settings(commonSettings)
117+
.settings(
118+
name := "todo-app-example",
119+
libraryDependencies ++= Seq(
120+
evolutions,
121+
Dependencies.anorm,
122+
Dependencies.postgres,
123+
Dependencies.jbcrypt
124+
),
125+
play.sbt.routes.RoutesKeys.routesImport := Seq(
126+
"java.util.UUID"
127+
)
128+
)
129+
.dependsOn(core, playSqlModule)
130+
114131
// Aggregate all projects
115132

116133
lazy val root: Project = project
117134
.in(file("."))
118-
.aggregate(core, sampleAppExample, playSqlModule)
135+
.aggregate(core, playSqlModule, sampleAppExample, todoAppExample)

core/src/main/scala/core/database/package.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ package object database {
4444
query: Query[Resource, M[A]]
4545
): QueryT[M, Resource, A] =
4646
QueryT[M, Resource, A](query.run)
47+
48+
def liftQuery[M[_]: Applicative, Resource, A](
49+
query: Query[Resource, A]
50+
): QueryT[M, Resource, A] =
51+
QueryT.fromQuery[M, Resource, A](
52+
query.map(implicitly[Applicative[M]].pure)
53+
)
4754
}
4855

4956
type QueryO[Resource, A] = QueryT[Option, Resource, A]

core/src/main/scala/module/sql/package.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ package object sql {
5050

5151
def fromQuery[M[_], A](query: SqlQuery[M[A]]) =
5252
QueryT.fromQuery[M, Connection, A](query)
53+
54+
def liftQuery[M[_]: Applicative, A](query: SqlQuery[A]) =
55+
QueryT.liftQuery[M, Connection, A](query)
5356
}
5457

5558
type SqlQueryO[A] = QueryO[Connection, A]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.zengularity.querymonad.examples.todoapp.controller
2+
3+
import java.util.UUID
4+
5+
import scala.concurrent.{ExecutionContext, Future}
6+
import scala.util.Try
7+
8+
import play.api.mvc._
9+
10+
trait Authentication { self: BaseController =>
11+
12+
implicit def ec: ExecutionContext
13+
14+
case class ConnectedUserInfo(
15+
id: UUID,
16+
login: String
17+
)
18+
19+
case class ConnectedUserRequest[A](
20+
userInfo: ConnectedUserInfo,
21+
request: Request[A]
22+
) extends WrappedRequest[A](request)
23+
24+
def ConnectedAction = Action andThen ConnectionRefiner
25+
26+
private def ConnectionRefiner =
27+
new ActionRefiner[Request, ConnectedUserRequest] {
28+
def executionContext = ec
29+
def refine[A](request: Request[A]) =
30+
Future.successful(
31+
request.session
32+
.get("id")
33+
.flatMap(str => Try(UUID.fromString(str)).toOption)
34+
.zip(request.session.get("login"))
35+
.headOption
36+
.map {
37+
case (id, login) =>
38+
ConnectedUserRequest(ConnectedUserInfo(id, login), request)
39+
}
40+
.toRight(Unauthorized("Missing credentials"))
41+
)
42+
}
43+
44+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.zengularity.querymonad.examples.todoapp.controller
2+
3+
import java.util.UUID
4+
5+
import scala.concurrent.{ExecutionContext, Future}
6+
7+
import cats.instances.either._
8+
import play.api.mvc._
9+
import play.api.libs.json.Json
10+
11+
import com.zengularity.querymonad.examples.todoapp.controller.model.AddTodoPayload
12+
import com.zengularity.querymonad.examples.todoapp.model.{Todo, User}
13+
import com.zengularity.querymonad.examples.todoapp.store.{TodoStore, UserStore}
14+
import com.zengularity.querymonad.module.sql.{SqlQueryRunner, SqlQueryT}
15+
16+
class TodoController(
17+
runner: SqlQueryRunner,
18+
todoStore: TodoStore,
19+
userStore: UserStore,
20+
cc: ControllerComponents
21+
)(implicit val ec: ExecutionContext)
22+
extends AbstractController(cc)
23+
with Authentication {
24+
25+
type ErrorOrResult[A] = Either[String, A]
26+
27+
private def check(
28+
login: String
29+
)(block: => Future[Result])(implicit request: ConnectedUserRequest[_]) = {
30+
if (request.userInfo.login == login)
31+
block
32+
else
33+
Future.successful(BadRequest("Not authorized action"))
34+
}
35+
36+
def addTodo(login: String): Action[AddTodoPayload] =
37+
ConnectedAction.async(parse.json[AddTodoPayload]) { implicit request =>
38+
check(login) {
39+
val payload = request.body
40+
val todo = AddTodoPayload.toModel(payload)(UUID.randomUUID(),
41+
request.userInfo.id)
42+
val query = for {
43+
_ <- SqlQueryT.fromQuery[ErrorOrResult, Unit](
44+
todoStore.getByNumber(todo.todoNumber).map {
45+
case Some(_) => Left("Todo already exists")
46+
case None => Right(())
47+
}
48+
)
49+
_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](
50+
todoStore.addTodo(todo)
51+
)
52+
} yield ()
53+
54+
runner(query).map {
55+
case Right(_) => NoContent
56+
case Left(description) => BadRequest(description)
57+
}
58+
}
59+
}
60+
61+
def getTodo(login: String, todoId: UUID): Action[AnyContent] =
62+
ConnectedAction.async { implicit request =>
63+
check(login) {
64+
runner(todoStore.getTodo(todoId)).map {
65+
case Some(todo) => Ok(Json.toJson(todo))
66+
case None => NotFound
67+
}
68+
}
69+
}
70+
71+
def listTodo(login: String): Action[AnyContent] =
72+
ConnectedAction.async { implicit request =>
73+
check(login) {
74+
val query = for {
75+
user <- SqlQueryT.fromQuery[ErrorOrResult, User](
76+
userStore.getByLogin(login).map(_.toRight("User doesn't exist"))
77+
)
78+
todo <- SqlQueryT.liftQuery[ErrorOrResult, List[Todo]](
79+
todoStore.listTodo(user.id)
80+
)
81+
} yield todo
82+
83+
runner(query).map {
84+
case Right(todos) => Ok(Json.toJson(todos))
85+
case Left(description) => BadRequest(description)
86+
}
87+
}
88+
}
89+
90+
def removeTodo(login: String, todoId: UUID): Action[AnyContent] =
91+
ConnectedAction.async { implicit request =>
92+
check(login) {
93+
val query = for {
94+
- <- SqlQueryT.fromQuery[ErrorOrResult, Todo](
95+
todoStore
96+
.getTodo(todoId)
97+
.map(_.toRight("Todo doesn't exist"))
98+
)
99+
_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](
100+
todoStore.removeTodo(todoId)
101+
)
102+
} yield ()
103+
104+
runner(query).map {
105+
case Right(_) => NoContent
106+
case Left(description) => BadRequest(description)
107+
}
108+
}
109+
}
110+
111+
def completeTodo(login: String, todoId: UUID): Action[AnyContent] =
112+
ConnectedAction.async { implicit request =>
113+
check(login) {
114+
runner(todoStore.completeTodo(todoId)).map {
115+
case Some(todo) => Ok(Json.toJson(todo))
116+
case None => NotFound("The todo doesn't exist")
117+
}
118+
}
119+
}
120+
121+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.zengularity.querymonad.examples.todoapp.controller
2+
3+
import java.nio.charset.Charset
4+
import java.util.{Base64, UUID}
5+
6+
import scala.concurrent.{ExecutionContext, Future}
7+
8+
import cats.instances.either._
9+
import play.api.mvc._
10+
import play.api.libs.json.Json
11+
12+
import com.zengularity.querymonad.examples.todoapp.controller.model.AddUserPayload
13+
import com.zengularity.querymonad.examples.todoapp.model.{Credential, User}
14+
import com.zengularity.querymonad.examples.todoapp.store.{
15+
CredentialStore,
16+
UserStore
17+
}
18+
import com.zengularity.querymonad.module.sql.{SqlQueryRunner, SqlQueryT}
19+
20+
class UserController(
21+
runner: SqlQueryRunner,
22+
store: UserStore,
23+
credentialStore: CredentialStore,
24+
cc: ControllerComponents
25+
)(implicit val ec: ExecutionContext)
26+
extends AbstractController(cc)
27+
with Authentication {
28+
29+
type ErrorOrResult[A] = Either[String, A]
30+
31+
def createUser: Action[AddUserPayload] =
32+
Action(parse.json[AddUserPayload]).async { implicit request =>
33+
val payload = request.body
34+
val query = for {
35+
_ <- SqlQueryT.fromQuery[ErrorOrResult, Unit](
36+
store.getByLogin(payload.login).map {
37+
case Some(_) => Left("User already exists")
38+
case None => Right(())
39+
}
40+
)
41+
42+
user = AddUserPayload.toModel(payload)(UUID.randomUUID())
43+
credential = AddUserPayload.toCredential(payload)
44+
45+
_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](
46+
credentialStore.saveCredential(credential)
47+
)
48+
_ <- SqlQueryT.liftQuery[ErrorOrResult, Unit](store.createUser(user))
49+
} yield ()
50+
51+
runner(query).map {
52+
case Right(_) => NoContent
53+
case Left(description) => BadRequest(description)
54+
}
55+
}
56+
57+
def getUser(userId: UUID): Action[AnyContent] = ConnectedAction.async {
58+
request =>
59+
if (request.userInfo.id == userId)
60+
runner(store.getUser(userId)).map {
61+
case Some(user) => Ok(Json.toJson(user))
62+
case None => NotFound("The user doesn't exist")
63+
} else
64+
Future.successful(NotFound("Cannot operate this action"))
65+
}
66+
67+
def deleteUser(userId: UUID): Action[AnyContent] = ConnectedAction.async {
68+
request =>
69+
val userInfo = request.userInfo
70+
if (userInfo.id == userId) {
71+
val query = for {
72+
_ <- credentialStore.deleteCredentials(userInfo.login)
73+
_ <- store.deleteUser(userId)
74+
} yield ()
75+
runner(query).map(_ => NoContent.withNewSession)
76+
} else
77+
Future.successful(BadRequest("Cannot operate this action"))
78+
}
79+
80+
def login: Action[AnyContent] = Action.async { implicit request =>
81+
val authHeaderOpt = request.headers
82+
.get("Authorization")
83+
.map(_.substring("Basic".length()).trim())
84+
85+
val query = for {
86+
credential <- SqlQueryT.liftF[ErrorOrResult, Credential](
87+
authHeaderOpt
88+
.map { encoded =>
89+
val decoded = Base64.getDecoder().decode(encoded)
90+
val authStr = new String(decoded, Charset.forName("UTF-8"))
91+
authStr.split(':').toList
92+
}
93+
.collect {
94+
case login :: password :: _ => Credential(login, password)
95+
}
96+
.toRight("Missing credentials")
97+
)
98+
99+
exists <- SqlQueryT.liftQuery[ErrorOrResult, Boolean](
100+
credentialStore.check(credential)
101+
)
102+
103+
user <- {
104+
if (exists)
105+
SqlQueryT.fromQuery[ErrorOrResult, User](
106+
store
107+
.getByLogin(credential.login)
108+
.map(_.toRight("The user doesn't exist"))
109+
)
110+
else
111+
SqlQueryT.liftF[ErrorOrResult, User](Left("Wrong credentials"))
112+
}
113+
} yield user
114+
115+
runner(query).map {
116+
case Right(user) =>
117+
NoContent.withSession("id" -> user.id.toString, "login" -> user.login)
118+
case Left(description) => BadRequest(description).withNewSession
119+
}
120+
}
121+
122+
def logout: Action[AnyContent] = ConnectedAction {
123+
NoContent.withNewSession
124+
}
125+
126+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.zengularity.querymonad.examples.todoapp.controller.model
2+
3+
import java.util.UUID
4+
5+
import play.api.libs.json.{Json, Reads}
6+
7+
import com.zengularity.querymonad.examples.todoapp.model.Todo
8+
9+
case class AddTodoPayload(
10+
todoNumber: Int,
11+
content: String,
12+
done: Boolean
13+
)
14+
15+
object AddTodoPayload {
16+
17+
def toModel(payload: AddTodoPayload)(id: UUID, userId: UUID): Todo = Todo(
18+
id,
19+
payload.todoNumber,
20+
payload.content,
21+
userId,
22+
payload.done
23+
)
24+
25+
implicit val format: Reads[AddTodoPayload] = Json.reads
26+
}

0 commit comments

Comments
 (0)